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:
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
The data was already fairly clean, but a few changes made it easier to work with:
# 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
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 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 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 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 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 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:
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.