Introduction

This report looks at the NBA Players dataset from Kaggle, put together by Justinas Cirtautas using data from the official NBA Stats API. Each row is one player in one season, so a player who played ten seasons shows up ten times. The file covers every season from 1996-97 through 2022-23.

The dataset has three main types of information:

  • Biographical details: age, height (cm), weight (kg), college and country.
  • Draft information: draft year, draft round and pick number.
  • Season performance: games played and per-game averages for points, rebounds and assists, plus advanced stats like net rating, usage rate (how many team plays a player finishes while on the floor), true shooting percentage (scoring efficiency) and assist percentage.

I picked this dataset because it mixes categorical variables (draft round, country) with lots of numeric ones, which works well for showing different kinds of charts. It also covers almost 30 seasons, so it’s possible to look at how the league has changed over time.

import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns

sns.set_theme(style="whitegrid")
pd.set_option("display.max_columns", None)  # show every column when printing
pd.set_option("display.width", 200)
# Load the data (all_seasons.csv must be in the same folder as this file)
nba = pd.read_csv("all_seasons.csv")

# Drop the leftover row-number column from the original export
nba = nba.loc[:, ~nba.columns.str.startswith("Unnamed")]

print(nba.shape)
## (12844, 21)
print(nba.head())
##         player_name team_abbreviation   age  player_height  player_weight                college country draft_year draft_round draft_number  gp   pts  reb  ast  net_rating  oreb_pct  dreb_pct  \
## 0  Randy Livingston               HOU  22.0         193.04      94.800728        Louisiana State     USA       1996           2           42  64   3.9  1.5  2.4         0.3     0.042     0.071   
## 1  Gaylon Nickerson               WAS  28.0         190.50      86.182480  Northwestern Oklahoma     USA       1994           2           34   4   3.8  1.3  0.3         8.9     0.030     0.111   
## 2      George Lynch               VAN  26.0         203.20     103.418976         North Carolina     USA       1993           1           12  41   8.3  6.4  1.9        -8.2     0.106     0.185   
## 3    George McCloud               LAL  30.0         203.20     102.058200          Florida State     USA       1989           1            7  64  10.2  2.8  1.7        -2.7     0.027     0.111   
## 4      George Zidek               DEN  23.0         213.36     119.748288                   UCLA     USA       1995           1           22  52   2.8  1.7  0.3       -14.1     0.102     0.169   
## 
##    usg_pct  ts_pct  ast_pct   season  
## 0    0.169   0.487    0.248  1996-97  
## 1    0.174   0.497    0.043  1996-97  
## 2    0.175   0.512    0.125  1996-97  
## 3    0.206   0.527    0.125  1996-97  
## 4    0.195   0.500    0.064  1996-97

Data Preparation

The data was already fairly clean, but a few changes made it easier to work with:

  1. Dropped the unnamed index column. The CSV includes an extra row-number column left over from when it was saved, so I removed it.
  2. Turned the season into a number. Seasons are stored as text like “1996-97”, so I kept the first four characters as a year (1996) to make plotting over time easier.
  3. Grouped draft round into three categories: 1st Round, 2nd Round and Undrafted. A few early seasons have unusual draft-round values, and those were left out of the draft comparison.
  4. Flagged international players. Any player whose country isn’t “USA” is marked as international.
  5. Made a “regulars” subset. Players who appeared in only a handful of games can have extreme per-game averages (for example, 20 points in one game). For the violin plot, scatter plot and heatmap I only used player-seasons with at least 20 games played, which leaves 10,720 rows.
# Season comes in as text like "1996-97" -> keep the starting year as a number
nba["season_start"] = nba["season"].str[:4].astype(int)

# Group draft round into three categories
nba["draft_status"] = nba["draft_round"].astype(str).map(
    {"1": "1st Round", "2": "2nd Round", "Undrafted": "Undrafted"}
)

# Flag international players
nba["international"] = nba["country"] != "USA"

# Players with very few games make per-game averages noisy,
# so some plots below only use player-seasons with 20+ games
regulars = nba[nba["gp"] >= 20]

print("Total player-seasons:", len(nba))
## Total player-seasons: 12844
print("Player-seasons with 20+ games:", len(regulars))
## Player-seasons with 20+ games: 10720
print("Unique players:", nba["player_name"].nunique())
## Unique players: 2551
print("Seasons covered:", nba["season"].min(), "to", nba["season"].max())
## Seasons covered: 1996-97 to 2022-23

Descriptive Statistics

The dataset has 12,844 player-seasons covering 2,551 different players. The typical player is about 27 years old, 200.6 cm tall (about 79 inches) and weighs 100.3 kg.

Scoring is very uneven. The average player scores 8.2 points per game, but the median is lower at 6.7. That means a smaller group of high scorers pulls the average up. The highest single-season average in the data is 36.1 points per game, by James Harden in 2018-19.

