MorelandMSBA's picture
Upload app.py
43b0545 verified
Raw History Blame Contribute Delete
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
@spaces.GPU
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 " &middot; ".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 &middot; {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 &middot; {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 &middot; {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()