Spaces:
Sleeping
Sleeping
Download app.py from MorelandMSBA/SeparationScoreboard: direct link, hf CLI and curl.
- Browser
- Download file 30.3 kB
-
https://huggingface.co/spaces/MorelandMSBA/SeparationScoreboard/resolve/main/app.py
- Command line
-
hf download hf://spaces/MorelandMSBA/SeparationScoreboard/app.py
-
curl -L -o app.py https://huggingface.co/spaces/MorelandMSBA/SeparationScoreboard/resolve/main/app.py
30.3 kB
| """Separation Rating — Hudl x UVA MSBA capstone demo (Gradio, HF Spaces).""" | |
| import os | |
| # ZeroGPU compatibility: this Space runs on ZeroGPU hardware, which requires at | |
| # least one @spaces.GPU-decorated function at startup. The app itself is pure | |
| # CPU (pandas); this no-op exists only to satisfy that platform requirement and | |
| # is never on the user path. | |
| try: | |
| import spaces | |
| def _zerogpu_startup_check(): | |
| return "ok" | |
| except ImportError: | |
| pass # running locally or on CPU hardware | |
| import pandas as pd | |
| import numpy as np | |
| import gradio as gr | |
| HERE = os.path.dirname(os.path.abspath(__file__)) | |
| # Hudl IQ palette (sampled from hudl.com/products/football-iq) | |
| ORANGE = "#FF6300" # Hudl brand orange | |
| BLUE = "#0A93F5" # interactive blue | |
| BG = "#101417" # page background | |
| PANEL = "#191F24" # panel background | |
| CARD = "#232A31" # card background | |
| LINE = "#2E3841" # hairlines | |
| TEXT2 = "#9DAAB8" # secondary text | |
| TIER_ORDER = ["Elite", "Star", "Above Avg", "Starter", "Rotation", "Below"] | |
| TIER_COLORS = {"Elite": ORANGE, "Star": "#FF8A3D", "Above Avg": BLUE, | |
| "Starter": "#9DAAB8", "Rotation": "#7A8A99", "Below": "#4E5D6C"} | |
| # ── season data: one flat file per season in data/ ───────────────────────── | |
| SEASON_FILES = {"2022": "app_players.csv", "2023": "app_players_2023.csv"} | |
| DFS = {} | |
| for _season, _fname in SEASON_FILES.items(): | |
| _p = os.path.join(HERE, "data", _fname) | |
| if os.path.exists(_p): | |
| _d = pd.read_csv(_p) | |
| # decode-file placeholder weights (e.g., 0 lbs) display as n/a instead | |
| _d.loc[_d["weight_pounds"].notna() & (_d["weight_pounds"] < 100), "weight_pounds"] = np.nan | |
| DFS[_season] = _d | |
| SEASONS = list(DFS.keys()) or ["2022"] | |
| DEFAULT_SEASON = SEASONS[-1] # latest season present | |
| def _df(season): | |
| return DFS.get(season, pd.DataFrame()) | |
| def _teams(d): | |
| return ["All"] + sorted(d["team_short_name"].dropna().unique().tolist()) if len(d) else ["All"] | |
| def _players(d): | |
| return sorted(d["player_name"].dropna().unique().tolist()) if len(d) else [] | |
| def top_route(p): | |
| """First entry of route_split is the highest-volume route: 'post +0.31 (22) | ...'""" | |
| s = p.get("route_split") | |
| if not isinstance(s, str) or not s.strip(): | |
| return "n/a" | |
| first = s.split(" | ")[0] | |
| parts = first.split() | |
| name = " ".join(parts[:-2]) if len(parts) > 2 else first | |
| return name.replace("_", "/") | |
| def _route_choices(d): | |
| if not len(d): | |
| return ["Any"] | |
| routes = sorted({top_route(p) for _, p in d.iterrows()} - {"n/a"}) | |
| return ["Any"] + routes | |
| # ── card + carousel rendering ────────────────────────────────────────────── | |
| def _subtitle(p): | |
| bits = [str(p["team_short_name"]), str(p["position"])] | |
| if pd.notna(p.get("class_type")): | |
| bits.append(str(p["class_type"])) | |
| return " · ".join(bits) | |
| def _card_html(p, badge=None, note=None): | |
| tier = p.get("tier", "") | |
| tcol = TIER_COLORS.get(tier, TEXT2) | |
| badge_html = (f'<div style="font-size:11px;font-weight:700;letter-spacing:.08em;' | |
| f'text-transform:uppercase;color:{ORANGE};margin-bottom:6px;">{badge}</div>' | |
| if badge else "") | |
| note_html = (f'<div style="margin-top:10px;font-size:12px;color:{BLUE};font-weight:600;">{note}</div>' | |
| if note else "") | |
| return f""" | |
| <div style="flex:1 1 210px;min-width:210px;scroll-snap-align:start;background:{CARD};border:1px solid {LINE}; | |
| border-top:3px solid {ORANGE};border-radius:8px;padding:16px 18px;"> | |
| {badge_html} | |
| <div style="font-size:17px;font-weight:700;color:#fff;line-height:1.2;">{p['player_name']}</div> | |
| <div style="font-size:12px;color:{TEXT2};margin:2px 0 12px;">{_subtitle(p)}</div> | |
| <div style="display:flex;align-items:baseline;gap:10px;"> | |
| <div style="font-size:26px;font-weight:800;color:#fff;">{p['shrunk']:+.2f}</div> | |
| <div style="font-size:11px;color:{TEXT2};">RSOE (yd)</div> | |
| <div style="margin-left:auto;font-size:11px;font-weight:700;padding:3px 8px;border-radius:4px; | |
| white-space:nowrap;background:{tcol}22;color:{tcol};border:1px solid {tcol};">{tier}</div> | |
| </div> | |
| <div style="display:flex;gap:0;margin-top:12px;border-top:1px solid {LINE};padding-top:10px;"> | |
| <div style="flex:1;"><div style="font-size:15px;font-weight:700;color:#fff;">{int(p['n'])}</div> | |
| <div style="font-size:10px;color:{TEXT2};text-transform:uppercase;letter-spacing:.05em;">Targets</div></div> | |
| <div style="flex:1;"><div style="font-size:15px;font-weight:700;color:#fff;">{int(p['catches'])}</div> | |
| <div style="font-size:10px;color:{TEXT2};text-transform:uppercase;letter-spacing:.05em;">Catches</div></div> | |
| <div style="flex:1.4;"><div style="font-size:15px;font-weight:700;color:#fff;">{top_route(p)}</div> | |
| <div style="font-size:10px;color:{TEXT2};text-transform:uppercase;letter-spacing:.05em;">Top route</div></div> | |
| </div> | |
| {note_html} | |
| </div>""" | |
| def _carousel(cards): | |
| return (f'<div style="display:flex;gap:14px;overflow-x:auto;scroll-snap-type:x mandatory;' | |
| f'padding:4px 2px 12px;">{"".join(cards)}</div>') | |
| def _spotlight_cards(q, pos_label): | |
| """The three standout callouts for one position group.""" | |
| cards = [] | |
| if not len(q): | |
| return cards | |
| top = q.sort_values(["sep_rating", "shrunk"], ascending=False).iloc[0] | |
| cards.append(_card_html(top, badge=f"Highest Separation Rating · {pos_label}", | |
| note=f"Separation Rating {int(top['sep_rating'])}")) | |
| rz = q[q["rz_rating"].notna()] | |
| if len(rz): | |
| p = rz.sort_values(["rz_rating", "rz_n"], ascending=False).iloc[0] | |
| cards.append(_card_html(p, badge=f"Red Zone Standout · {pos_label}", | |
| note=f"Red zone rating {int(p['rz_rating'])} ({int(p['rz_n'])} RZ targets)")) | |
| cw = q[q["contested_att"] >= 8] | |
| if len(cw): | |
| p = cw.sort_values(["contested_win_rate", "contested_att"], ascending=False).iloc[0] | |
| cards.append(_card_html(p, badge=f"Contested-Catch Standout · {pos_label}", | |
| note=f"{p['contested_win_rate']:.0f}% contested wins ({int(p['contested_att'])} att)")) | |
| return cards | |
| def spotlight_row(season): | |
| """Leaderboard hero row: WR and TE callouts side by side, six across.""" | |
| d = _df(season) | |
| if not len(d): | |
| return f"<p>data/{SEASON_FILES.get(season, '?')} not found</p>" | |
| q = d[d["n"] >= 15] | |
| cards = [] | |
| for pos in ("WR", "TE"): | |
| cards.extend(_spotlight_cards(q[q["position"] == pos], pos)) | |
| return _carousel(cards) | |
| # ── tables ───────────────────────────────────────────────────────────────── | |
| LB_COLS = ["player_name", "team_short_name", "position", "n", "sep_rating", "tier", | |
| "percentile", "sched_rank", "shrunk"] | |
| LB_LABELS = ["Player", "Team", "Pos", "Targets", "Separation Rating", "Tier", | |
| "Percentile", "Sched Rank", "RSOE (shrunk)"] | |
| def leaderboard(season, search, team, pos, tier, min_targets): | |
| d = _df(season) | |
| if not len(d): | |
| return pd.DataFrame({"error": [f"data/{SEASON_FILES.get(season, '?')} not found — run the prep notebook and add it"]}) | |
| out = d.copy() | |
| if search: | |
| out = out[out["player_name"].str.contains(search, case=False, na=False)] | |
| if team != "All": | |
| out = out[out["team_short_name"] == team] | |
| if pos != "All": | |
| out = out[out["position"] == pos] | |
| if tier != "All": | |
| out = out[out["tier"] == tier] | |
| out = out[out["n"] >= min_targets] | |
| out = out.sort_values(["sep_rating", "shrunk"], ascending=False) | |
| view = out[LB_COLS].copy() | |
| view.columns = LB_LABELS | |
| return view.reset_index(drop=True) | |
| DRAFT_CLASSES = ["SR", "RS-SR", "Graduate"] | |
| ELIG_CHOICES = ["Any", "Returning (transfer candidates)", "Seniors & grads (draft)"] | |
| def recruit_search(season, pos, elig, tier, min_rating, min_targets, route, min_contested, | |
| min_rz, min_height, min_weight): | |
| """The recruiting finder: filter the board by what a staff is shopping for.""" | |
| d = _df(season) | |
| if not len(d): | |
| return f"data/{SEASON_FILES.get(season, '?')} not found", "", pd.DataFrame() | |
| s = d.copy() | |
| s["top_route"] = s.apply(top_route, axis=1) | |
| if pos != "All": | |
| s = s[s["position"] == pos] | |
| if elig == ELIG_CHOICES[1]: | |
| s = s[s["class_type"].notna() & ~s["class_type"].isin(DRAFT_CLASSES)] | |
| elif elig == ELIG_CHOICES[2]: | |
| s = s[s["class_type"].isin(DRAFT_CLASSES)] | |
| if tier != "All": | |
| s = s[s["tier"] == tier] | |
| s = s[(s["sep_rating"] >= min_rating) & (s["n"] >= min_targets)] | |
| if route != "Any": | |
| s = s[s["top_route"] == route] | |
| if min_contested > 0: | |
| s = s[(s["contested_att"] >= 5) & (s["contested_win_rate"] >= min_contested)] | |
| if min_rz > 40: | |
| s = s[s["rz_rating"].notna() & (s["rz_rating"] >= min_rz)] | |
| if min_height > 66: | |
| s = s[s["height_inches"].notna() & (s["height_inches"] >= min_height)] | |
| if min_weight > 150: | |
| s = s[s["weight_pounds"].notna() & (s["weight_pounds"] >= min_weight)] | |
| s = s.sort_values(["sep_rating", "shrunk"], ascending=False) | |
| if not len(s): | |
| return ("**0 players match.** Loosen a filter (rating floor, size, or thresholds) and search again.", | |
| "", pd.DataFrame()) | |
| summary = (f"**{len(s)} players match.** Ranked by Separation Rating; " | |
| f"every rating is opponent-adjusted and shrunk for sample size.") | |
| cards = [_card_html(p, badge=f"Match {i + 1}", | |
| note=f"Separation Rating {int(p['sep_rating'])}") | |
| for i, (_, p) in enumerate(s.head(10).iterrows())] | |
| view = s[["player_name", "team_short_name", "position", "class_type", "sep_rating", | |
| "tier", "shrunk", "n", "catches", "top_route", "contested_win_rate", | |
| "rz_rating"]].copy() | |
| view.columns = ["Player", f"{season} Team", "Pos", "Class", "Separation Rating", "Tier", | |
| "RSOE", "Targets", "Catches", "Most frequent route", | |
| "Contested win %", "RZ rating"] | |
| return summary, _carousel(cards), view.reset_index(drop=True) | |
| def player_card(season, name): | |
| d = _df(season) | |
| if not len(d) or not name: | |
| return "Select a player." | |
| m = d[d["player_name"] == name] | |
| if not len(m): | |
| return f"No player named {name} in {season}." | |
| p = m.iloc[0] | |
| rz = f"{int(p['rz_rating'])} ({int(p['rz_n'])} RZ targets)" if pd.notna(p.get("rz_rating")) else "not enough red zone targets" | |
| cw = f"{p['contested_win_rate']:.0f}% on {int(p['contested_att'])} contested" if pd.notna(p.get("contested_win_rate")) else "no contested targets" | |
| hw = "" | |
| if pd.notna(p.get("height_inches")): | |
| hw = f"{int(p['height_inches'] // 12)}'{int(p['height_inches'] % 12)}\" · {int(p['weight_pounds'])} lbs" if pd.notna(p.get("weight_pounds")) else "" | |
| cls = p.get("class_type") if pd.notna(p.get("class_type")) else "n/a" | |
| card = f""" | |
| ## {p['player_name']} — {p['team_short_name']} ({p['position']}) · {season} | |
| # Separation Rating: {int(p['sep_rating'])} · {p['tier']} | |
| | | | | |
| |---|---| | |
| | Targets | {int(p['n'])} | | |
| | RSOE (shrunk / raw) | {p['shrunk']:+.2f} / {p['raw']:+.2f} yd | | |
| | Percentile | {int(p['percentile'])} | | |
| | Red zone rating | {rz} | | |
| | Contested catch | {cw} | | |
| | Schedule rank | {int(p['sched_rank'])} (1 = toughest coverage slate) | | |
| | Season line | {int(p['catches'])} catches · {int(p['yards'])} yds · {int(p['yac'])} YAC · {p['epa_per_target']:+.2f} EPA/target | | |
| | Measurables | {hw or 'n/a'} | | |
| | Class | {cls} | | |
| **By route:** {p.get('route_split') if pd.notna(p.get('route_split')) else 'n/a'} | |
| **By coverage:** {p.get('coverage_split') if pd.notna(p.get('coverage_split')) else 'n/a'} | |
| """ | |
| return card | |
| # ── Start Here explainers (pinned to 2022, the season the numbers cite) ──── | |
| EXPLAIN_SEASON = "2022" if "2022" in DFS else DEFAULT_SEASON | |
| def _named(name): | |
| d = _df(EXPLAIN_SEASON) | |
| if not len(d): | |
| return None | |
| m = d[d["player_name"] == name] | |
| return m.iloc[0] if len(m) else None | |
| def _fmt(p): | |
| return f"**{p['player_name']}** ({p['team_short_name']}, {p['position']})" | |
| def build_explainers(): | |
| """(question, answer) pairs for the Start Here accordions, using live numbers.""" | |
| d = _df(EXPLAIN_SEASON) | |
| top = d[d["n"] >= 15].sort_values(["sep_rating", "shrunk"], ascending=False).iloc[0] if len(d) else None | |
| hyatt, mayer, tinsley = _named("Jalin Hyatt"), _named("Michael Mayer"), _named("Mitchell Tinsley") | |
| q1 = ("What is RSOE?", | |
| "RSOE is **Residual Separation Over Expected**: how open a receiver actually was at the catch " | |
| "point, minus how open the **situation** said he should have been.\n\n" | |
| "A model looks at everything outside the receiver's control on the play (coverage, cushion, route " | |
| "called, alignment, field position, opponent quality) and predicts the separation an average D1 " | |
| "receiver would get there. Whatever is left over is the player.\n\n" | |
| "- **Positive RSOE**: he gets more open than the play should allow. That is skill.\n" | |
| "- **Negative RSOE**: he gets less open than an average receiver would in the same spots.\n\n" | |
| + (f"Example: {_fmt(top)} runs at **{top['shrunk']:+.2f} yards** per target. That much extra " | |
| f"openness on every route, over {int(top['n'])} targets, is the difference between contested " | |
| f"throws and easy completions." if top is not None else "")) | |
| q2 = ("How do I read the 40-99 Separation Rating?", | |
| "It is the Madden convention applied to RSOE: **75 = average D1 starter**, and every 12 points is " | |
| "one standard deviation. Tiers: Elite 95+, Star 88-94, Above Avg 80-87, Starter 70-79, Rotation " | |
| "60-69, Below under 60.\n\n" | |
| "Small samples are **shrunk toward 75** before scaling, so a hot 12-target month cannot fake an " | |
| "Elite badge. A rating is about half signal at 100 targets, which is why every card shows the " | |
| "target count next to the number.\n\n" | |
| + (f"Face validity check: {_fmt(hyatt)}, the 2022 Biletnikoff winner, rates " | |
| f"**{int(hyatt['sep_rating'])}**, and his coverage schedule ranked " | |
| f"{int(hyatt['sched_rank'])} of {len(d)} in difficulty (1 = toughest), so the number is " | |
| f"not a soft-schedule artifact." if hyatt is not None else "")) | |
| med_n = int(d["n"].median()) if len(d) else 0 | |
| q2b = ("How do we know the rating is skill and not luck?", | |
| "This was the central test in developing the metric: prove that average RSOE measures the " | |
| "player, not his bounces.\n\n" | |
| "**Any single target is mostly noise.** Whether a receiver got 2 or 4 yards of separation on one " | |
| "play depends on the coverage call, ball placement, a slip, a busted assignment. One play tells " | |
| "you almost nothing. Average over many plays and the luck cancels while the skill keeps pointing " | |
| "the same direction. The question is how many plays until the average is mostly him.\n\n" | |
| "**How we measured it**: split each player's season in half at random (by games), compute his " | |
| "RSOE on each half separately, and check whether the halves agree. Pure luck would mean zero " | |
| "agreement; pure skill would mean a near-perfect match. We repeated this 200 times with " | |
| "different random splits.\n\n" | |
| "**The answer: about 100 targets is the 50/50 point.** At 100 targets, a player's average RSOE " | |
| "is roughly half true skill, half leftover noise. Concretely: a player showing +0.30 RSOE on " | |
| "100 targets has a best talent estimate near +0.15, halfway between what you observed and " | |
| "league average.\n\n" | |
| "**This is exactly what shrinkage corrects.** Before scaling to 40-99, every raw average is " | |
| "pulled toward the league mean in proportion to how little data it rests on. A 20-target player " | |
| "gets pulled hard; a 110-target player barely moves. So the rating you see already carries the " | |
| "appropriate skepticism, and a 90 on 25 targets means the raw number was spectacular enough to " | |
| "survive aggressive shrinkage.\n\n" | |
| "**Season two confirmed it.** With 2023 in hand, a player's 2022 rating predicts his 2023 " | |
| "result at roughly 85% of the theoretical maximum his sample size allows. The signal is real " | |
| "and it persists.\n\n" | |
| + (f"**The honest limit**: the median player on this board has {med_n} targets, well short of " | |
| f"100, so most single-season ratings lean heavily on shrinkage. That is the reality of " | |
| f"one-season data, not a flaw of the metric, and it is the case for adding more seasons. " | |
| f"For context, this stabilization framing is standard in sports analytics, and roughly 100 " | |
| f"targets is fast for a football stat." if med_n else "")) | |
| q3 = ("What actually predicts separation? (the biggest RSOE drivers)", | |
| "We measured this with exact SHAP values on the frozen model (25,000 plays; numbers are yards of " | |
| "expected separation moved per play, so they read in football units). The average play carries " | |
| "**3.65 yards** of expected separation, and here is what moves it:\n\n" | |
| "1. **The route called (0.93)**: by far the biggest driver. A flat route leaks its man into open " | |
| "grass by design (0.27 on its own); a go ball runs into coverage by design (0.21). The model has " | |
| "to know the assignment before it can judge the player.\n" | |
| "2. **Man vs zone (0.48)**: the zone-coverage probability is the single largest individual signal " | |
| "in the model (0.34). Zone concedes space; man contests it. Scheme families (Cover 2, Cover 4) " | |
| "add the rest.\n" | |
| "3. **Where he lines up (0.41)**: alignment depth, width, and wide/slot/tight combined. Slot " | |
| "receivers see space; boundary receivers see sideline.\n" | |
| "4. **Defender cushion (0.30)**: average and closest DB depth at the snap. Off coverage hands out " | |
| "separation; press takes it away.\n" | |
| "5. **Situation (0.22)**: down, field position, distance, and score together.\n" | |
| "6. **Opponent quality (0.15)**: team and DB on-field coverage ratings (leave-one-game-out). " | |
| "Modest per play, but it is exactly the fairness adjustment that puts every schedule on one scale.\n\n" | |
| "Screens are excluded entirely, and anything the receiver himself controls (his speed, his release) " | |
| "is deliberately kept **out** so the residual stays pure skill.") | |
| q4 = ("What is the red zone rating?", | |
| "The same rating, computed only on targets inside the opponent's 20, where average separation " | |
| "compresses from about 3.8 to 2.6 yards. The model expects that compression, so the red zone rating " | |
| "isolates **who still gets open when the field disappears**. Shown once a player has 5+ red zone " | |
| "targets; shrunk the same way as the main rating.\n\n" | |
| + (f"Example: {_fmt(tinsley)} carries a red zone rating of **{int(tinsley['rz_rating'])}** on " | |
| f"{int(tinsley['rz_n'])} red zone targets, a scoring-area specialist the overall number alone " | |
| f"would undersell." if tinsley is not None and pd.notna(tinsley.get('rz_rating')) else "")) | |
| q5 = ("Why do some famous players rate low?", | |
| "Because getting open is one skill, and winning when **not** open is another. Big-body receivers " | |
| "and inline tight ends are often schemed into contested catches on purpose.\n\n" | |
| + (f"Example: {_fmt(mayer)} sits at **{mayer['shrunk']:+.2f} RSOE** (rating " | |
| f"{int(mayer['sep_rating'])}), yet wins **{mayer['contested_win_rate']:.0f}%** of his " | |
| f"{int(mayer['contested_att'])} contested balls. " if mayer is not None else "") | |
| + "That is not a red flag; it is a different evaluation lane. Read Separation Rating and contested " | |
| "win rate together: separator profiles live top-left, contested winners live bottom-right, and " | |
| "both can play on Sundays.") | |
| return [q1, q2, q2b, q3, q4, q5] | |
| HOW_TO = """ | |
| ### How to use the app | |
| - **Season**: the selector at the top switches every tab between the 2022 and 2023 boards. | |
| - **Leaderboard**: the spotlight row shows WR and TE standouts. Cut the board with search, team, position, tier, and min targets; the table sorts by Separation Rating. | |
| - **Player card**: below the leaderboard, pick a name for the full profile: rating, red zone rating, contested win rate, schedule rank, season line, and RSOE split by route and by coverage. | |
| - **Recruiting Finder**: set the profile your room needs (position, eligibility, tier, primary route, rating floor, contested, red zone, size) and hit **Find players**. | |
| - **Mind the sample**: ratings are half signal at about 100 targets. Shrinkage protects against small-sample flukes, but always glance at the target count on the card. | |
| - **Compare across conferences freely**: every rating is opponent-adjusted, so a Sun Belt 90 and an SEC 90 mean the same thing. That is the recruiting edge. | |
| """ | |
| METHODOLOGY = """ | |
| ### How Separation Rating works | |
| - We model **how open a receiver should have been** on every targeted pass using only context outside his | |
| control: coverage scheme, defensive front, alignment spot, the route called, formation, personnel, situation, | |
| defender cushions, and opponent quality (team level and defender level, leave-one-game-out). | |
| - His skill = **actual minus expected separation (RSOE)**, averaged with sample-size shrinkage, then scaled | |
| to a 40-99 **Separation Rating** (75 = average starter). | |
| - Screens and penalty-negated plays are excluded. Every expectation comes from cross-validation grouped by | |
| game, so no player is ever scored by a model that saw his games. | |
| ### Validation highlights | |
| - Predicted separation tracks reality across every zone of the field (red zone compression: actual 3.79 to 2.60, | |
| expected 3.81 to 2.66, bias under 0.06 yards everywhere). | |
| - Rankings survive model swaps (0.85-0.98 correlation across four model generations) and screen exclusion. | |
| - The same frozen model spec generalizes across seasons (2022 R-squared 0.283, 2023 R-squared 0.295), and a | |
| player's 2022 rating predicts his 2023 result at roughly 85% of the ceiling his sample size allows. | |
| - Face validity: the 2022 Biletnikoff winner ranks top-10; famous contested-catch specialists correctly rank | |
| low on separation and are flagged for the contested-catch lane instead. | |
| - The same framework produces defender ratings (separation allowed on-field): the 2022 leaders include | |
| Kool-Aid McKinstry, Brian Branch, and the Alabama secondary; the 2023 leaders are headlined by the | |
| Florida State secondary. | |
| ### Honest limits | |
| - One-play separation is noisy: a player's rating is half signal at roughly 100 targets, which is why shrinkage | |
| is built in. Ratings for low-target players lean toward average by design. | |
| - Nearest-defender, not assigned-defender, underlies the separation measurement (Hudl's definition). | |
| """ | |
| # Force dark mode so the Hudl palette renders as designed. | |
| FORCE_DARK = """ | |
| function refresh() { | |
| const url = new URL(window.location); | |
| if (url.searchParams.get('__theme') !== 'dark') { | |
| url.searchParams.set('__theme', 'dark'); | |
| window.location.href = url.href; | |
| } | |
| } | |
| """ | |
| CSS = f""" | |
| .gradio-container {{ background: {BG} !important; }} | |
| .gradio-container .prose h1, .gradio-container .prose h2 {{ color: #fff; }} | |
| footer {{ display: none !important; }} | |
| #hero-title h1 {{ font-weight: 800; letter-spacing: -0.01em; }} | |
| #hero-title h1 strong {{ color: {ORANGE}; }} | |
| /* wrap table cells at spaces only: names may stack, words never split */ | |
| .gradio-container table th, .gradio-container table td, | |
| .gradio-container table th span, .gradio-container table td span {{ | |
| word-break: normal !important; overflow-wrap: normal !important; | |
| }} | |
| """ | |
| # column_widths is not available on every gradio version this Space may pin | |
| import inspect | |
| _DF_HAS_WIDTHS = "column_widths" in inspect.signature(gr.Dataframe.__init__).parameters | |
| def _df_kwargs(widths): | |
| return {"column_widths": widths} if _DF_HAS_WIDTHS else {} | |
| LB_WIDTHS = ["17%", "8%", "6%", "8%", "13%", "10%", "10%", "10%", "18%"] | |
| RF_WIDTHS = ["14%", "7%", "5%", "7%", "10%", "8%", "7%", "7%", "7%", "11%", "9%", "8%"] | |
| THEME = gr.themes.Base( | |
| font=[gr.themes.GoogleFont("Barlow"), "system-ui", "sans-serif"], | |
| primary_hue="orange", | |
| ).set( | |
| body_background_fill_dark=BG, | |
| block_background_fill_dark=PANEL, | |
| background_fill_secondary_dark=PANEL, | |
| border_color_primary_dark=LINE, | |
| button_primary_background_fill_dark=ORANGE, | |
| ) | |
| with gr.Blocks(title="Separation Rating", theme=THEME, css=CSS, js=FORCE_DARK) as demo: | |
| with gr.Row(): | |
| with gr.Column(scale=4): | |
| gr.Markdown("# **Separation** Rating\n**Who really gets open?** Scheme- and opponent-adjusted receiver " | |
| "evaluation for D1 football, built on Hudl IQ data. UVA MSBA x Hudl.", | |
| elem_id="hero-title") | |
| with gr.Column(scale=1): | |
| season = gr.Radio(SEASONS, value=DEFAULT_SEASON, label="Season") | |
| with gr.Tab("Start Here"): | |
| gr.Markdown("## The metric, in plain football terms\n" | |
| "Click a question to expand it. Every example uses real numbers from the app.") | |
| for _q, _a in build_explainers(): | |
| with gr.Accordion(_q, open=False): | |
| gr.Markdown(_a) | |
| gr.Markdown(HOW_TO) | |
| with gr.Tab("Leaderboard"): | |
| spot = gr.HTML(spotlight_row(DEFAULT_SEASON)) | |
| with gr.Row(): | |
| search = gr.Textbox(label="Search player", placeholder="e.g., Hyatt") | |
| team = gr.Dropdown(_teams(_df(DEFAULT_SEASON)), value="All", label="Team") | |
| pos = gr.Dropdown(["All", "WR", "TE"], value="All", label="Position") | |
| tierdd = gr.Dropdown(["All"] + TIER_ORDER, value="All", label="Tier") | |
| min_t = gr.Slider(10, 80, value=15, step=5, label="Min targets") | |
| table = gr.Dataframe(value=leaderboard(DEFAULT_SEASON, "", "All", "All", "All", 15), | |
| interactive=False, wrap=True, **_df_kwargs(LB_WIDTHS)) | |
| for c in (search, team, pos, tierdd, min_t): | |
| c.change(leaderboard, [season, search, team, pos, tierdd, min_t], table) | |
| gr.Markdown("---\n## Player card") | |
| psel = gr.Dropdown(_players(_df(DEFAULT_SEASON)), label="Select player") | |
| card = gr.Markdown() | |
| psel.change(player_card, [season, psel], card) | |
| with gr.Tab("Recruiting Finder"): | |
| gr.Markdown("## Build your target board\n" | |
| "Pick what your room needs; we return every player whose **Separation Rating** " | |
| "fits the profile. Ratings are scheme- and opponent-adjusted, so a Sun Belt " | |
| "separator and an SEC separator are on the same scale — that is the recruiting edge.") | |
| with gr.Row(): | |
| r_pos = gr.Dropdown(["All", "WR", "TE"], value="All", label="Position") | |
| r_elig = gr.Dropdown(ELIG_CHOICES, value="Any", label="Eligibility") | |
| r_tier = gr.Dropdown(["All"] + TIER_ORDER, value="All", label="Tier") | |
| r_route = gr.Dropdown(_route_choices(_df(DEFAULT_SEASON)), value="Any", label="Primary route") | |
| r_rating = gr.Slider(40, 99, value=80, step=1, label="Min Separation Rating") | |
| r_targets = gr.Slider(10, 80, value=15, step=5, label="Min targets") | |
| with gr.Row(): | |
| r_contested = gr.Slider(0, 100, value=0, step=5, | |
| label="Min contested win % (0 = off, needs 5+ attempts)") | |
| r_rz = gr.Slider(40, 99, value=40, step=1, label="Min red zone rating (40 = off)") | |
| r_height = gr.Slider(66, 80, value=66, step=1, label="Min height, inches (66 = off)") | |
| r_weight = gr.Slider(150, 260, value=150, step=5, label="Min weight, lbs (150 = off)") | |
| r_btn = gr.Button("Find players", variant="primary") | |
| _s0, _c0, _t0 = recruit_search(DEFAULT_SEASON, "All", "Any", "All", 80, 15, "Any", 0, 40, 66, 150) | |
| r_summary = gr.Markdown(_s0) | |
| r_cards = gr.HTML(_c0) | |
| r_table = gr.Dataframe(value=_t0, interactive=False, wrap=True, **_df_kwargs(RF_WIDTHS)) | |
| r_inputs = [season, r_pos, r_elig, r_tier, r_rating, r_targets, r_route, | |
| r_contested, r_rz, r_height, r_weight] | |
| r_btn.click(recruit_search, r_inputs, [r_summary, r_cards, r_table]) | |
| gr.Markdown("*Low separation + high contested win rate is its own lane (the big-body " | |
| "archetype), not a red flag: set the contested slider and drop the rating " | |
| "floor to shop that profile. Eligibility is class-based from the season's rosters; " | |
| "early draft declares and actual transfer decisions are not tracked.*") | |
| with gr.Accordion("Methodology & validation", open=False): | |
| gr.Markdown(METHODOLOGY) | |
| def _on_season(s): | |
| d = _df(s) | |
| s0, c0, t0 = recruit_search(s, "All", "Any", "All", 80, 15, "Any", 0, 40, 66, 150) | |
| return (spotlight_row(s), | |
| gr.update(choices=_teams(d), value="All"), | |
| leaderboard(s, "", "All", "All", "All", 15), | |
| gr.update(choices=_players(d), value=None), | |
| "Select a player.", | |
| gr.update(choices=_route_choices(d), value="Any"), | |
| s0, c0, t0) | |
| season.change(_on_season, season, | |
| [spot, team, table, psel, card, r_route, r_summary, r_cards, r_table]) | |
| if __name__ == "__main__": | |
| demo.launch() | |