Basketball Player Scouting Report
Pull real NCAA box score data and build a multi-panel scouting report.
Introduction
30 Second Summary
Every basketball coach makes decisions about who plays, how long, and in what matchups. The best decisions come from data, not gut feeling.
In this project, you will build a Python-based scouting report that pulls real NCAA box score data for Syracuse Men's Basketball, computes advanced analytics metrics used by professional basketball analysts, and produces a multi-panel visual report comparing player performance.
What You'll Build
A complete analytics pipeline that ingests live game data using CBBpy, calculates six advanced metrics with pandas and numpy, and outputs a polished matplotlib scouting report ready for a coaching staff.
By the end of this project, you'll have:
- A data ingestion script using CBBpy to pull season-long box scores for Syracuse Men's Basketball.
- A pandas-based analytics pipeline computing 6 advanced metrics including True Shooting %, Effective FG %, and assist-to-turnover ratio.
- A multi-panel matplotlib scouting report with 4 visualizations comparing player performance across key dimensions.
- A reusable codebase parameterized by team name and season, so you can generate a report for any NCAA D1 team.
- Secret Mission: Add an opponent scouting comparison that overlays a selected opponent's metrics against Syracuse to identify matchup advantages.
Are there any prerequisites?
This project assumes you are familiar with Python, pandas, matplotlib, and numpy. You should also have Python and PyCharm installed on your machine.
Set Up Your Project and Install Dependencies
Before you can crunch any basketball numbers, you need a Python project loaded with the right tools. The key library is CBBpy, a free scraper that pulls real NCAA box score data and returns it directly as pandas DataFrames.
In this step, you'll create a PyCharm project, install your dependencies, and confirm everything works by pulling live Syracuse data.
In this step, get ready to:
- Create a PyCharm project with a virtual environment.
- Install CBBpy and matplotlib.
- Verify CBBpy can pull Syracuse box score data.
Create a new PyCharm project
A dedicated project keeps your scouting report code, virtual environment, and output files isolated from anything else on your machine.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type PyCharm and press Enter to open it.
- On the Welcome screen, click New Project.
- In the Location field, change the project name at the end of the path to syracuse-scouting-report.
- Under Python Interpreter, confirm that Virtualenv is selected as the environment type. This is the default.
- Click Create.
PyCharm creates your project and automatically sets up a virtual environment for it.
- Click the Terminal tab at the bottom of the PyCharm window to open the built-in terminal.
- Check for (venv) at the beginning of your terminal prompt. This confirms the virtual environment is active.
What is a virtual environment?
A virtual environment is an isolated space for your project's Python packages. Packages you install here stay separate from your system Python and other projects.
The (venv) prefix in your terminal is the signal that it's active. Every pip install you run from here only affects this project.
Don't see (venv) in your terminal?
If PyCharm's terminal does not show (venv), the virtual environment may not have been created during project setup.
- Try closing and reopening the Terminal tab. PyCharm sometimes needs a refresh to detect the venv.
- Go to File > Settings (or PyCharm > Preferences on macOS), then Project: syracuse-scouting-report > Python Interpreter. Confirm a Virtualenv interpreter is selected.
Still stuck? Help me activate a virtual environment in my PyCharm project.
Install CBBpy and matplotlib
CBBpy is the library that scrapes NCAA basketball data from ESPN. Matplotlib is the plotting library you'll use to build the visual scouting report.
- Install both packages by running this command in the PyCharm terminal:
pip install cbbpy matplotlib
What does this install?
You only need to name two packages, but you get a full analytics toolkit. CBBpy automatically installs its own dependencies, including pandas (data manipulation), NumPy (numerical operations), beautifulsoup4 (HTML parsing), and rapidfuzz (fuzzy team-name matching).
- Wait for the install to finish. You should see Successfully installed followed by a list of package names near the bottom of the output.
Seeing a "pip not found" or permissions error?
- Make sure your terminal shows (venv) at the start of the prompt. Without the virtual environment active, pip may point to a system-level Python that requires admin access.
- If your system uses pip3 instead of pip, try running pip3 install cbbpy matplotlib instead.
Help me troubleshoot pip install errors in my PyCharm virtual environment.
Create your scouting report script and test CBBpy
Now you need a Python file to hold your scouting report code. You'll start with the import statements, then run a quick data pull to confirm CBBpy can reach Syracuse's box scores.
- In the Project panel on the left side of PyCharm, right-click the syracuse-scouting-report folder.
- Select New, then Python File.
- Type scouting_report and press Enter. PyCharm creates scouting_report.py and opens it in the editor.
- Add these import statements to the top of the file:
import cbbpy.mens_scraper as cbb
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import matplotlib.gridspec as gridspec
What do these imports do?
- cbbpy.mens_scraper is the CBBpy module that scrapes NCAA men's basketball data from ESPN. You alias it as cbb for shorter function calls.
- pandas handles all the data manipulation. CBBpy returns its results as pandas DataFrames.
- numpy provides numerical operations you'll use for metric calculations, like conditional logic with np.where().
- matplotlib.pyplot and matplotlib.gridspec power the multi-panel scouting report visualization you'll build in Step 4.
- Save scouting_report.py by pressing Cmd+S (macOS) or Ctrl+S (Windows).
- Run the file to verify the imports work by running this in the PyCharm terminal:
python scouting_report.py
The script should finish silently with no output and no errors. A clean exit means all five libraries imported successfully.
Seeing a ModuleNotFoundError?
- Double check that your terminal still shows (venv) at the start of the prompt. If the venv deactivated, the packages you installed earlier are not visible.
- Check the import lines in your file for typos. The module is cbbpy.mens_scraper (not cbbpy.men_scraper or cbb_py).
Help me fix a ModuleNotFoundError when importing CBBpy in my Python file.
Now confirm that CBBpy can actually reach ESPN and pull Syracuse data. This is a live scrape over the internet, so it takes around 30 to 60 seconds to complete.
- Run this test command in your terminal:
python -c "import cbbpy.mens_scraper as cbb; i,b,p = cbb.get_games_team('syracuse', 2026, pbp=False); print(len(i), 'games found')"
You should see a number followed by games found printed in the terminal (something like 32 games found). The exact count depends on how many games Syracuse played in the 2025-26 season.
What just happened?
CBBpy's get_games_team() function scraped every Syracuse game from the 2025-26 season off ESPN. It returned three DataFrames: game info, player box scores, and play-by-play data.
You passed pbp=False to skip play-by-play data and speed up the request. The season parameter 2026 refers to the ending year, so it pulls the 2025-26 season.
Seeing a timeout or connection error?
- CBBpy pulls data from ESPN, so you need an active internet connection. Check that you can reach espn.com in your browser.
- If you are on a corporate or university network, a firewall may block scraping requests. Try switching to a personal network or mobile hotspot.
- The command can take up to 60 seconds. Wait for it to finish before assuming it failed.
Help me fix a CBBpy connection error when pulling NCAA basketball data.
✔️ Awesome, I've got everything!
Great. Double check you've saved scouting_report.py and you're ready for the next step.
ⓧ I'd like to double check the full code
Here is the complete scouting_report.py so far. Make sure your file matches this exactly.
import cbbpy.mens_scraper as cbb
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import matplotlib.gridspec as gridspec
Your project is set up and CBBpy is pulling real Syracuse data. Next, you'll use that data pull to build a full season DataFrame and clean it for analysis.
Pull Syracuse Season Box Score Data
In the last step, you set up your Python environment and confirmed that CBBpy can pull data from ESPN. Now you have everything you need to start working with real game data.
CBBpy can fetch an entire season of box scores for any NCAA D1 team with a single function call. In this step, you'll pull Syracuse's 2025-26 season data, clean it for analysis, and aggregate it into per-player season totals.
In this step, get ready to:
- Pull Syracuse Men's Basketball box score data for the 2025-26 season.
- Clean and filter the raw box score DataFrame.
- Aggregate game-by-game stats into season totals per player.
Pull Syracuse's season box scores
Before you call CBBpy, you need a few configuration constants that control which team and season to pull. These constants also set thresholds you'll use later to filter out low-minute players.
- In scouting_report.py, add these configuration constants right below your import statements:
# ── Configuration ──────────────────────────────────────────────
TEAM = "syracuse"
SEASON = 2026 # CBBpy uses the ending year (2025-26 season = 2026)
MIN_GAMES = 10
MIN_MPG = 10.0
NCAA_AVG_TS = 0.540 # Approximate D1 average TS% for reference line
What do these constants do?
- TEAM is the team name CBBpy uses to search ESPN's database. CBBpy uses fuzzy matching, so "syracuse" automatically resolves to "Syracuse Orange."
- SEASON uses the ending year of the season. The 2025-26 season is represented as 2026.
- MIN_GAMES and MIN_MPG set thresholds for filtering to rotation players later. You'll use these in Step 3.
- NCAA_AVG_TS is an approximate D1 average True Shooting percentage. It becomes a reference line on your scouting report chart.
You won't see any output yet. The next chunk uses these constants to pull data.
Now for the exciting part. CBBpy's get_games_team() function reaches out to ESPN, scrapes every game Syracuse played this season, and returns the data as pandas DataFrames.
- Pull the season's box score data by adding this code below your configuration constants:
# ── Step 2: Pull and prepare box score data ────────────────────
print(f"Pulling {TEAM.title()} box scores for {SEASON - 1}-{str(SEASON)[2:]} season...")
game_info, box_scores, pbp = cbb.get_games_team(TEAM, SEASON, info=True, box=True, pbp=False)
print(f"Retrieved {len(game_info)} games and {len(box_scores)} player box score rows.")
What does this code do?
- get_games_team() returns a tuple of three DataFrames: game info, player box scores, and play-by-play. You store each one in its own variable.
- Setting pbp=False skips play-by-play data since you don't need it. This speeds up the scrape.
- The two print() statements give you a progress update so you know the scrape is running and how much data came back.
- Save scouting_report.py.
- Run the script by clicking the green play button in PyCharm's toolbar, or by running this in your terminal:
python scouting_report.py
You should see output like Pulling Syracuse box scores for 2025-26 season... followed by a line showing how many games and box score rows were retrieved. The scrape takes 30-60 seconds since CBBpy is pulling data from ESPN for every game in the season.
Script hanging or throwing an error?
- If you see a network or connection error, check your internet connection. CBBpy needs to reach ESPN's servers to scrape data.
- If you see ModuleNotFoundError: No module named 'cbbpy', make sure your virtual environment is activated. You should see its name in parentheses at the start of your terminal prompt.
- The scrape can take up to 60 seconds. Give it time before assuming something is wrong.
Why is my CBBpy scrape failing?
Clean the box score data
Raw scraped data is rarely ready for analysis. CBBpy returns some columns as strings instead of numbers, and the DataFrame includes rows for players who sat on the bench the entire game. It also includes opponent players mixed in with Syracuse's roster.
- Add the following cleaning code below the second print() statement in scouting_report.py:
# Clean box score data
box_scores = box_scores.copy()
box_scores["min"] = pd.to_numeric(box_scores["min"], errors="coerce")
numeric_cols = ["fgm", "fga", "2pm", "2pa", "3pm", "3pa", "ftm", "fta",
"oreb", "dreb", "reb", "ast", "stl", "blk", "to", "pf", "pts"]
for col in numeric_cols:
box_scores[col] = pd.to_numeric(box_scores[col], errors="coerce")
# Filter out rows where the player did not play
box_scores = box_scores[box_scores["min"] > 0].copy()
# Filter to only Syracuse players (exclude opponent rows)
team_name = box_scores["team"].mode()[0]
box_scores = box_scores[box_scores["team"] == team_name].copy()
What does this code do?
- box_scores.copy() creates an independent copy of the DataFrame so your cleaning operations don't trigger pandas warnings about modifying a slice.
- pd.to_numeric() converts string columns to numbers. The errors="coerce" argument turns any unconvertible values into NaN instead of crashing.
- Filtering min > 0 removes players who appeared in the box score but never entered the game.
- box_scores["team"].mode()[0] finds the most common team name in the data, which is Syracuse. This filters out opponent rows so you only keep Syracuse players.
You won't see any new output yet. The cleaning happens silently behind the scenes, and the next chunk uses this cleaned data to build season totals.
Aggregate into season totals per player
Right now, each row in your DataFrame is one player's stats from one game. To build a scouting report, you need each player's cumulative totals across the entire season.
- Add this aggregation code at the bottom of scouting_report.py:
# Count games per player
games_played = box_scores.groupby("player").size().reset_index(name="games")
# Aggregate season totals per player
season_totals = box_scores.groupby("player")[["min", "fgm", "fga", "3pm", "3pa",
"ftm", "fta", "oreb", "dreb", "reb",
"ast", "stl", "blk", "to", "pf",
"pts"]].sum().reset_index()
season_totals = season_totals.merge(games_played, on="player")
What does this code do?
- groupby("player").size() counts how many rows (games) each player appears in. This becomes the games column.
- The second groupby() sums every stat column across all games for each player. This gives you season totals for minutes, field goals, rebounds, assists, and everything else.
- .merge(games_played, on="player") joins the game count back onto the season totals so each player has both their cumulative stats and the number of games they played.
- Save scouting_report.py and run it again.
You should see the same two print lines as before with no errors. The cleaning and aggregation happen silently, but if there were a typo or column name mismatch, you'd see a KeyError or TypeError here.
Seeing a KeyError or TypeError?
- A KeyError means a column name doesn't match what CBBpy returned. Double-check that your numeric_cols list matches the code above exactly, including "2pm" and "2pa" (those start with a number).
- A TypeError during the groupby().sum() usually means a column wasn't converted to numeric. Make sure the pd.to_numeric() loop comes before the aggregation code.
Why am I getting a KeyError or TypeError in my pandas aggregation?
✔️ Awesome, I've got everything!
Great. Double check you've saved scouting_report.py before moving on.
ⓧ I'd like to double check the full code
Here's the complete scouting_report.py up to this point. Compare it with yours and fix any differences.
import cbbpy.mens_scraper as cbb
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import matplotlib.gridspec as gridspec
# ── Configuration ──────────────────────────────────────────────
TEAM = "syracuse"
SEASON = 2026 # CBBpy uses the ending year (2025-26 season = 2026)
MIN_GAMES = 10
MIN_MPG = 10.0
NCAA_AVG_TS = 0.540 # Approximate D1 average TS% for reference line
# ── Step 2: Pull and prepare box score data ────────────────────
print(f"Pulling {TEAM.title()} box scores for {SEASON - 1}-{str(SEASON)[2:]} season...")
game_info, box_scores, pbp = cbb.get_games_team(TEAM, SEASON, info=True, box=True, pbp=False)
print(f"Retrieved {len(game_info)} games and {len(box_scores)} player box score rows.")
# Clean box score data
box_scores = box_scores.copy()
box_scores["min"] = pd.to_numeric(box_scores["min"], errors="coerce")
numeric_cols = ["fgm", "fga", "2pm", "2pa", "3pm", "3pa", "ftm", "fta",
"oreb", "dreb", "reb", "ast", "stl", "blk", "to", "pf", "pts"]
for col in numeric_cols:
box_scores[col] = pd.to_numeric(box_scores[col], errors="coerce")
# Filter out rows where the player did not play
box_scores = box_scores[box_scores["min"] > 0].copy()
# Filter to only Syracuse players (exclude opponent rows)
team_name = box_scores["team"].mode()[0]
box_scores = box_scores[box_scores["team"] == team_name].copy()
# Count games per player
games_played = box_scores.groupby("player").size().reset_index(name="games")
# Aggregate season totals per player
season_totals = box_scores.groupby("player")[["min", "fgm", "fga", "3pm", "3pa",
"ftm", "fta", "oreb", "dreb", "reb",
"ast", "stl", "blk", "to", "pf",
"pts"]].sum().reset_index()
season_totals = season_totals.merge(games_played, on="player")
Why use mode() to find the team name?
CBBpy's box score data includes rows for both teams in every game. The most frequently appearing team name in a team-specific query is the team you searched for. Using .mode()[0] grabs that name dynamically so you don't have to hardcode the exact string ESPN uses.
Your data pipeline is in place. You've gone from zero to a clean, aggregated DataFrame of every Syracuse player's season stats. Next up, you'll turn those raw totals into advanced analytics metrics that professional basketball analysts actually use.
Compute Advanced Analytics Metrics
You now have a season_totals DataFrame with cumulative stats for every Syracuse player across the season. Raw box score numbers like total points and total rebounds are useful, but they don't tell the full story on their own.
Professional basketball analysts use advanced metrics to compare players on a level playing field. In this step, you'll compute six metrics that account for shooting efficiency, per-minute production, and playmaking ability.
In this step, get ready to:
- Calculate shooting efficiency metrics (TS% and eFG%) for each player.
- Compute per-minute production rates and a playmaking ratio.
- Filter the data down to rotation players with meaningful minutes.
Calculate shooting efficiency metrics
A player's raw point total doesn't reveal how efficiently they score. Two players might both score 300 points in a season, but one gets there on 200 field goal attempts while the other needs 350.
Two standard metrics capture this. True Shooting Percentage (TS%) measures overall scoring efficiency by factoring in two-pointers, three-pointers, and free throws. Effective Field Goal Percentage (eFG%) measures pure shooting from the field, giving extra credit for three-pointers.
- In scouting_report.py, add the following code at the bottom of your file, below the line season_totals = season_totals.merge(games_played, on="player"):
# ── Step 3: Compute advanced metrics ──────────────────────────
# Shooting efficiency
season_totals["ts_pct"] = season_totals["pts"] / (
2 * (season_totals["fga"] + 0.44 * season_totals["fta"])
)
season_totals["efg_pct"] = (
(season_totals["fgm"] + 0.5 * season_totals["3pm"]) / season_totals["fga"]
)
What do these formulas mean?
TS% uses the formula pts / (2 * (fga + 0.44 * fta)). The 0.44 is a standard coefficient that estimates the number of possessions used by free throw attempts. It accounts for and-ones, technical free throws, and three-shot fouls.
eFG% uses (fgm + 0.5 * 3pm) / fga. The 0.5 bonus for three-pointers reflects their extra point of value compared to a two-pointer.
Why these two metrics together?
Comparing TS% and eFG% side by side reveals something specific about a player. When a player's TS% is much higher than their eFG%, it means they generate a lot of scoring value from the free throw line. This is exactly the kind of insight that makes a scouting report useful to a coaching staff.
- Save scouting_report.py.
- You won't see a change yet. The next chunk adds the remaining metrics before you run the script.
Seeing a KeyError?
If you see a KeyError for a column like fga or 3pm, check that the Step 2 code that converts columns to numeric and aggregates season totals is intact. The column names are case-sensitive and must match exactly.
Help me debug a KeyError in my shooting efficiency calculations.
Calculate production and playmaking metrics
Season totals can be misleading because a starter who plays 35 minutes per game will accumulate more raw stats than a bench player who plays 12. Per-minute metrics normalize production so you can compare any two players fairly.
- Add the following code below the efg_pct calculation you just wrote:
# Per-minute production
season_totals["ppm"] = season_totals["pts"] / season_totals["min"]
season_totals["rpm"] = season_totals["reb"] / season_totals["min"]
season_totals["apm"] = season_totals["ast"] / season_totals["min"]
season_totals["oreb_pm"] = season_totals["oreb"] / season_totals["min"]
season_totals["dreb_pm"] = season_totals["dreb"] / season_totals["min"]
# Assist-to-turnover ratio (handle zero turnovers)
season_totals["ast_to"] = np.where(
season_totals["to"] == 0,
season_totals["ast"], # If 0 turnovers, use raw assists as ratio
season_totals["ast"] / season_totals["to"]
)
# Minutes per game
season_totals["mpg"] = season_totals["min"] / season_totals["games"]
What does this code do?
- ppm, rpm, and apm divide a player's total points, rebounds, and assists by their total minutes. This tells you how productive a player is per minute on the court.
- oreb_pm and dreb_pm split rebounding into offensive and defensive rates. This helps identify whether a player's rebounds come from crashing the glass or securing defensive possessions.
- ast_to uses np.where() to handle a division-by-zero edge case. If a player has zero turnovers, dividing would crash the script. Instead, the code uses their raw assist count as the ratio value.
- mpg calculates minutes per game. You'll use this in the next chunk to filter out players without enough playing time.
- Save scouting_report.py.
- You won't see a change yet. The next chunk filters the data and prints results to the terminal.
Seeing a ZeroDivisionError?
If you see a ZeroDivisionError on a per-minute line, it means a player has 0 total minutes in season_totals. Go back to your Step 2 code and make sure the line box_scores = box_scores[box_scores["min"] > 0].copy() is filtering out zero-minute rows before aggregation.
Help me fix a ZeroDivisionError in my per-minute calculations.
Filter to rotation players
A team roster includes walk-ons and deep bench players who might only play garbage-time minutes. Including them in a scouting report would distort the analysis.
You'll filter to rotation players only: those who appeared in at least 10 games and averaged 10 or more minutes per game. These thresholds match what most analysts use to separate meaningful contributors from limited-role players.
- Add the following code at the bottom of your file, below the mpg calculation:
# Filter to rotation players
rotation = season_totals[
(season_totals["games"] >= MIN_GAMES) & (season_totals["mpg"] >= MIN_MPG)
].copy()
rotation = rotation.sort_values("pts", ascending=False).reset_index(drop=True)
print(f"\nRotation players ({len(rotation)}):")
print(rotation[["player", "games", "mpg", "pts", "ts_pct", "efg_pct", "ast_to"]].to_string(index=False))
What does this code do?
- The filter uses the MIN_GAMES and MIN_MPG constants you defined at the top of the file. This makes it easy to adjust the thresholds later without hunting through your code.
- .copy() creates a separate DataFrame so any changes to rotation won't accidentally modify season_totals.
- Sorting by pts descending puts the highest-volume scorers at the top, which makes the printed output and future charts easier to read.
- Save scouting_report.py.
- Run the script by entering this command in your PyCharm terminal:
python scouting_report.py
- You should see the game retrieval messages from Step 2, followed by a table listing each rotation player's name, games played, minutes per game, total points, TS%, eFG%, and assist-to-turnover ratio.
Not seeing any rotation players?
If the output says Rotation players (0): with an empty table, the filter thresholds might be too strict for the data CBBpy returned. Try temporarily printing season_totals[["player", "games", "mpg"]] before the filter to inspect the actual values. If all players show fewer than 10 games, the season data may be partial.
If you see a NameError for MIN_GAMES or MIN_MPG, check that those constants are defined in the configuration section at the top of your file.
Help me figure out why my rotation filter returns no players.
Why do these metrics matter for analytics interviews?
The metrics you just computed align with Dean Oliver's Four Factors of Basketball Success. This is the standard framework used by Basketball-Reference and professional analysts. Shooting is measured by eFG%. Turnovers are captured by the assist-to-turnover ratio. Rebounding shows up in per-minute rebound rates. Free throw impact is revealed by comparing TS% against eFG%.
Being able to explain this framework in an interview shows you understand the analytical reasoning behind the numbers, not just the formulas.
✔️ Awesome, I've got everything!
Great. Double check you've saved scouting_report.py before moving on.
ⓧ I'd like to double check the full code
Here is the full scouting_report.py file up to this point. Compare it against yours and fix any differences.
import cbbpy.mens_scraper as cbb
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import matplotlib.gridspec as gridspec
# ── Configuration ──────────────────────────────────────────────
TEAM = "syracuse"
SEASON = 2026 # CBBpy uses the ending year (2025-26 season = 2026)
MIN_GAMES = 10
MIN_MPG = 10.0
NCAA_AVG_TS = 0.540 # Approximate D1 average TS% for reference line
# ── Step 2: Pull and prepare box score data ────────────────────
print(f"Pulling {TEAM.title()} box scores for {SEASON - 1}-{str(SEASON)[2:]} season...")
game_info, box_scores, pbp = cbb.get_games_team(TEAM, SEASON, info=True, box=True, pbp=False)
print(f"Retrieved {len(game_info)} games and {len(box_scores)} player box score rows.")
# Clean box score data
box_scores = box_scores.copy()
box_scores["min"] = pd.to_numeric(box_scores["min"], errors="coerce")
numeric_cols = ["fgm", "fga", "2pm", "2pa", "3pm", "3pa", "ftm", "fta",
"oreb", "dreb", "reb", "ast", "stl", "blk", "to", "pf", "pts"]
for col in numeric_cols:
box_scores[col] = pd.to_numeric(box_scores[col], errors="coerce")
# Filter out rows where the player did not play
box_scores = box_scores[box_scores["min"] > 0].copy()
# Filter to only Syracuse players (exclude opponent rows)
team_name = box_scores["team"].mode()[0]
box_scores = box_scores[box_scores["team"] == team_name].copy()
# Count games per player
games_played = box_scores.groupby("player").size().reset_index(name="games")
# Aggregate season totals per player
season_totals = box_scores.groupby("player")[["min", "fgm", "fga", "3pm", "3pa",
"ftm", "fta", "oreb", "dreb", "reb",
"ast", "stl", "blk", "to", "pf",
"pts"]].sum().reset_index()
season_totals = season_totals.merge(games_played, on="player")
# ── Step 3: Compute advanced metrics ──────────────────────────
# Shooting efficiency
season_totals["ts_pct"] = season_totals["pts"] / (
2 * (season_totals["fga"] + 0.44 * season_totals["fta"])
)
season_totals["efg_pct"] = (
(season_totals["fgm"] + 0.5 * season_totals["3pm"]) / season_totals["fga"]
)
# Per-minute production
season_totals["ppm"] = season_totals["pts"] / season_totals["min"]
season_totals["rpm"] = season_totals["reb"] / season_totals["min"]
season_totals["apm"] = season_totals["ast"] / season_totals["min"]
season_totals["oreb_pm"] = season_totals["oreb"] / season_totals["min"]
season_totals["dreb_pm"] = season_totals["dreb"] / season_totals["min"]
# Assist-to-turnover ratio (handle zero turnovers)
season_totals["ast_to"] = np.where(
season_totals["to"] == 0,
season_totals["ast"], # If 0 turnovers, use raw assists as ratio
season_totals["ast"] / season_totals["to"]
)
# Minutes per game
season_totals["mpg"] = season_totals["min"] / season_totals["games"]
# Filter to rotation players
rotation = season_totals[
(season_totals["games"] >= MIN_GAMES) & (season_totals["mpg"] >= MIN_MPG)
].copy()
rotation = rotation.sort_values("pts", ascending=False).reset_index(drop=True)
print(f"\nRotation players ({len(rotation)}):")
print(rotation[["player", "games", "mpg", "pts", "ts_pct", "efg_pct", "ast_to"]].to_string(index=False))
Your analytics engine is complete. You have six advanced metrics computed for every rotation player on the Syracuse roster. Next up, you'll turn these numbers into a polished four-panel scouting report that a coaching staff could actually use.
Build the Multi-Panel Scouting Report
Your rotation DataFrame now holds six advanced metrics for every meaningful Syracuse player.
Numbers in a table are useful, but coaching staffs need to see patterns at a glance. In this step, you will turn your analytics into a polished 4-panel matplotlib scouting report that compares player performance across shooting efficiency, playmaking, and rebounding.
In this step, get ready to:
- Set up a multi-panel matplotlib figure with a 2x2 grid layout.
- Create four visualization panels comparing player performance.
- Add key insights and save the report as PNG and PDF.
Set up the figure layout
Before plotting any data, you need a canvas. matplotlib's GridSpec lets you arrange multiple subplots in a grid so each panel gets its own space.
- In scouting_report.py, scroll to the bottom of the file (after the last print statement) and add the following code:
# ── Step 4: Build the scouting report ─────────────────────────
fig = plt.figure(figsize=(16, 12))
fig.suptitle(
f"{team_name} - Player Performance Scouting Report {SEASON - 1}-{str(SEASON)[2:]}",
fontsize=16, fontweight="bold", y=0.98
)
gs = gridspec.GridSpec(2, 2, hspace=0.35, wspace=0.3)
# Shorten player names for display (first initial + last name)
def shorten_name(full_name):
parts = full_name.strip().split()
if len(parts) >= 2:
return f"{parts[0][0]}. {parts[-1]}"
return full_name
rotation["short_name"] = rotation["player"].apply(shorten_name)
What does this code do?
- plt.figure(figsize=(16, 12)) creates a large figure (16 inches wide, 12 tall) to give each panel enough room.
- fig.suptitle() adds a bold title across the top of the entire figure, using your team_name and SEASON variables so it stays dynamic.
- GridSpec(2, 2) divides the figure into a 2x2 grid. The hspace and wspace values add breathing room between panels so labels don't overlap.
- shorten_name() converts full player names like "John Smith" into "J. Smith" so they fit neatly on chart axes.
You won't see a change yet. The next chunks will fill each panel with data.
Create the visualization panels
Each panel answers a different question a coaching staff would ask about their roster. You will build all four panels, then view the complete report at the end.
- Add the code for Panel 1 (shooting efficiency) and Panel 2 (scoring vs. playmaking) directly below the shorten_name line you just added:
# Panel 1: True Shooting % horizontal bar chart
ax1 = fig.add_subplot(gs[0, 0])
colors1 = ["#2E86AB" if ts >= NCAA_AVG_TS else "#A23B72" for ts in rotation["ts_pct"]]
bars1 = ax1.barh(rotation["short_name"], rotation["ts_pct"], color=colors1, edgecolor="white")
ax1.axvline(x=NCAA_AVG_TS, color="#E8702A", linestyle="--", linewidth=1.5, label=f"D1 Avg ({NCAA_AVG_TS:.1%})")
ax1.set_xlabel("True Shooting %")
ax1.set_title("Shooting Efficiency (TS%)", fontweight="bold")
ax1.legend(fontsize=8)
ax1.invert_yaxis()
ax1.xaxis.set_major_formatter(plt.FuncFormatter(lambda x, _: f"{x:.0%}"))
# Panel 2: Points Per Minute vs Assists Per Minute scatter
ax2 = fig.add_subplot(gs[0, 1])
ax2.scatter(rotation["apm"], rotation["ppm"], s=100, c="#2E86AB", edgecolors="white", zorder=3)
for _, row in rotation.iterrows():
ax2.annotate(row["short_name"], (row["apm"], row["ppm"]),
textcoords="offset points", xytext=(5, 5), fontsize=8)
ax2.set_xlabel("Assists Per Minute")
ax2.set_ylabel("Points Per Minute")
ax2.set_title("Scoring vs. Playmaking", fontweight="bold")
ax2.grid(True, alpha=0.3)
What do these panels show?
Panel 1 is a horizontal bar chart of each player's True Shooting %. Bars are colored blue if the player shoots above the D1 average (NCAA_AVG_TS) and purple if below. A dashed orange reference line marks the average so the coaching staff can instantly spot who is above or below the benchmark.
Panel 2 is a scatter plot with assists per minute on the x-axis and points per minute on the y-axis. Players in the top-right corner are the most complete offensive threats because they both score and create for teammates.
You won't see a change yet. The next chunk adds the bottom two panels.
- Add the code for Panel 3 (free throw impact) and Panel 4 (rebounding profile) directly below the Panel 2 code:
# Panel 3: eFG% vs TS% grouped bar chart
ax3 = fig.add_subplot(gs[1, 0])
x_pos = np.arange(len(rotation))
bar_width = 0.35
bars_efg = ax3.bar(x_pos - bar_width / 2, rotation["efg_pct"], bar_width, label="eFG%", color="#2E86AB")
bars_ts = ax3.bar(x_pos + bar_width / 2, rotation["ts_pct"], bar_width, label="TS%", color="#E8702A")
ax3.set_xticks(x_pos)
ax3.set_xticklabels(rotation["short_name"], rotation=45, ha="right", fontsize=8)
ax3.set_ylabel("Percentage")
ax3.set_title("eFG% vs TS% (Free Throw Impact)", fontweight="bold")
ax3.legend(fontsize=8)
ax3.yaxis.set_major_formatter(plt.FuncFormatter(lambda x, _: f"{x:.0%}"))
# Panel 4: Rebounding profile stacked bar chart
ax4 = fig.add_subplot(gs[1, 1])
ax4.bar(rotation["short_name"], rotation["oreb_pm"], label="Off Reb/Min", color="#2E86AB")
ax4.bar(rotation["short_name"], rotation["dreb_pm"], bottom=rotation["oreb_pm"],
label="Def Reb/Min", color="#E8702A")
ax4.set_ylabel("Rebounds Per Minute")
ax4.set_title("Rebounding Profile", fontweight="bold")
ax4.legend(fontsize=8)
ax4.set_xticklabels(rotation["short_name"], rotation=45, ha="right", fontsize=8)
What do these panels show?
Panel 3 places each player's eFG% and TS% side by side. The gap between the two bars reveals how much a player benefits from getting to the free throw line. A player whose TS% is much higher than their eFG% is great at drawing fouls.
Panel 4 is a stacked bar chart splitting each player's rebounds per minute into offensive and defensive. This tells coaches who crashes the glass on offense versus who anchors the defensive boards.
Why these four panels?
These panels map to Dean Oliver's Four Factors of Basketball Success. Shooting is measured by TS% and eFG%. Turnovers are captured by the assist-to-turnover ratio in the scatter plot. Rebounding gets its own panel. Free throw impact shows in the eFG% vs. TS% comparison.
You won't see a change yet. The next chunk adds the insights text box and saves the report.
Add insights and save the report
A scouting report needs a quick-read summary so a coach can grab the headline without studying every chart. You will add a text box that automatically identifies the top scorer, most efficient shooter, and best playmaker.
- Add the following code directly below the Panel 4 code:
# Key insights text box
top_scorer = rotation.loc[rotation["pts"].idxmax(), "short_name"]
most_efficient = rotation.loc[rotation["ts_pct"].idxmax(), "short_name"]
best_playmaker = rotation.loc[rotation["ast_to"].idxmax(), "short_name"]
insights = (
f"Key Insights:\n"
f" Top Scorer: {top_scorer}\n"
f" Most Efficient (TS%): {most_efficient}\n"
f" Best Playmaker (A/TO): {best_playmaker}"
)
fig.text(0.5, 0.01, insights, ha="center", fontsize=10,
bbox=dict(boxstyle="round,pad=0.5", facecolor="#F0F0F0", edgecolor="#CCCCCC"))
plt.savefig("syracuse_scouting_report.png", dpi=150, bbox_inches="tight")
plt.savefig("syracuse_scouting_report.pdf", bbox_inches="tight")
print("\nScouting report saved as syracuse_scouting_report.png and .pdf")
plt.show()
What does this code do?
- idxmax() finds the row index of the highest value in a column. This lets you pull the player name for each headline stat automatically.
- fig.text() places a text box at the bottom center of the figure (y=0.01) with a light gray background so it stands out as a summary.
- plt.savefig() exports the figure twice: once as a high-resolution PNG (dpi=150) for screens and once as a PDF for print. The bbox_inches="tight" option trims whitespace around the edges.
- plt.show() opens an interactive window so you can inspect the report on screen.
- Save scouting_report.py.
- Run the script by clicking the green play button in PyCharm, or by running this command in your terminal:
python scouting_report.py
The script will pull data from CBBpy (this may take a minute), then a matplotlib window will pop up showing your 4-panel scouting report. You should also see syracuse_scouting_report.png and syracuse_scouting_report.pdf saved in your project folder.
Not seeing the matplotlib window?
- Make sure you saved scouting_report.py before running. Unsaved changes won't execute.
- If the script runs but the window doesn't appear, check that your terminal output ends with "Scouting report saved as syracuse_scouting_report.png and .pdf". The PNG and PDF files are still saved even if the interactive window doesn't open.
- If you see a KeyError or ValueError, double check that the column names in your Panel code (ts_pct, efg_pct, short_name) match exactly what you created in Step 3.
- Why isn't my scouting report displaying correctly?
✔️ Awesome, I've got everything!
Great. Double check you've saved your file before moving on.
ⓧ I'd like to double check the full code
Here is the complete scouting_report.py file. Compare it against yours line by line.
import cbbpy.mens_scraper as cbb
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import matplotlib.gridspec as gridspec
# ── Configuration ──────────────────────────────────────────────
TEAM = "syracuse"
SEASON = 2026 # CBBpy uses the ending year (2025-26 season = 2026)
MIN_GAMES = 10
MIN_MPG = 10.0
NCAA_AVG_TS = 0.540 # Approximate D1 average TS% for reference line
# ── Step 2: Pull and prepare box score data ────────────────────
print(f"Pulling {TEAM.title()} box scores for {SEASON - 1}-{str(SEASON)[2:]} season...")
game_info, box_scores, pbp = cbb.get_games_team(TEAM, SEASON, info=True, box=True, pbp=False)
print(f"Retrieved {len(game_info)} games and {len(box_scores)} player box score rows.")
# Clean box score data
box_scores = box_scores.copy()
box_scores["min"] = pd.to_numeric(box_scores["min"], errors="coerce")
numeric_cols = ["fgm", "fga", "2pm", "2pa", "3pm", "3pa", "ftm", "fta",
"oreb", "dreb", "reb", "ast", "stl", "blk", "to", "pf", "pts"]
for col in numeric_cols:
box_scores[col] = pd.to_numeric(box_scores[col], errors="coerce")
# Filter out rows where the player did not play
box_scores = box_scores[box_scores["min"] > 0].copy()
# Filter to only Syracuse players (exclude opponent rows)
team_name = box_scores["team"].mode()[0]
box_scores = box_scores[box_scores["team"] == team_name].copy()
# Count games per player
games_played = box_scores.groupby("player").size().reset_index(name="games")
# Aggregate season totals per player
season_totals = box_scores.groupby("player")[["min", "fgm", "fga", "3pm", "3pa",
"ftm", "fta", "oreb", "dreb", "reb",
"ast", "stl", "blk", "to", "pf",
"pts"]].sum().reset_index()
season_totals = season_totals.merge(games_played, on="player")
# ── Step 3: Compute advanced metrics ──────────────────────────
# Shooting efficiency
season_totals["ts_pct"] = season_totals["pts"] / (
2 * (season_totals["fga"] + 0.44 * season_totals["fta"])
)
season_totals["efg_pct"] = (
(season_totals["fgm"] + 0.5 * season_totals["3pm"]) / season_totals["fga"]
)
# Per-minute production
season_totals["ppm"] = season_totals["pts"] / season_totals["min"]
season_totals["rpm"] = season_totals["reb"] / season_totals["min"]
season_totals["apm"] = season_totals["ast"] / season_totals["min"]
season_totals["oreb_pm"] = season_totals["oreb"] / season_totals["min"]
season_totals["dreb_pm"] = season_totals["dreb"] / season_totals["min"]
# Assist-to-turnover ratio (handle zero turnovers)
season_totals["ast_to"] = np.where(
season_totals["to"] == 0,
season_totals["ast"], # If 0 turnovers, use raw assists as ratio
season_totals["ast"] / season_totals["to"]
)
# Minutes per game
season_totals["mpg"] = season_totals["min"] / season_totals["games"]
# Filter to rotation players
rotation = season_totals[
(season_totals["games"] >= MIN_GAMES) & (season_totals["mpg"] >= MIN_MPG)
].copy()
rotation = rotation.sort_values("pts", ascending=False).reset_index(drop=True)
print(f"\nRotation players ({len(rotation)}):")
print(rotation[["player", "games", "mpg", "pts", "ts_pct", "efg_pct", "ast_to"]].to_string(index=False))
# ── Step 4: Build the scouting report ─────────────────────────
fig = plt.figure(figsize=(16, 12))
fig.suptitle(
f"{team_name} - Player Performance Scouting Report {SEASON - 1}-{str(SEASON)[2:]}",
fontsize=16, fontweight="bold", y=0.98
)
gs = gridspec.GridSpec(2, 2, hspace=0.35, wspace=0.3)
# Shorten player names for display (first initial + last name)
def shorten_name(full_name):
parts = full_name.strip().split()
if len(parts) >= 2:
return f"{parts[0][0]}. {parts[-1]}"
return full_name
rotation["short_name"] = rotation["player"].apply(shorten_name)
# Panel 1: True Shooting % horizontal bar chart
ax1 = fig.add_subplot(gs[0, 0])
colors1 = ["#2E86AB" if ts >= NCAA_AVG_TS else "#A23B72" for ts in rotation["ts_pct"]]
bars1 = ax1.barh(rotation["short_name"], rotation["ts_pct"], color=colors1, edgecolor="white")
ax1.axvline(x=NCAA_AVG_TS, color="#E8702A", linestyle="--", linewidth=1.5, label=f"D1 Avg ({NCAA_AVG_TS:.1%})")
ax1.set_xlabel("True Shooting %")
ax1.set_title("Shooting Efficiency (TS%)", fontweight="bold")
ax1.legend(fontsize=8)
ax1.invert_yaxis()
ax1.xaxis.set_major_formatter(plt.FuncFormatter(lambda x, _: f"{x:.0%}"))
# Panel 2: Points Per Minute vs Assists Per Minute scatter
ax2 = fig.add_subplot(gs[0, 1])
ax2.scatter(rotation["apm"], rotation["ppm"], s=100, c="#2E86AB", edgecolors="white", zorder=3)
for _, row in rotation.iterrows():
ax2.annotate(row["short_name"], (row["apm"], row["ppm"]),
textcoords="offset points", xytext=(5, 5), fontsize=8)
ax2.set_xlabel("Assists Per Minute")
ax2.set_ylabel("Points Per Minute")
ax2.set_title("Scoring vs. Playmaking", fontweight="bold")
ax2.grid(True, alpha=0.3)
# Panel 3: eFG% vs TS% grouped bar chart
ax3 = fig.add_subplot(gs[1, 0])
x_pos = np.arange(len(rotation))
bar_width = 0.35
bars_efg = ax3.bar(x_pos - bar_width / 2, rotation["efg_pct"], bar_width, label="eFG%", color="#2E86AB")
bars_ts = ax3.bar(x_pos + bar_width / 2, rotation["ts_pct"], bar_width, label="TS%", color="#E8702A")
ax3.set_xticks(x_pos)
ax3.set_xticklabels(rotation["short_name"], rotation=45, ha="right", fontsize=8)
ax3.set_ylabel("Percentage")
ax3.set_title("eFG% vs TS% (Free Throw Impact)", fontweight="bold")
ax3.legend(fontsize=8)
ax3.yaxis.set_major_formatter(plt.FuncFormatter(lambda x, _: f"{x:.0%}"))
# Panel 4: Rebounding profile stacked bar chart
ax4 = fig.add_subplot(gs[1, 1])
ax4.bar(rotation["short_name"], rotation["oreb_pm"], label="Off Reb/Min", color="#2E86AB")
ax4.bar(rotation["short_name"], rotation["dreb_pm"], bottom=rotation["oreb_pm"],
label="Def Reb/Min", color="#E8702A")
ax4.set_ylabel("Rebounds Per Minute")
ax4.set_title("Rebounding Profile", fontweight="bold")
ax4.legend(fontsize=8)
ax4.set_xticklabels(rotation["short_name"], rotation=45, ha="right", fontsize=8)
# Key insights text box
top_scorer = rotation.loc[rotation["pts"].idxmax(), "short_name"]
most_efficient = rotation.loc[rotation["ts_pct"].idxmax(), "short_name"]
best_playmaker = rotation.loc[rotation["ast_to"].idxmax(), "short_name"]
insights = (
f"Key Insights:\n"
f" Top Scorer: {top_scorer}\n"
f" Most Efficient (TS%): {most_efficient}\n"
f" Best Playmaker (A/TO): {best_playmaker}"
)
fig.text(0.5, 0.01, insights, ha="center", fontsize=10,
bbox=dict(boxstyle="round,pad=0.5", facecolor="#F0F0F0", edgecolor="#CCCCCC"))
plt.savefig("syracuse_scouting_report.png", dpi=150, bbox_inches="tight")
plt.savefig("syracuse_scouting_report.pdf", bbox_inches="tight")
print("\nScouting report saved as syracuse_scouting_report.png and .pdf")
plt.show()
Secret mission
Add Opponent Scouting Comparison
Your scouting report covers Syracuse's own players. But what about the team they're playing next? Build a comparison overlay that places Syracuse's team averages against any opponent across all 6 metrics, turning your player report into a real game-prep tool.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally, so there are no ongoing costs.
Resources you used:
- PyCharm project syracuse-scouting-report.
- Python virtual environment inside the project folder.
- Installed Python packages: cbbpy, matplotlib, pandas, numpy, and their dependencies.
- Generated files: scouting_report.py, syracuse_scouting_report.png, and syracuse_scouting_report.pdf.
Keep everything running
No action needed. Choose this if you are still actively building or want to re-run the scouting report for other teams and seasons.
- Your PyCharm project, virtual environment, and all generated files stay in place.
- You can re-run scouting_report.py anytime by changing the TEAM and SEASON variables at the top of the file.
Pause - I'll come back to this later
Deactivate your virtual environment to free up terminal resources. Your project files and installed packages remain on disk for when you return.
- In your PyCharm terminal, deactivate the virtual environment by running:
deactivate
- Close the PyCharm project. When you come back, reopen it and reactivate the virtual environment before running your script.
Delete - I don't want to use this again
Remove all project resources and start fresh. This permanently deletes your code, virtual environment, and generated reports.
- Close the project in PyCharm.
- Delete the entire syracuse-scouting-report folder from your computer using Finder (macOS) or File Explorer (Windows).
- This removes the virtual environment, all installed packages, your script, and the generated PNG and PDF reports.
That's a Wrap!
That's a Wrap!
Nice work! You just built a real basketball analytics pipeline that turns raw NCAA box score data into an interview-ready scouting report for Syracuse Men's Basketball.
Here's what you learned:
- Pulled live NCAA box score data using CBBpy and cleaned it into a structured pandas DataFrame ready for analysis.
- Computed 6 advanced metrics used by professional basketball analysts, including True Shooting %, Effective FG%, per-minute production, and assist-to-turnover ratio.
- Built a polished 4-panel matplotlib scouting report that presents complex player data in a format a coaching staff can act on.
- Added an opponent scouting comparison that overlays a selected opponent's metrics against Syracuse to identify matchup advantages.
Ready to quiz yourself?