For the draft, there are 7,351 player-seasons from 1st-round picks, 3,032 from 2nd-round picks and 2,411 from undrafted players. So 1st-round picks take up the most roster spots, but undrafted players are still a real part of the league.

About 83.5% of all player-seasons belong to American players. The next most common country is Canada with 205 player-seasons. Among players who went to college, Kentucky and Duke produced the most NBA player-seasons.

# Summary statistics for the main numeric columns
print(nba[["age", "player_height", "player_weight", "gp",
     "pts", "reb", "ast", "ts_pct", "usg_pct"]].describe().round(2))
##             age  player_height  player_weight        gp       pts       reb       ast    ts_pct   usg_pct
## count  12844.00       12844.00       12844.00  12844.00  12844.00  12844.00  12844.00  12844.00  12844.00
## mean      27.05         200.56         100.26     51.15      8.21      3.56      1.82      0.51      0.18
## std        4.34           9.11          12.43     25.08      6.02      2.48      1.80      0.10      0.05
## min       18.00         160.02          60.33      1.00      0.00      0.00      0.00      0.00      0.00
## 25%       24.00         193.04          90.72     31.00      3.60      1.80      0.60      0.48      0.15
## 50%       26.00         200.66          99.79     57.00      6.70      3.00      1.20      0.52      0.18
## 75%       30.00         208.28         108.86     73.00     11.50      4.70      2.40      0.56      0.22
## max       44.00         231.14         163.29     85.00     36.10     16.30     11.70      1.50      1.00
# Draft status counts
print(nba["draft_status"].value_counts(dropna=False), "\n")
## draft_status
## 1st Round    7351
## 2nd Round    3032
## Undrafted    2411
## NaN            50
## Name: count, dtype: int64
# Top 10 countries by number of player-seasons
print(nba["country"].value_counts().head(10), "\n")
## country
## USA          10721
## Canada         205
## France         190
## Australia      100
## Spain           93
## Brazil          86
## Slovenia        76
## Turkey          76
## Croatia         72
## Serbia          70
## Name: count, dtype: int64
# Top 10 colleges
print(nba["college"].value_counts().head(10))
## college
## Kentucky          447
## Duke              410
## North Carolina    355
## UCLA              318
## Arizona           293
## Kansas            289
## Connecticut       235
## Michigan          197
## Georgia Tech      193
## Florida           193
## Name: count, dtype: int64

Visualization 1: Histogram – Points per Game

# Visualization 1: Histogram of points per game
fig, ax = plt.subplots(figsize=(9, 5))
ax.hist(nba["pts"], bins=40, color="#1D428A", edgecolor="white")
ax.axvline(nba["pts"].mean(), color="#C8102E", linestyle="--", linewidth=2,
           label=f"Mean = {nba['pts'].mean():.1f}")
ax.axvline(nba["pts"].median(), color="#F4A100", linewidth=2,
           label=f"Median = {nba['pts'].median():.1f}")
ax.set_title("Distribution of Points per Game")
ax.set_xlabel("Points per Game")
ax.set_ylabel("Number of Player-Seasons")
ax.legend()
plt.tight_layout()
plt.savefig("viz1_histogram_points.png", dpi=200)
plt.show()

The histogram shows how many player-seasons fall into each points-per-game range. The distribution is clearly right-skewed: most of the bars are on the left, between about 0 and 10 points, and a long, thin tail stretches out to the right toward 25 and 30+ points.

The two lines show this skew. The red dashed line (mean = 8.2) sits to the right of the orange line (median = 6.7) because the small number of star scorers pulls the average up. That’s why the median is a better measure of a “typical” NBA player’s scoring. Most players have smaller roles, and only a few are the main scoring options on their teams.

Visualization 2: Violin Plot – Points by Draft Status

# Visualization 2: Violin plot of points per game by draft status
fig, ax = plt.subplots(figsize=(9, 5))
sns.violinplot(data=regulars.dropna(subset=["draft_status"]),
               x="draft_status", y="pts",
               order=["1st Round", "2nd Round", "Undrafted"],
               hue="draft_status", legend=False,
               palette={"1st Round": "#1D428A", "2nd Round": "#5B8DD6", "Undrafted": "#A9C4EB"},
               inner="quartile", cut=0, ax=ax)
ax.set_title("Points per Game by Draft Status (players with 20+ games)")
ax.set_xlabel("")
ax.set_ylabel("Points per Game")
plt.tight_layout()
plt.savefig("viz2_violin_draft.png", dpi=200)
plt.show()

A violin plot works like a boxplot, but it also shows the shape of the data. The wider the violin is at a certain height, the more players scored that many points. The dashed line in the middle of each violin is the median, and the dotted lines are the 25th and 75th percentiles.

1st-round picks have the highest median at 9.5 points per game, and their violin stretches the highest, which means almost all of the league’s top scorers were 1st-round picks. 2nd-round picks (median 6.2) and undrafted players (median 5.2) are bunched much lower, with most of their seasons under 10 points. This makes sense, because teams use their highest picks on the players they think are best. The thin tops of the 2nd-round and undrafted violins show there are still a few players who outperformed where they were drafted.

