Spaces:
Paused
Paused
AIBRUH
Claude Opus 5.5
Add IB 2.0 pass-rush matchup score, calibrated from 2023-2025 play by play
6fe7db5 Download scripts/ib_deep_dive.py from AIBRUH/big-proppa: direct link, hf CLI and curl.
- Browser
- Download file 30.7 kB
-
https://huggingface.co/spaces/AIBRUH/big-proppa/resolve/main/scripts/ib_deep_dive.py
- Command line
-
hf download hf://spaces/AIBRUH/big-proppa/scripts/ib_deep_dive.py
-
curl -L -o ib_deep_dive.py https://huggingface.co/spaces/AIBRUH/big-proppa/resolve/main/scripts/ib_deep_dive.py
30.7 kB
| """ | |
| IB 2.0 deep dive -- what actually rattles a QB, 2023-2025, play by play. | |
| Joins nflverse play_by_play + pbp_participation (was_pressure, pass rushers, | |
| time_to_throw, coverage) + FTN charting (blitzers, interception-worthy throws, | |
| QB out of pocket). Raw files live in lake/bronze/nflverse/ (gitignored). | |
| Sections | |
| A play level: which conditions produce interceptions / INT-worthy throws | |
| B rattle carryover: does being hit earlier make a QB worse later, with the | |
| current play's pressure held fixed | |
| C defense level: pre-game IB 1.0 (sacks/INTs/points, the original formula) | |
| vs IB 2.0 (true pressure, 4-man pressure, blitz, INT-worthy forced) -- | |
| which one predicts the opponent's passing game, trained 2023-24, | |
| scored on 2025 untouched | |
| D in-game: first-half pressure as a live signal for the second half | |
| E receivers: where the targets go when the pocket collapses | |
| Run from the repo root: | |
| backend/.venv/bin/python scripts/ib_deep_dive.py | |
| """ | |
| from __future__ import annotations | |
| import json | |
| from pathlib import Path | |
| import numpy as np | |
| import pandas as pd | |
| from sklearn.linear_model import LinearRegression, LogisticRegression | |
| from sklearn.metrics import roc_auc_score | |
| REPO = Path(__file__).resolve().parent.parent | |
| BRONZE = REPO / "lake" / "bronze" / "nflverse" | |
| GOLD = REPO / "lake" / "gold" / "nfl" | |
| SEASONS = (2023, 2024, 2025) | |
| TRAIN, TEST = (2023, 2024), 2025 | |
| PRIOR_DB = 150 # dropbacks of prior-season weight before a defense's own games take over | |
| PBP_COLS = [ | |
| "game_id", "play_id", "season", "week", "season_type", "posteam", "defteam", "home_team", | |
| "passer_player_id", "passer_player_name", "receiver_player_id", "receiver_player_name", | |
| "qb_dropback", "pass_attempt", "sack", "qb_hit", "qb_scramble", "qb_spike", "qb_kneel", "interception", | |
| "complete_pass", "passing_yards", "receiving_yards", "air_yards", "down", "ydstogo", "yardline_100", "qtr", | |
| "game_half", "score_differential", "half_seconds_remaining", "wp", "shotgun", "no_huddle", | |
| "epa", "cpoe", "two_point_attempt", "spread_line", "total_line", "fumble_lost", "pass_touchdown", | |
| ] | |
| # ββ load βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| PART_COLS = ["was_pressure", "number_of_pass_rushers", "time_to_throw", "defense_coverage_type", | |
| "defense_man_zone_type", "defenders_in_box"] | |
| FTN_COLS = ["n_blitzers", "ftn_rushers", "is_interception_worthy", "is_qb_out_of_pocket", "is_play_action", | |
| "is_screen_pass", "is_throw_away", "is_qb_fault_sack"] | |
| def load_dropbacks(seasons=SEASONS) -> pd.DataFrame: | |
| """Participation / FTN files are optional per season (in-season years may only have play-by-play).""" | |
| frames = [] | |
| for s in seasons: | |
| pbp = pd.read_csv(BRONZE / f"play_by_play_{s}.csv.gz", usecols=PBP_COLS, low_memory=False) | |
| pbp = pbp[(pbp.season_type == "REG") & (pbp.qb_dropback == 1) & (pbp.two_point_attempt.fillna(0) == 0) | |
| & (pbp.qb_spike.fillna(0) == 0) & (pbp.qb_kneel.fillna(0) == 0)] | |
| df = pbp | |
| if (BRONZE / f"pbp_participation_{s}.csv").exists(): | |
| part = pd.read_csv(BRONZE / f"pbp_participation_{s}.csv", low_memory=False, | |
| usecols=["nflverse_game_id", "play_id"] + PART_COLS) | |
| df = df.merge(part.rename(columns={"nflverse_game_id": "game_id"}), on=["game_id", "play_id"], how="left") | |
| if (BRONZE / f"ftn_charting_{s}.csv").exists(): | |
| ftn = pd.read_csv(BRONZE / f"ftn_charting_{s}.csv", low_memory=False, | |
| usecols=["nflverse_game_id", "nflverse_play_id", "n_blitzers", "n_pass_rushers", | |
| "is_interception_worthy", "is_qb_out_of_pocket", "is_play_action", | |
| "is_screen_pass", "is_throw_away", "is_qb_fault_sack"]) | |
| ftn = ftn.rename(columns={"nflverse_game_id": "game_id", "nflverse_play_id": "play_id", | |
| "n_pass_rushers": "ftn_rushers"}) | |
| df = df.merge(ftn, on=["game_id", "play_id"], how="left") | |
| frames.append(df.reindex(columns=list(df.columns) + [c for c in PART_COLS + FTN_COLS if c not in df.columns])) | |
| df = pd.concat(frames, ignore_index=True) | |
| for c in ["was_pressure", "is_interception_worthy", "is_qb_out_of_pocket", "is_play_action", | |
| "is_screen_pass", "is_throw_away", "is_qb_fault_sack"]: | |
| df[c] = df[c].map({True: 1, False: 0, "True": 1, "False": 0, 1: 1, 0: 0}).astype("float") | |
| df["rushers"] = df["number_of_pass_rushers"].fillna(df["ftn_rushers"]) | |
| df["blitz"] = (df["n_blitzers"].fillna(0) > 0).astype(int) | |
| df["pressure"] = df["was_pressure"].fillna(((df.sack == 1) | (df.qb_hit == 1)).astype(float)) | |
| df["pressure4"] = ((df.pressure == 1) & (df.rushers <= 4)).astype(int) # pressure without sending extra men | |
| df["hit_or_sack"] = ((df.sack == 1) | (df.qb_hit == 1)).astype(int) | |
| df["int_worthy"] = df["is_interception_worthy"].fillna(df["interception"]) | |
| df["pass_yds"] = df["passing_yards"].fillna(0) | |
| return df | |
| # ββ A: conditions behind interceptions βββββββββββββββββββββββββββββββββββββββ | |
| def rate_table(df: pd.DataFrame, col: str, label: str) -> list[dict]: | |
| g = df.groupby(col, observed=True).agg(dropbacks=("interception", "size"), ints=("interception", "sum"), | |
| int_worthy=("int_worthy", "sum"), sacks=("sack", "sum"), | |
| epa=("epa", "mean")) | |
| base = df.interception.mean() | |
| out = [] | |
| for k, r in g.iterrows(): | |
| if r.dropbacks < 300: | |
| continue | |
| out.append({"factor": label, "bucket": str(k), "dropbacks": int(r.dropbacks), | |
| "int_rate": round(r.ints / r.dropbacks, 4), "int_worthy_rate": round(r.int_worthy / r.dropbacks, 4), | |
| "sack_rate": round(r.sacks / r.dropbacks, 4), "epa_per_db": round(r.epa, 3), | |
| "int_lift": round((r.ints / r.dropbacks) / base, 2)}) | |
| return out | |
| def section_a(df: pd.DataFrame) -> dict: | |
| d = df.copy() | |
| d["pressure_b"] = d.pressure.map({1.0: "PRESSURED", 0.0: "CLEAN"}) | |
| d["blitzers_b"] = pd.cut(d.n_blitzers, [-1, 0, 1, 2, 20], labels=["0", "1", "2", "3+"]) | |
| d["ttt_b"] = pd.cut(d.time_to_throw, [0, 2.0, 2.5, 3.0, 3.5, 20], labels=["<2.0s", "2.0-2.5s", "2.5-3.0s", "3.0-3.5s", "3.5s+"]) | |
| d["down_dist"] = np.select( | |
| [d.down == 3, d.down == 4, d.ydstogo >= 10], | |
| [np.where(d.ydstogo >= 7, "3rd & 7+", "3rd & <7"), "4th down", "1st/2nd & 10+"], "1st/2nd & <10") | |
| d["game_state"] = np.select( | |
| [(d.qtr >= 4) & (d.score_differential <= -8), (d.qtr >= 4) & (d.score_differential < 0), | |
| d.score_differential >= 8, d.score_differential <= -8], | |
| ["4Q trailing 9+", "4Q trailing 1-8", "leading 8+", "trailing 8+ (Q1-3)"], "within one score") | |
| d["coverage"] = d.defense_coverage_type.where(d.defense_coverage_type.isin( | |
| ["COVER_0", "COVER_1", "COVER_2", "COVER_3", "COVER_4", "COVER_6", "2_MAN", "COVER_9"])) | |
| d["pocket"] = d.is_qb_out_of_pocket.map({1.0: "OUT OF POCKET", 0.0: "IN POCKET"}) | |
| d["pa"] = d.is_play_action.map({1.0: "PLAY ACTION", 0.0: "STRAIGHT DROPBACK"}) | |
| table = [] | |
| for col, label in [("pressure_b", "pressure"), ("blitzers_b", "blitzers"), ("ttt_b", "time_to_throw"), | |
| ("down_dist", "down_distance"), ("game_state", "game_state"), ("coverage", "coverage"), | |
| ("pocket", "pocket"), ("pa", "play_action")]: | |
| table += rate_table(d, col, label) | |
| # Pressure x game state: the "desperate and hunted" combination | |
| combo = d.assign(combo=d.pressure_b + " Β· " + d.game_state) | |
| table += rate_table(combo, "combo", "pressure_x_state") | |
| # Logistic model on INT-worthy throws (less noisy than INTs: drops and lucky picks wash out) | |
| att = d[d.pass_attempt == 1].copy() | |
| feats = pd.DataFrame({ | |
| "pressure": att.pressure.fillna(0), | |
| "blitzers": att.n_blitzers.fillna(0).clip(0, 4), | |
| "rushers5plus": (att.rushers >= 5).astype(int), | |
| "out_of_pocket": att.is_qb_out_of_pocket.fillna(0), | |
| "play_action": att.is_play_action.fillna(0), | |
| "third_long": ((att.down == 3) & (att.ydstogo >= 7)).astype(int), | |
| "trailing_4q": ((att.qtr >= 4) & (att.score_differential < 0)).astype(int), | |
| "wp_low": (att.wp < 0.2).astype(int), | |
| "air_yards_15plus": (att.air_yards >= 15).astype(int), | |
| "cover_0_1": att.defense_coverage_type.isin(["COVER_0", "COVER_1"]).astype(int), | |
| "cover_2_4_6": att.defense_coverage_type.isin(["COVER_2", "COVER_4", "COVER_6", "2_MAN"]).astype(int), | |
| }) | |
| y = att.int_worthy.fillna(0).astype(int) | |
| tr, te = att.season.isin(TRAIN), att.season == TEST | |
| m = LogisticRegression(max_iter=2000).fit(feats[tr], y[tr]) | |
| p = m.predict_proba(feats[te])[:, 1] | |
| q = pd.qcut(p, 10, labels=False, duplicates="drop") | |
| top, bot = y[te].values[q == q.max()].mean(), y[te].values[q == q.min()].mean() | |
| return { | |
| "table": table, | |
| "odds_ratios": {f: round(float(np.exp(c)), 2) for f, c in zip(feats.columns, m.coef_[0])}, | |
| "auc_2025": round(float(roc_auc_score(y[te], p)), 3), | |
| "top_decile_rate": round(float(top), 4), "bottom_decile_rate": round(float(bot), 4), | |
| "base_rate": round(float(y[te].mean()), 4), | |
| } | |
| # ββ B: rattle carryover ββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def section_b(df: pd.DataFrame) -> dict: | |
| d = df.sort_values(["game_id", "play_id"]).copy() | |
| grp = d.groupby(["game_id", "passer_player_id"], sort=False) | |
| d["prior_hits"] = grp.hit_or_sack.cumsum() - d.hit_or_sack | |
| d["prior_db"] = grp.cumcount() | |
| d["hit_last_db"] = grp.hit_or_sack.shift(1).fillna(0) | |
| d["prior_hit_rate"] = d.prior_hits / d.prior_db.replace(0, np.nan) | |
| att = d[(d.pass_attempt == 1) & (d.prior_db >= 8)].copy() | |
| att["rattled"] = pd.cut(att.prior_hit_rate, [-0.01, 0.08, 0.16, 0.25, 1.0], | |
| labels=["0-8% hit so far", "8-16%", "16-25%", "25%+ hit so far"]) | |
| def summarize(frame): | |
| return {"attempts": int(len(frame)), "int_worthy_rate": round(float(frame.int_worthy.mean()), 4), | |
| "int_rate": round(float(frame.interception.mean()), 4), | |
| "ttt": round(float(frame.time_to_throw.mean()), 2), "epa": round(float(frame.epa.mean()), 3), | |
| "cpoe": round(float(frame.cpoe.mean()), 2)} | |
| # Only CLEAN pockets: if the QB still misfires with nobody on him, that is the rattle | |
| clean = att[att.pressure == 0] | |
| by_rattle = {str(k): summarize(g) for k, g in clean.groupby("rattled", observed=True)} | |
| after_hit = {"clean pocket, hit on previous dropback": summarize(clean[clean.hit_last_db == 1]), | |
| "clean pocket, not hit on previous dropback": summarize(clean[clean.hit_last_db == 0])} | |
| X = pd.DataFrame({"prior_hit_rate": att.prior_hit_rate.fillna(0), "pressure_now": att.pressure.fillna(0), | |
| "third_long": ((att.down == 3) & (att.ydstogo >= 7)).astype(int), | |
| "trailing": (att.score_differential < 0).astype(int), "qtr": att.qtr}) | |
| m = LogisticRegression(max_iter=2000).fit(X, att.int_worthy.fillna(0).astype(int)) | |
| # Within-game test: same QB, same game, same defense -- clean-pocket throws right after a hit vs the rest. | |
| # Demeaning by QB-game removes defense quality, weather, game plan and QB talent. | |
| c = clean.assign(qg=clean.game_id + clean.passer_player_id) | |
| c = c[c.groupby("qg").hit_last_db.transform("nunique") == 2] | |
| fe = {} | |
| for col in ["int_worthy", "epa", "cpoe"]: | |
| y = c[col] - c.groupby("qg")[col].transform("mean") | |
| x = c.hit_last_db - c.groupby("qg").hit_last_db.transform("mean") | |
| ok = y.notna() | |
| beta = float((x[ok] * y[ok]).sum() / (x[ok] ** 2).sum()) | |
| resid = y[ok] - beta * x[ok] | |
| se = float(np.sqrt((resid ** 2).sum() / (ok.sum() - 1) / (x[ok] ** 2).sum())) | |
| fe[col] = {"effect_after_hit": round(beta, 4), "se": round(se, 4), "t": round(beta / se, 2)} | |
| return {"within_game_after_hit_clean_pocket": fe, "within_game_n": int(len(c)), | |
| "clean_pocket_by_prior_hit_rate": by_rattle, "clean_pocket_after_hit": after_hit, | |
| "odds_ratio_per_+10pct_prior_hit_rate": round(float(np.exp(m.coef_[0][0] * 0.10)), 3), | |
| "odds_ratio_pressure_now": round(float(np.exp(m.coef_[0][1])), 2)} | |
| # ββ C: defense IB 1.0 vs IB 2.0, pre-game, predicting the opponent's passing game ββ | |
| DEF_STATS = { # name -> (numerator column, denominator column) | |
| "pressure_rate": ("pressure", "db"), | |
| "pressure4_rate": ("pressure4", "rush4_db"), | |
| "blitz_rate": ("blitz", "db"), | |
| "sack_rate": ("sack", "db"), | |
| "hit_rate": ("hit_or_sack", "db"), | |
| "int_worthy_rate": ("int_worthy", "att"), | |
| "int_rate": ("interception", "att"), | |
| "epa_per_db": ("epa", "db"), | |
| "pass_yds_per_game": ("pass_yds", "games"), | |
| "points_allowed": ("points", "games"), | |
| } | |
| def defense_games(df: pd.DataFrame) -> pd.DataFrame: | |
| d = df.assign(att=df.pass_attempt, db=1, rush4_db=(df.rushers <= 4).astype(int)) | |
| g = d.groupby(["season", "week", "game_id", "defteam", "posteam"]).agg( | |
| db=("db", "sum"), att=("att", "sum"), rush4_db=("rush4_db", "sum"), pressure=("pressure", "sum"), | |
| pressure4=("pressure4", "sum"), blitz=("blitz", "sum"), sack=("sack", "sum"), hit_or_sack=("hit_or_sack", "sum"), | |
| int_worthy=("int_worthy", "sum"), interception=("interception", "sum"), epa=("epa", "sum"), | |
| pass_yds=("pass_yds", "sum"), pass_td=("pass_touchdown", "sum"), spread_line=("spread_line", "first"), | |
| total_line=("total_line", "first"), home_team=("home_team", "first"), | |
| ).reset_index() | |
| g["games"] = 1 | |
| # points allowed from team_game_stats (already in the lake) | |
| tgs = pd.read_csv(GOLD / "team_game_stats.csv")[["game_id", "team", "points_against"]] | |
| g = g.merge(tgs.rename(columns={"team": "defteam", "points_against": "points"}), on=["game_id", "defteam"], how="left") | |
| g["points"] = g.points.fillna(g.points.mean()) | |
| return g.sort_values(["defteam", "season", "week"]).reset_index(drop=True) | |
| def pregame(g: pd.DataFrame, key: str, stats: dict) -> pd.DataFrame: | |
| """Each row gets the team's rate from games BEFORE this one, shrunk toward last season (or league mean).""" | |
| out = g.copy() | |
| cols = {c for pair in stats.values() for c in pair} | |
| season_tot = g.groupby([key, "season"])[list(cols)].sum() | |
| league = {name: g[n].sum() / g[dn].sum() for name, (n, dn) in stats.items()} | |
| grp = out.groupby([key, "season"], sort=False) | |
| for name, (n, dn) in stats.items(): | |
| cum_n = grp[n].cumsum() - out[n] | |
| cum_d = grp[dn].cumsum() - out[dn] | |
| prev = [season_tot.loc[(t, s - 1)] if (t, s - 1) in season_tot.index else None | |
| for t, s in zip(out[key], out.season)] | |
| prior = np.array([p[n] / p[dn] if p is not None and p[dn] else league[name] for p in prev]) | |
| prior = 0.67 * prior + 0.33 * league[name] # regress last season a third of the way to the mean | |
| w = PRIOR_DB if dn in ("db", "att", "rush4_db") else 4 # 4 games of prior weight for per-game stats | |
| out[f"pre_{name}"] = (cum_n + prior * w) / (cum_d + w) | |
| return out | |
| def zscore(s: pd.Series, by: pd.Series) -> pd.Series: | |
| return s.groupby(by).transform(lambda x: (x - x.mean()) / (x.std() or 1)) | |
| def section_c(df: pd.DataFrame) -> tuple[dict, pd.DataFrame]: | |
| g = pregame(defense_games(df), "defteam", DEF_STATS) | |
| # offense side: this offense's own pre-game passing level and pressure allowed | |
| off = g.rename(columns={"defteam": "_d", "posteam": "offteam"}) | |
| off = pregame(off, "offteam", {"off_pass_yds": ("pass_yds", "games"), "off_pressure_allowed": ("pressure", "db"), | |
| "off_int_worthy": ("int_worthy", "att"), "off_sack_allowed": ("sack", "db")}) | |
| g = g.merge(off[["game_id", "offteam", "pre_off_pass_yds", "pre_off_pressure_allowed", "pre_off_int_worthy", | |
| "pre_off_sack_allowed"]].rename(columns={"offteam": "posteam"}), on=["game_id", "posteam"]) | |
| wk = g.season.astype(str) + "-" + g.week.astype(str) | |
| # IB 1.0 -- the original lake formula's ingredients: sacks, interceptions, points allowed | |
| g["ib1_raw"] = zscore(g.pre_sack_rate, wk) + zscore(g.pre_int_rate, wk) - zscore(g.pre_points_allowed, wk) | |
| # IB 2.0 -- true pressure, pressure with four, INT-worthy forced, EPA allowed; blitz is NOT rewarded on its own | |
| g["ib2_raw"] = (1.0 * zscore(g.pre_pressure_rate, wk) + 0.8 * zscore(g.pre_pressure4_rate, wk) | |
| + 0.6 * zscore(g.pre_int_worthy_rate, wk) - 0.8 * zscore(g.pre_epa_per_db, wk)) | |
| # MATCHUP -- the defense's pressure against this offense's protection | |
| g["exp_pressure"] = g.pre_pressure_rate * g.pre_off_pressure_allowed / (g.pressure.sum() / g.db.sum()) | |
| g["ib2_matchup_raw"] = g.ib2_raw + 1.2 * zscore(g.pre_off_pressure_allowed, wk) + 0.6 * zscore(g.pre_off_int_worthy, wk) | |
| for c in ["ib1", "ib2", "ib2_matchup"]: | |
| g[c] = pd.qcut(g[f"{c}_raw"].rank(method="first"), 5, labels=[1, 2, 3, 4, 5]).astype(int) | |
| g["team_spread"] = np.where(g.posteam == g.home_team, g.spread_line, -g.spread_line) # + = offense favored | |
| g["pass_under"] = (g.pass_yds < g.pre_off_pass_yds).astype(int) | |
| g["actual_pressure_rate"] = g.pressure / g.db | |
| test = g[g.season == TEST] | |
| buckets = {} | |
| for c in ["ib1", "ib2", "ib2_matchup"]: | |
| t = test.groupby(c).agg(games=("pass_yds", "size"), pass_yds=("pass_yds", "mean"), | |
| vs_own_avg=("pass_yds", lambda x: 0), under_rate=("pass_under", "mean"), | |
| ints=("interception", "mean"), sacks=("sack", "mean"), | |
| pressure=("actual_pressure_rate", "mean")) | |
| t["vs_own_avg"] = test.groupby(c).apply(lambda x: (x.pass_yds - x.pre_off_pass_yds).mean(), include_groups=False) | |
| buckets[c] = [{"score": int(k), **{kk: round(float(v), 3) for kk, v in r.items()}} for k, r in t.iterrows()] | |
| # Incremental value beyond the offense's own level and the Vegas lines | |
| base = ["pre_off_pass_yds", "team_spread", "total_line"] | |
| tr, te = g.season.isin(TRAIN), g.season == TEST | |
| def r2(cols, target): | |
| mdl = LinearRegression().fit(g.loc[tr, cols].fillna(0), g.loc[tr, target]) | |
| pred = mdl.predict(g.loc[te, cols].fillna(0)) | |
| resid = g.loc[te, target] - pred | |
| return round(float(1 - (resid ** 2).sum() / ((g.loc[te, target] - g.loc[te, target].mean()) ** 2).sum()), 4), mdl | |
| incr = {} | |
| for target in ["pass_yds", "interception", "sack", "int_worthy"]: | |
| b, _ = r2(base, target) | |
| v1, _ = r2(base + ["ib1_raw"], target) | |
| v2, _ = r2(base + ["ib2_raw"], target) | |
| vm, m = r2(base + ["ib2_matchup_raw"], target) | |
| incr[target] = {"lines_only": b, "plus_ib1": v1, "plus_ib2": v2, "plus_ib2_matchup": vm, | |
| "yds_per_1sd_ib2_matchup": round(float(m.coef_[-1]), 2) if target == "pass_yds" else None} | |
| # Does the pre-game rate predict the in-game pressure? (stability of each ingredient) | |
| stab = {n: round(float(test[f"pre_{n}"].corr(test[a] / test[b])), 3) | |
| for n, (a, b) in DEF_STATS.items() if b in ("db", "att", "rush4_db")} | |
| stab["exp_pressure(matchup)"] = round(float(test.exp_pressure.corr(test.actual_pressure_rate)), 3) | |
| # Blitz-heavy vs pressure-with-four defenses: two different animals | |
| test = test.assign(style=np.select([(test.pre_blitz_rate > test.pre_blitz_rate.quantile(0.67)), | |
| (test.pre_pressure4_rate > test.pre_pressure4_rate.quantile(0.67))], | |
| ["BLITZ-HEAVY", "PRESSURE WITH FOUR"], "OTHER")) | |
| style = test.groupby("style").agg(games=("pass_yds", "size"), pass_yds_vs_own=("pass_yds", "mean"), | |
| ints=("interception", "mean"), pass_td=("pass_td", "mean"), | |
| epa=("epa", "sum")).reset_index() | |
| style["pass_yds_vs_own"] = test.groupby("style").apply(lambda x: (x.pass_yds - x.pre_off_pass_yds).mean(), | |
| include_groups=False).values | |
| style["epa"] = test.groupby("style").apply(lambda x: x.epa.sum() / x.db.sum(), include_groups=False).values | |
| # Is it already in the lines? Residual after the lines-only model, by IB 2.0 matchup quintile. | |
| _, lm = r2(base, "pass_yds") | |
| # Residuals are centered within each season so a league-wide scoring shift doesn't read as an IB effect. | |
| g["pass_yds_vs_lines"] = g.pass_yds - lm.predict(g[base].fillna(0)) | |
| g["pass_yds_vs_lines"] -= g.groupby("season").pass_yds_vs_lines.transform("median") | |
| _, sm = r2(base, "sack") | |
| g["sacks_vs_lines"] = g.sack - sm.predict(g[base].fillna(0)) | |
| g["sacks_vs_lines"] -= g.groupby("season").sacks_vs_lines.transform("mean") | |
| vs_lines = {} | |
| for season_set, label in [((TEST,), "2025_holdout"), (SEASONS, "2023_2025_all")]: | |
| sub = g[g.season.isin(season_set)] | |
| vs_lines[label] = sub.groupby("ib2_matchup").agg( | |
| games=("pass_yds", "size"), pass_yds_vs_lines=("pass_yds_vs_lines", "mean"), | |
| under_lines_rate=("pass_yds_vs_lines", lambda x: (x < 0).mean()), | |
| sacks_vs_lines=("sacks_vs_lines", "mean"), sacks=("sack", "mean"), ints=("interception", "mean"), | |
| dropbacks=("db", "mean")).round(3).reset_index().to_dict("records") | |
| return {"buckets_2025": buckets, "incremental_r2_2025": incr, "pregame_to_ingame_corr_2025": stab, | |
| "style_2025": style.round(3).to_dict("records"), "vs_lines": vs_lines}, g | |
| # ββ D: first-half pressure as a live second-half signal ββββββββββββββββββββββ | |
| def section_d(df: pd.DataFrame) -> dict: | |
| d = df[df.qtr <= 4] | |
| h = d.groupby(["game_id", "posteam", "game_half"]).agg(db=("pressure", "size"), score=("score_differential", "last"), | |
| pressure=("pressure", "mean"), | |
| hits=("hit_or_sack", "mean"), iw=("int_worthy", "sum"), | |
| ints=("interception", "sum"), yds=("pass_yds", "sum"), | |
| epa=("epa", "mean")).reset_index() | |
| h1 = h[h.game_half == "Half1"].set_index(["game_id", "posteam"]) | |
| h2 = h[h.game_half == "Half2"].set_index(["game_id", "posteam"]) | |
| j = h1.join(h2, lsuffix="_1h", rsuffix="_2h", how="inner") | |
| j = j[(j.db_1h >= 12) & (j.db_2h >= 8)] | |
| j["bucket"] = pd.qcut(j.hits_1h, 4, labels=["low 1H hits", "mid-low", "mid-high", "high 1H hits"]) | |
| t = j.groupby("bucket", observed=True).agg(games=("db_2h", "size"), hit_rate_1h=("hits_1h", "mean"), | |
| pressure_2h=("pressure_2h", "mean"), iw_per_100_2h=("iw_2h", "sum"), | |
| ints_2h=("ints_2h", "mean"), epa_2h=("epa_2h", "mean")) | |
| t["iw_per_100_2h"] = j.groupby("bucket", observed=True).apply(lambda x: 100 * x.iw_2h.sum() / x.db_2h.sum()).values | |
| t["pass_yds_2h"] = j.groupby("bucket", observed=True).yds_2h.mean().values | |
| t["dropbacks_2h"] = j.groupby("bucket", observed=True).db_2h.mean().values | |
| t["halftime_score_diff"] = j.groupby("bucket", observed=True).score_1h.mean().values | |
| # Same comparison among games within one score at the half -- strips out "trailing teams throw more" | |
| close = j[j.score_1h.abs() <= 8] | |
| tc = close.groupby("bucket", observed=True).agg(games=("db_2h", "size"), epa_2h=("epa_2h", "mean"), | |
| pass_yds_2h=("yds_2h", "mean"), ints_2h=("ints_2h", "mean")) | |
| return {"by_first_half_hits": [{"bucket": str(k), **{kk: round(float(v), 3) for kk, v in r.items()}} | |
| for k, r in t.iterrows()], | |
| "close_at_half": [{"bucket": str(k), **{kk: round(float(v), 3) for kk, v in r.items()}} | |
| for k, r in tc.iterrows()], | |
| "corr_1h_hits_2h_epa": round(float(j.hits_1h.corr(j.epa_2h)), 3), | |
| "corr_1h_pressure_2h_pressure": round(float(j.pressure_1h.corr(j.pressure_2h)), 3)} | |
| # ββ E: receivers under a collapsing pocket βββββββββββββββββββββββββββββββββββ | |
| def section_e(df: pd.DataFrame, games: pd.DataFrame) -> dict: | |
| att = df[(df.pass_attempt == 1) & df.receiver_player_id.notna()].copy() | |
| att = att.merge(games[["game_id", "posteam", "ib2_matchup"]], on=["game_id", "posteam"], how="left") | |
| att["depth"] = pd.cut(att.air_yards, [-99, 0, 9, 19, 99], labels=["behind LOS", "short 1-9", "intermediate 10-19", "deep 20+"]) | |
| mix = att.groupby(["ib2_matchup", "depth"], observed=True).size().unstack(fill_value=0) | |
| mix = mix.div(mix.sum(axis=1), axis=0).round(3) | |
| adot = att.groupby("ib2_matchup").air_yards.mean().round(2) | |
| # Receiver game lines vs each receiver's own pre-game average (prior games that season) | |
| rg = att.groupby(["season", "week", "game_id", "posteam", "receiver_player_id"]).agg( | |
| tgt=("pass_attempt", "sum"), rec=("complete_pass", "sum"), yds=("receiving_yards", "sum"), | |
| adot=("air_yards", "mean"), ib=("ib2_matchup", "first")).reset_index() | |
| rg["yds"] = rg.yds.fillna(0) | |
| rg = rg.sort_values(["receiver_player_id", "season", "week"]) | |
| grp = rg.groupby(["receiver_player_id", "season"]) | |
| for c in ["rec", "yds", "tgt", "adot"]: | |
| rg[f"pre_{c}"] = (grp[c].cumsum() - rg[c]) / grp.cumcount().replace(0, np.nan) | |
| rg["n_prior"] = grp.cumcount() | |
| rg = rg[rg.n_prior >= 3] | |
| primary = rg[rg.pre_yds >= 50] # WR1/TE1-type volume receivers | |
| checkdown = rg[(rg.pre_adot < 4) & (rg.pre_tgt >= 3)] # RBs/TEs living behind or near the line | |
| def by_ib(frame, stat): | |
| t = frame.groupby("ib").apply(lambda x: pd.Series({ | |
| "players": len(x), "avg_vs_own": (x[stat] - x[f"pre_{stat}"]).mean(), | |
| "under_own_rate": (x[stat] < x[f"pre_{stat}"]).mean()}), include_groups=False) | |
| return [{"ib2_matchup": int(k), **{kk: round(float(v), 3) for kk, v in r.items()}} for k, r in t.iterrows()] | |
| return {"target_depth_mix_by_ib2_matchup": {int(k): v for k, v in mix.to_dict("index").items()}, | |
| "adot_by_ib2_matchup": {int(k): float(v) for k, v in adot.items()}, | |
| "primary_receiver_yds": by_ib(primary, "yds"), | |
| "checkdown_receptions": by_ib(checkdown, "rec")} | |
| # ββ F: QB pressure profile -- is the QB's reaction to pressure a stable trait? ββ | |
| def section_f(df: pd.DataFrame) -> tuple[dict, pd.DataFrame]: | |
| d = df[df.passer_player_id.notna()] | |
| rows = [] | |
| for (pid, s), x in d.groupby(["passer_player_id", "season"]): | |
| if len(x) < 200: | |
| continue | |
| pr, cl = x[x.pressure == 1], x[x.pressure == 0] | |
| rows.append({"passer_player_id": pid, "season": s, "name": x.passer_player_name.mode().iat[0], | |
| "team": x.posteam.mode().iat[0], "dropbacks": len(x), | |
| "pressure_rate_faced": x.pressure.mean(), | |
| "pressure_to_sack": pr.sack.mean(), | |
| "epa_clean": cl.epa.mean(), "epa_pressured": pr.epa.mean(), | |
| "pressure_epa_drop": cl.epa.mean() - pr.epa.mean(), | |
| "int_worthy_clean": cl.int_worthy.mean(), "int_worthy_pressured": pr.int_worthy.mean(), | |
| "ttt": x.time_to_throw.mean(), "hold_3s_rate": (x.time_to_throw >= 3).mean(), | |
| "out_of_pocket_rate": x.is_qb_out_of_pocket.mean(), "int_worthy_rate": x.int_worthy.mean()}) | |
| q = pd.DataFrame(rows) | |
| nxt = q.merge(q.assign(season=q.season - 1), on=["passer_player_id", "season"], suffixes=("", "_next")) | |
| stab = {c: round(float(nxt[c].corr(nxt[f"{c}_next"])), 3) | |
| for c in ["pressure_rate_faced", "pressure_to_sack", "pressure_epa_drop", "epa_clean", "epa_pressured", | |
| "int_worthy_clean", "int_worthy_pressured", "ttt", "hold_3s_rate", "int_worthy_rate"]} | |
| return {"year_over_year_corr": stab, "qb_pairs": int(len(nxt))}, q | |
| def main() -> None: | |
| df = load_dropbacks() | |
| print(f"dropbacks {len(df):,} pressure coverage {df.was_pressure.notna().mean():.1%} " | |
| f"FTN coverage {df.is_interception_worthy.notna().mean():.1%}") | |
| a = section_a(df) | |
| b = section_b(df) | |
| c, games = section_c(df) | |
| d = section_d(df) | |
| e = section_e(df, games) | |
| f, qb = section_f(df) | |
| qb.round(4).to_csv(GOLD / "ib2_qb_pressure_profile.csv", index=False) | |
| # 2026 proxy check: can pbp-only (sack/hit/scramble) stand in for true pressure at the team level? | |
| proxy = df.assign(proxy=((df.sack == 1) | (df.qb_hit == 1) | (df.qb_scramble == 1)).astype(int)) \ | |
| .groupby(["season", "defteam"]).agg(p=("pressure", "mean"), x=("proxy", "mean")) | |
| c["proxy_team_corr"] = round(float(proxy.p.corr(proxy.x)), 3) | |
| summary = {"A_int_conditions": a, "B_rattle": b, "C_defense": c, "D_in_game": d, "E_receivers": e, "F_qb": f, | |
| "n_dropbacks": int(len(df)), "n_int": int(df.interception.sum()), | |
| "n_int_worthy": int(df.int_worthy.sum())} | |
| GOLD.mkdir(parents=True, exist_ok=True) | |
| pd.DataFrame(a["table"]).to_csv(GOLD / "ib2_int_conditions.csv", index=False) | |
| keep = ["season", "week", "game_id", "defteam", "posteam", "db", "pressure", "sack", "interception", "int_worthy", | |
| "pass_yds", "pre_off_pass_yds", "pre_pressure_rate", "pre_pressure4_rate", "pre_blitz_rate", | |
| "pre_int_worthy_rate", "pre_epa_per_db", "pre_off_pressure_allowed", "exp_pressure", | |
| "ib1", "ib2", "ib2_matchup", "ib2_raw", "ib2_matchup_raw"] | |
| games[keep].round(4).to_csv(GOLD / "ib2_defense_game_backtest.csv", index=False) | |
| (REPO / "lake" / "gold" / "nfl" / "ib2_deep_dive_summary.json").write_text(json.dumps(summary, indent=1, default=str)) | |
| print(json.dumps({k: v for k, v in summary.items() if k != "A_int_conditions"}, indent=1, default=str)[:12000]) | |
| print(json.dumps({k: v for k, v in a.items() if k != "table"}, indent=1)) | |
| if __name__ == "__main__": | |
| main() | |