The Efficiency Landscape: Plotting Offensive vs. Defensive Rating

BasketballIntermediatePython~5 min read

What you'll build

A four-quadrant scatter of every NBA team's offense and defense, split by the league-average crosshairs.

A four-quadrant scatter of every NBA team's offense and defense, split by the league-average crosshairs.
Data: Bundled sample (NBA team ratings), retrieved June 2026

A standings table tells you who is winning. It doesn't tell you how. Two 50-win teams can be built completely differently — one bludgeoning opponents on offense, the other suffocating them on defense. The cleanest way to see that at a glance is to plot every team's offensive rating against its defensive rating on a single chart and split it into four quadrants. Build exactly that from the bundled NBA team ratings, and one picture separates the contenders from the rebuilders.

This pairs naturally with the net-rating dashboard table: that tutorial ranks teams; this one shows the whole landscape. The data is the bundled nba_ratings.csv (per-100-possession ratings, Basketball-Reference, retrieved June 2026), so it runs offline.

  1. Load the ratings and find the league average

    Offensive rating (ORtg) is points scored per 100 possessions; defensive rating (DRtg) is points allowed per 100; net rating (NRtg) is the difference. Because both ends are measured on the same per-100 scale, league-average offense and league-average defense are the same number — the crosshairs of our chart.

    python
    import pandas as pd
    
    df = pd.read_csv("nba_ratings.csv")   # Team, W, L, W/L%, ORtg, DRtg, NRtg
    lg_off = df["ORtg"].mean()
    lg_def = df["DRtg"].mean()
    print(df.sort_values("NRtg", ascending=False)[["Team", "ORtg", "DRtg", "NRtg"]].head().to_string())
    League average and the best net ratings
    League-average rating: 116.2 offense / 116.2 defense
    
    Best net rating:
                         Team    ORtg    DRtg   NRtg
    0          Boston Celtics  124.23  112.51  11.71
    1   Oklahoma City Thunder  120.43  113.12   7.31
    2  Minnesota Timberwolves  116.63  109.98   6.65
    3          Denver Nuggets  119.49  114.05   5.44
    4         New York Knicks  119.11  114.22   4.89

    The league averages out to about 116 points per 100 possessions on each end. At the top, the Celtics pair an elite offense (124.2) with a strong defense (112.5) for a net rating near +12 — the signature of a true contender. Minnesota gets there the opposite way, anchored by the league's stingiest defense (about 110).

  2. A short label for each point

    Thirty full team names would turn the chart into spaghetti. We derive a three-letter tag from each nickname — the last word of the name, trimmed and upper-cased.

    python
    df["Tag"] = df["Team"].str.split().str[-1].str[:3].str.upper()
    # "Boston Celtics" -> "CEL", "Denver Nuggets" -> "NUG"

    Chaining string methods with the .str accessor is the pandas way to transform a whole column at once, with no loop.

  3. Draw the quadrant scatter

    Now the payoff. We scatter ORtg on the x-axis and DRtg on the y-axis, color each point by net rating, and draw dashed lines at the league averages. The one trick that makes it readable: invert the y-axis, because a lower defensive rating is better, and we want good defenses at the top.

    python
    import matplotlib.pyplot as plt
    
    fig, ax = plt.subplots(figsize=(8, 7))
    sc = ax.scatter(df["ORtg"], df["DRtg"], c=df["NRtg"], cmap="RdYlGn",
                    s=70, edgecolor="#20242B", linewidth=0.5)
    ax.axvline(lg_off, color="#6C7079", ls="--")
    ax.axhline(lg_def, color="#6C7079", ls="--")
    for _, r in df.iterrows():
        ax.annotate(r["Tag"], (r["ORtg"], r["DRtg"]),
                    textcoords="offset points", xytext=(5, 2), fontsize=7)
    
    ax.invert_yaxis()   # better defense (lower DRtg) now sits at the top
    fig.colorbar(sc, ax=ax).set_label("net rating")
    fig.savefig("efficiency_quadrant.png", dpi=144, bbox_inches="tight")
    Four-quadrant scatter of NBA teams plotting offensive rating against (inverted) defensive rating, colored by net rating, with dashed league-average crosshairs; elite two-way teams sit in the top-right
    Data: Bundled sample (NBA team ratings), retrieved June 2026

    Read it by quadrant. Top-right is the promised land: above-average offense and defense (the green points). Bottom-left is a rebuild — below average at both ends. The other two corners are the lopsided teams: great offense with a leaky defense (bottom-right), or a defense-first grinder that can't score (top-left). One chart, and every team's identity is obvious.

Troubleshooting

The best teams are in the bottom-right, not the top-right

You skipped ax.invert_yaxis(). Without it, low (good) defensive ratings sit at the bottom, so elite defenses look like they're struggling. Inverting the axis puts "good" up where readers expect it.

The team tags overlap and collide

With 30 labels some crowding is unavoidable on a static chart. Nudge them with the xytext offset, shrink the font, or label only the teams you want to highlight (filter the DataFrame before the annotate loop). For full de-confliction, the adjustText library repositions labels automatically.

The colorbar squishes the plot

Pass fraction=0.046, pad=0.04 to fig.colorbar to keep it slim, or give the figure a little more width in figsize.

Challenge yourself

Scale each point by wins — pass s=df["W"]*4 to scatter — so good records literally loom larger, and check whether the biggest dots really do cluster in the top-right. Then label the four quadrants with ax.text ("Contenders", "Rebuild", and the two lopsided corners) so the chart explains itself to someone who's never seen a rating before.

Get the code

Here's the complete, working script for this tutorial. It runs exactly as shown.

Download the finished script (42_efficiency_landscape_ortg_vs_drtg.py)

This script imports a small shared helper (and reads any bundled sample data) that live next to it in /downloads/ — grab these into the same folder so it runs as-is: sdt_common.py, sdt_nba.py.

More Basketball tutorials

A current-standings DataFrame from nba_api, with the proper headers baked in.
Basketball Beginner

Pull Your First NBA Data with nba_api

Pull NBA standings with nba_api, with the browser headers and retry logic stats.nba.com demands. Includes exactly what to do when the endpoint refuses to answer.

~9 min
A ranked net-rating table styled like a real dashboard, exported as an image.
Basketball Intermediate

Build a Team Net-Rating Dashboard Table

Combine offensive and defensive ratings into a ranked net-rating table, then style it into a dashboard-quality figure you can drop into a report.

~8 min
A half-court drawn in matplotlib with a player's makes and misses plotted on it.
Basketball Intermediate

Draw an NBA Shot Chart with matplotlib

Draw a regulation half-court from scratch in matplotlib, then plot a player's makes and misses in court coordinates for a real, shareable shot chart.

~10 min