Visualization 3: Scatter Plot – Height vs. Rebounds

# Visualization 3: Scatter plot of height vs. rebounds
fig, ax = plt.subplots(figsize=(9, 6))
ax.scatter(regulars["player_height"], regulars["reb"],
           alpha=0.15, s=10, color="#1D428A")
# Simple straight trend line
slope, intercept = np.polyfit(regulars["player_height"], regulars["reb"], 1)
x_line = np.linspace(regulars["player_height"].min(), regulars["player_height"].max(), 100)
ax.plot(x_line, slope * x_line + intercept, color="#C8102E", linewidth=2)
ax.set_title("Player Height vs. Rebounds per Game (players with 20+ games)")
ax.set_xlabel("Height (cm)")
ax.set_ylabel("Rebounds per Game")
plt.tight_layout()
plt.savefig("viz3_scatter_height_reb.png", dpi=200)
plt.show()

print("Correlation between height and rebounds:",
      round(regulars["player_height"].corr(regulars["reb"]), 2))
## Correlation between height and rebounds: 0.48

Each dot is one player-season, with height on the x-axis and rebounds per game on the y-axis. The red line is a straight trend line fitted to the data.

There’s a clear positive relationship: taller players grab more rebounds. The correlation is 0.48, a moderate positive correlation. This makes sense, since taller players play closer to the basket and can reach the ball first. The relationship isn’t perfect, though. At every height there’s a wide spread of rebounding numbers, because minutes played, position and effort matter too. Some shorter players rebound well and some tall players don’t.

Visualization 4: Line Chart – International Players Over Time

# Visualization 4: Line chart of the share of international players by season
intl_share = (nba.groupby("season_start")["international"].mean() * 100)

fig, ax = plt.subplots(figsize=(10, 5))
ax.plot(intl_share.index, intl_share.values, marker="o", color="#1D428A", linewidth=2)
ax.set_title("Share of International (Non-U.S.) Players by Season")
ax.set_xlabel("Season (starting year)")
ax.set_ylabel("% of Player-Seasons")
plt.tight_layout()
plt.savefig("viz4_line_international.png", dpi=200)
plt.show()

print("First season:", round(intl_share.iloc[0], 1), "%")
## First season: 2.0 %
print("Last season:", round(intl_share.iloc[-1], 1), "%")
## Last season: 23.4 %

This line chart shows the percentage of player-seasons each year that belonged to players from outside the United States. In 1996, international players made up only 2% of the league. By 2022, that share had grown to 23.4%.

The trend goes up steadily over time instead of jumping all at once. This lines up with the NBA’s global growth: more international scouting, stronger basketball programs in Europe, Africa, Canada and Australia, and the success of international stars encouraging teams to draft players from overseas.

Visualization 5: Correlation Heatmap

# Visualization 5: Correlation heatmap of player stats
corr_cols = ["age", "player_height", "player_weight", "gp", "pts",
             "reb", "ast", "net_rating", "usg_pct", "ts_pct", "ast_pct"]
corr_matrix = regulars[corr_cols].corr()

fig, ax = plt.subplots(figsize=(10, 8))
sns.heatmap(corr_matrix, annot=True, fmt=".2f", cmap="RdBu_r",
            vmin=-1, vmax=1, square=True, linewidths=0.5, ax=ax)
ax.set_title("Correlation Among Player Statistics (players with 20+ games)")
plt.tight_layout()
plt.savefig("viz5_heatmap_correlation.png", dpi=200)
plt.show()

The heatmap shows the correlation between every pair of statistics. Correlation goes from -1 to +1: dark red is a strong positive relationship (both go up together), dark blue is a strong negative relationship (one goes up while the other goes down), and white means no relationship. The diagonal is always 1.00 because each stat is perfectly correlated with itself.

A few things stand out:

  • Assists and assist percentage (0.87) are the strongest pair. That’s expected, because they measure almost the same skill.
  • Points and usage rate (0.76) are strongly related. Players who finish more of their team’s plays naturally score more.
  • Points and assists (0.63) are also positively related, which suggests the best players tend to both score and create for teammates.
  • Height and assist percentage (-0.65) have a clear negative relationship. Shorter players are usually guards who handle the ball, while taller players are centers who rebound, which matches the positive height–rebound result from the scatter plot.
  • Age has almost no relationship with points (-0.02), so being older or younger on its own doesn’t predict how much a player scores.

Conclusion

This analysis of 12,844 NBA player-seasons shows a few clear patterns. First, scoring is concentrated among a small number of players: the points distribution is right-skewed and the mean is higher than the median. Second, draft position matters, since 1st-round picks score noticeably more than 2nd-round or undrafted players. Third, physical traits line up with player roles: taller players rebound more, while shorter players handle more of the passing. Finally, the league has become much more international, growing from 2% international players in 1996 to 23.4% in 2022.