An attention-grabbing first sports activities evaluation doesn’t want a mannequin, a big characteristic set, or an advanced dashboard. It wants a query, sufficient information to reply it, and a end result you’ll be able to clarify.
On this walkthrough, we are going to make three requests with the official CFBD Python consumer and switch them right into a profile of Michigan’s 2023 nationwide championship season. The completed chart compares Michigan with the total FBS throughout report, scoring, effectivity, explosiveness, and tempo.
Plan on about 20-Half-hour the primary time by way of. As soon as it really works, altering two variables will allow you to reuse the identical evaluation for one more group or accomplished season.
One essential word earlier than we begin: it is a descriptive profile, not a causal mannequin. It summarizes what occurred, however doesn’t show why it occurred.
Arrange a secure Python setting
This instance pins model 5.18.0 of the official CFBD Python consumer because the minimal model required. That is the model used to provide the outcomes on this publish. Additionally, you will want pandas for the tables and Matplotlib for the chart.
pip set up “cfbd>=5.18.0” “pandas>=2.2,=3.8,
If you don’t have already got an API key, request a CFBD key right here. Retailer it in an setting variable named CFBD_API_KEY. Don’t paste an actual key right into a pocket book, script, screenshot, or public repository.
On macOS or Linux:
export CFBD_API_KEY=”your-key-here”
In Home windows PowerShell:
$env:CFBD_API_KEY=”your-key-here”
Or you’ll be able to paste the important thing right into a .env file on the root of your challenge (my advice):
CFBD_API_KEY=your-key-here
Now import the packages, select a group and season, and throw a failure message if the bottom line is lacking or misconfigured:
import os
from pprint import pprint
import cfbd
import matplotlib.pyplot as plt
import pandas as pd
# Be at liberty to alter this to any group and 12 months
TEAM = “Michigan”
YEAR = 2023
EXCLUDE_GARBAGE_TIME = True
api_key = os.getenv(“CFBD_API_KEY”)
if not api_key:
elevate RuntimeError(
“CFBD_API_KEY will not be set. Add it to your setting, restart the ”
“kernel, and run the pocket book once more. Don’t paste the important thing into this pocket book.”
)
configuration = cfbd.Configuration(access_token=api_key)
print(f”Able to construct a {YEAR} profile for {TEAM}. API key loaded safely.”)
The secret’s read-only at runtime, and the code by no means prints it.
Select a small set of questions
The CFBD API exposes way more information than we want for a primary profile. Importing each out there subject would make the cleanup more durable with out essentially making the end result extra helpful.
As an alternative, we are going to reply 4 centered questions:
How profitable was the group general?How productive, environment friendly, and explosive have been the offense and protection?Did the group play quick or sluggish?What was one clear relative power and one clear relative weak point?
Eight metrics are sufficient for this model. This retains the end result readable and makes every alternative simpler to clarify.
Retrieve the smallest helpful dataset
We’ll make three requests:
TeamsApi.get_fbs_teams grabs the record of FBS groups from the 2023 season for comparability.GamesApi.get_games fetches accomplished regular-season and postseason scores.StatsApi.get_advanced_season_stats returns effectivity, explosiveness, and play counts.
You may confirm the out there arguments within the official consumer docs for TeamsApi, GamesApi, and StatsApi.
with cfbd.ApiClient(configuration) as api_client:
teams_api = cfbd.TeamsApi(api_client)
games_api = cfbd.GamesApi(api_client)
stats_api = cfbd.StatsApi(api_client)
fbs_teams = teams_api.get_fbs_teams(12 months=YEAR)
video games = games_api.get_games(
12 months=YEAR,
season_type=cfbd.SeasonType.BOTH,
classification=cfbd.DivisionClassification.FBS,
)
advanced_stats = stats_api.get_advanced_season_stats(
12 months=YEAR,
exclude_garbage_time=EXCLUDE_GARBAGE_TIME,
)
print(
f”Retrieved {len(fbs_teams)} FBS groups, {len(video games)} video games, ”
f”and {len(advanced_stats)} superior group rows.”
)
For this run, the three requests returned 133 FBS groups, 910 video games, and 133 superior group rows.
Earlier than reworking something, examine one returned object. The consumer returns typed Python fashions and calling .dict() will convert to a dictionary form:
sample_game = subsequent(
sport for sport in video games
if sport.home_team == TEAM or sport.away_team == TEAM
)
pprint(sample_game.dict())
The primary Michigan sport within the response consists of fields corresponding to home_team, away_team, home_points, away_points, accomplished, season_type, and week. Taking a look at one actual object earlier than writing transformation code is a straightforward behavior, however it prevents quite a lot of guessing about subject names and response construction.
Reshape video games into one row per group
A sport response has one residence group and one away group. Our evaluation might be simpler if every accomplished sport turns into one row per FBS group, so points_for, points_against, and win_value imply the identical factor no matter location.
fbs_names = {group.faculty for group in fbs_teams}
def win_value(points_for, points_against):
“””Return 1 for a win, 0.5 for a tie, and 0 for a loss.”””
if points_for > points_against:
return 1.0
if points_for == points_against:
return 0.5
return 0.0
team_game_rows = []
for sport in video games:
if not sport.accomplished:
proceed
if sport.home_points is None or sport.away_points is None:
proceed
if sport.home_team in fbs_names:
team_game_rows.append(
{
“game_id”: sport.id,
“group”: sport.home_team,
“points_for”: sport.home_points,
“points_against”: sport.away_points,
“win_value”: win_value(sport.home_points, sport.away_points),
}
)
if sport.away_team in fbs_names:
team_game_rows.append(
{
“game_id”: sport.id,
“group”: sport.away_team,
“points_for”: sport.away_points,
“points_against”: sport.home_points,
“win_value”: win_value(sport.away_points, sport.home_points),
}
)
team_games_df = pd.DataFrame(team_game_rows)
team_games_df.head()
Now summarize these rows into one season report per group:
season_results = team_games_df.groupby(“group”).agg(
video games=(“game_id”, “nunique”),
wins=(“win_value”, “sum”),
points_for=(“points_for”, “sum”),
points_against=(“points_against”, “sum”),
).reset_index()
season_results[“win_pct”] = season_results[“wins”] / season_results[“games”]
season_results[“points_per_game”] = (
season_results[“points_for”] / season_results[“games”]
)
season_results[“points_allowed_per_game”] = (
season_results[“points_against”] / season_results[“games”]
)
season_results[season_results[“team”] == TEAM]
The end result offers Michigan 15 video games, 15 wins, 35.9 factors per sport, and 10.4 factors allowed per sport.
There’s a season-definition element value calling out. SeasonType.BOTH consists of regular-season and postseason video games assigned to the 2023 season. The scoring averages additionally embody accomplished video games in opposition to non-FBS opponents when an FBS group participated. These decisions make sense for this profile, however one other evaluation may make completely different decisions.
Choose the superior fields we really need
Subsequent, copy a small set of fields from the superior response:
advanced_rows = []
for team_stats in advanced_stats:
if team_stats.group not in fbs_names:
proceed
advanced_rows.append(
{
“group”: team_stats.group,
“convention”: team_stats.convention,
“offensive_success_rate”: team_stats.offense.success_rate,
“defensive_success_rate”: team_stats.protection.success_rate,
“offensive_explosiveness”: team_stats.offense.explosiveness,
“defensive_explosiveness”: team_stats.protection.explosiveness,
“offensive_plays”: team_stats.offense.performs,
}
)
advanced_df = pd.DataFrame(advanced_rows)
advanced_df.head()
The items and instructions matter:
Success fee is a share of performs, so 0.45 means 45%. Larger is best for offense; decrease is best for protection.Explosiveness is CFBD’s superior explosiveness worth. Larger is best for offense; decrease is best for protection.Performs per sport might be derived as non-garbage-time offensive performs divided by accomplished video games. It describes tempo. Quicker will not be robotically higher.
As a result of the superior request makes use of exclude_garbage_time=True, these superior fields deliberately omit CFBD-defined garbage-time performs.
Be a part of the tables and deal with lacking values
Be a part of the scoring and superior tables by group, derive performs per sport, and explicitly take away any group that’s lacking a metric required by the chart:
team_seasons = season_results.merge(advanced_df, on=”group”, how=”interior”)
team_seasons[“plays_per_game”] = (
team_seasons[“offensive_plays”] / team_seasons[“games”]
)
metric_columns = [
“win_pct”,
“points_per_game”,
“points_allowed_per_game”,
“offensive_success_rate”,
“defensive_success_rate”,
“offensive_explosiveness”,
“defensive_explosiveness”,
“plays_per_game”,
]
rows_before = len(team_seasons)
team_seasons = team_seasons.dropna(subset=metric_columns).reset_index(drop=True)
rows_removed = rows_before – len(team_seasons)
if TEAM not in set(team_seasons[“team”]):
elevate ValueError(f”{TEAM!r} will not be current within the cleaned {YEAR} FBS information.”)
print(
f”Clear baseline: {len(team_seasons)} FBS groups ”
f”({rows_removed} incomplete rows eliminated).”
)
On this 2023 run, all 133 FBS groups had the fields required for the profile. The missing-value step remains to be essential. If protection adjustments for one more season, incomplete rows is not going to silently be included within the comparability.
Convert uncooked values into FBS percentiles
Uncooked values reply what occurred. Percentile ranks add context by answering how uncommon a end result was amongst FBS groups.
A Ninetieth-percentile worth means the group ranked higher than roughly 90% of the groups within the comparability group. For factors allowed, defensive success fee, and defensive explosiveness, decrease uncooked values are higher, so we reverse their rating course. Tempo stays completely different: the next percentile means quicker, not higher.
# Larger uncooked values rank increased for these metrics.
team_seasons[“win_pct_percentile”] = (
team_seasons[“win_pct”].rank(pct=True) * 100
)
team_seasons[“points_per_game_percentile”] = (
team_seasons[“points_per_game”].rank(pct=True) * 100
)
team_seasons[“offensive_success_rate_percentile”] = (
team_seasons[“offensive_success_rate”].rank(pct=True) * 100
)
team_seasons[“offensive_explosiveness_percentile”] = (
team_seasons[“offensive_explosiveness”].rank(pct=True) * 100
)
# Decrease uncooked values rank increased for defensive metrics.
team_seasons[“points_allowed_per_game_percentile”] = (
team_seasons[“points_allowed_per_game”].rank(pct=True, ascending=False) * 100
)
team_seasons[“defensive_success_rate_percentile”] = (
team_seasons[“defensive_success_rate”].rank(pct=True, ascending=False) * 100
)
team_seasons[“defensive_explosiveness_percentile”] = (
team_seasons[“defensive_explosiveness”].rank(pct=True, ascending=False) * 100
)
# For tempo, the next percentile means quicker—not higher.
team_seasons[“plays_per_game_percentile”] = (
team_seasons[“plays_per_game”].rank(pct=True) * 100
)
Acquire the chosen group’s uncooked worth, the FBS median, and its percentile into one compact desk:
selected_team = team_seasons[team_seasons[“team”] == TEAM].iloc[0]
# Every tuple accommodates: label, DataFrame column, course, show scale.
metric_specs = [
(“Win percentage (%)”, “win_pct”, “higher”, 100),
(“Points per game”, “points_per_game”, “higher”, 1),
(“Points allowed per game”, “points_allowed_per_game”, “lower”, 1),
(“Offensive success rate (%)”, “offensive_success_rate”, “higher”, 100),
(“Defensive success rate allowed (%)”, “defensive_success_rate”, “lower”, 100),
(“Offensive explosiveness”, “offensive_explosiveness”, “higher”, 1),
(“Defensive explosiveness allowed”, “defensive_explosiveness”, “lower”, 1),
(“Non-garbage-time plays per game”, “plays_per_game”, “faster”, 1),
]
profile_rows = []
for label, column, course, scale in metric_specs:
profile_rows.append(
{
“metric”: label,
“team_value”: selected_team[column] * scale,
“fbs_median”: team_seasons[column].median() * scale,
“percentile”: selected_team[f”{column}_percentile”],
“course”: course,
}
)
profile = pd.DataFrame(profile_rows)
profile_table = profile[[“metric”, “team_value”, “fbs_median”, “percentile”]].copy()
profile_table = profile_table.rename(
columns={“team_value”: TEAM, “percentile”: “FBS percentile”}
)
profile_table.spherical(1)
Michigan’s profile features a A hundredth-percentile win proportion, Ninetieth-percentile scoring, 92nd-percentile offensive success fee, and A hundredth-percentile marks for each factors allowed and defensive success fee allowed. Its offensive explosiveness ranks a lot decrease, on the thirty eighth percentile, whereas its tempo is among the many slowest within the comparability group.
These comparisons are unadjusted. Opponent high quality, sport rely, postseason path, and play-sample measurement all have an effect on the profile. Percentiles make the groups simpler to match, however they don’t take away schedule power or flip a descriptive metric into proof of trigger.
Construct the chart
The plotting code makes use of one horizontal bar per metric and a dashed reference line on the FBS median. High quality metrics at or above the median are inexperienced, these beneath it are purple, and tempo is grey as a result of quick and sluggish are kinds slightly than grades.
plot_data = profile.iloc[::-1].reset_index(drop=True)
colours = []
for row in plot_data.itertuples():
if row.course == “quicker”:
colours.append(“#6B7280”)
elif row.percentile >= 50:
colours.append(“#009C27”)
else:
colours.append(“#BB0019”)
fig, ax = plt.subplots(figsize=(10, 6.5))
bars = ax.barh(
plot_data[“metric”],
plot_data[“percentile”],
colour=colours,
peak=0.64,
)
ax.axvline(50, colour=”#9CA3AF”, linewidth=1.2, linestyle=”–“)
labels = [f”{value:.0f}” for value in plot_data[“percentile”]]
ax.bar_label(bars, labels=labels, padding=3, fontsize=10, fontweight=”daring”)
ax.set_xlim(0, 105)
ax.set_xticks([0, 20, 40, 60, 80, 100])
ax.set_xlabel(“FBS percentile (increased = stronger; tempo = quicker)”, fontsize=10)
ax.set_title(
f”{TEAM} {YEAR} Workforce Profile”,
loc=”left”,
fontsize=18,
fontweight=”daring”,
colour=”#00274C”,
)
for facet in [“top”, “right”, “left”]:
ax.spines[side].set_visible(False)
ax.tick_params(axis=”y”, size=0)
ax.grid(axis=”x”, colour=”#E5E7EB”, linewidth=0.8)
ax.set_axisbelow(True)
plt.tight_layout()
output_path = f”{TEAM.decrease().exchange(‘ ‘, ‘_’)}_{YEAR}_team_profile.png”
fig.savefig(output_path, dpi=200, bbox_inches=”tight”, facecolor=”white”)
plt.present()
print(f”Saved chart to {output_path}”)
The saved PNG is able to use in a dashboard, article, class challenge, or social publish.
Interpret the end result
The chart helps a couple of direct observations:
Michigan’s report, scoring protection, and defensive success fee allowed all sit on the prime of this FBS comparability.Offensive explosiveness is the lowest-ranked high quality metric within the profile on the thirty eighth percentile. That makes it a relative weak point inside this set of metrics, not proof that the offense was poor.Michigan’s non-garbage-time tempo is across the 2nd FBS percentile. That describes tempo and shouldn’t be learn as a high quality grade.
These statements describe the place Michigan landed. They don’t set up that tempo, explosiveness, or some other single metric induced the 15–0 season. Teaching, personnel, opponent high quality, sport state, and different components overlap.
Run it in your group
Change the 2 parameters close to the highest and run the total script once more:
TEAM = “Indiana”
YEAR = 2025
Use a accomplished season for the cleanest first comparability. Workforce names should match the names returned by TeamsApi.get_fbs_teams.
As soon as this model works, there are a number of completely different instructions you’ll be able to take it:
Add an opponent adjustment or schedule-strength management.Put two groups facet by facet.Add an end_week filter and refresh the profile through the season.Transfer the saved chart right into a dashboard or an extended evaluation.
Begin with the straightforward model. Construct the profile in your personal group, then share the chart or the one end result that shocked you most.








