Spaces:
Sleeping
Sleeping
Upload 2 files
Browse files- app.py +101 -54
- app_players_2023.csv +0 -0
app.py
CHANGED
|
@@ -18,7 +18,6 @@ import numpy as np
|
|
| 18 |
import gradio as gr
|
| 19 |
|
| 20 |
HERE = os.path.dirname(os.path.abspath(__file__))
|
| 21 |
-
DATA_PATH = os.path.join(HERE, "data", "app_players.csv")
|
| 22 |
|
| 23 |
# Hudl IQ palette (sampled from hudl.com/products/football-iq)
|
| 24 |
ORANGE = "#FF6300" # Hudl brand orange
|
|
@@ -33,14 +32,30 @@ TIER_ORDER = ["Elite", "Star", "Above Avg", "Starter", "Rotation", "Below"]
|
|
| 33 |
TIER_COLORS = {"Elite": ORANGE, "Star": "#FF8A3D", "Above Avg": BLUE,
|
| 34 |
"Starter": "#9DAAB8", "Rotation": "#7A8A99", "Below": "#4E5D6C"}
|
| 35 |
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
|
| 41 |
|
| 42 |
-
def
|
| 43 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
|
| 45 |
|
| 46 |
def top_route(p):
|
|
@@ -54,6 +69,13 @@ def top_route(p):
|
|
| 54 |
return name.replace("_", "/")
|
| 55 |
|
| 56 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
# ── card + carousel rendering ──────────────────────────────────────────────
|
| 58 |
|
| 59 |
def _subtitle(p):
|
|
@@ -121,11 +143,12 @@ def _spotlight_cards(q, pos_label):
|
|
| 121 |
return cards
|
| 122 |
|
| 123 |
|
| 124 |
-
def spotlight_row():
|
| 125 |
"""Leaderboard hero row: WR and TE callouts side by side, six across."""
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
|
|
|
| 129 |
cards = []
|
| 130 |
for pos in ("WR", "TE"):
|
| 131 |
cards.extend(_spotlight_cards(q[q["position"] == pos], pos))
|
|
@@ -140,10 +163,11 @@ LB_LABELS = ["Player", "Team", "Pos", "Targets", "Separation Rating", "Tier",
|
|
| 140 |
"Percentile", "Sched Rank", "RSOE (shrunk)"]
|
| 141 |
|
| 142 |
|
| 143 |
-
def leaderboard(search, team, pos, tier, min_targets):
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
|
|
|
| 147 |
if search:
|
| 148 |
out = out[out["player_name"].str.contains(search, case=False, na=False)]
|
| 149 |
if team != "All":
|
|
@@ -163,19 +187,13 @@ DRAFT_CLASSES = ["SR", "RS-SR", "Graduate"]
|
|
| 163 |
ELIG_CHOICES = ["Any", "Returning (transfer candidates)", "Seniors & grads (draft)"]
|
| 164 |
|
| 165 |
|
| 166 |
-
def
|
| 167 |
-
if not len(df):
|
| 168 |
-
return ["Any"]
|
| 169 |
-
routes = sorted({top_route(p) for _, p in df.iterrows()} - {"n/a"})
|
| 170 |
-
return ["Any"] + routes
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
def recruit_search(pos, elig, tier, min_rating, min_targets, route, min_contested,
|
| 174 |
min_rz, min_height, min_weight):
|
| 175 |
"""The recruiting finder: filter the board by what a staff is shopping for."""
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
|
|
|
| 179 |
s["top_route"] = s.apply(top_route, axis=1)
|
| 180 |
if pos != "All":
|
| 181 |
s = s[s["position"] == pos]
|
|
@@ -210,18 +228,19 @@ def recruit_search(pos, elig, tier, min_rating, min_targets, route, min_conteste
|
|
| 210 |
view = s[["player_name", "team_short_name", "position", "class_type", "sep_rating",
|
| 211 |
"tier", "shrunk", "n", "catches", "top_route", "contested_win_rate",
|
| 212 |
"rz_rating"]].copy()
|
| 213 |
-
view.columns = ["Player", "
|
| 214 |
"RSOE", "Targets", "Catches", "Most frequent route",
|
| 215 |
"Contested win %", "RZ rating"]
|
| 216 |
return summary, _carousel(cards), view.reset_index(drop=True)
|
| 217 |
|
| 218 |
|
| 219 |
-
def player_card(name):
|
| 220 |
-
|
|
|
|
| 221 |
return "Select a player."
|
| 222 |
-
m =
|
| 223 |
if not len(m):
|
| 224 |
-
return f"No player named {name}."
|
| 225 |
p = m.iloc[0]
|
| 226 |
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"
|
| 227 |
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"
|
|
@@ -230,7 +249,7 @@ def player_card(name):
|
|
| 230 |
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 ""
|
| 231 |
cls = p.get("class_type") if pd.notna(p.get("class_type")) else "n/a"
|
| 232 |
card = f"""
|
| 233 |
-
## {p['player_name']} — {p['team_short_name']} ({p['position']})
|
| 234 |
# Separation Rating: {int(p['sep_rating'])} · {p['tier']}
|
| 235 |
| | |
|
| 236 |
|---|---|
|
|
@@ -251,12 +270,16 @@ def player_card(name):
|
|
| 251 |
return card
|
| 252 |
|
| 253 |
|
| 254 |
-
# ── Start Here explainers ────
|
|
|
|
|
|
|
|
|
|
| 255 |
|
| 256 |
def _named(name):
|
| 257 |
-
|
|
|
|
| 258 |
return None
|
| 259 |
-
m =
|
| 260 |
return m.iloc[0] if len(m) else None
|
| 261 |
|
| 262 |
|
|
@@ -266,7 +289,8 @@ def _fmt(p):
|
|
| 266 |
|
| 267 |
def build_explainers():
|
| 268 |
"""(question, answer) pairs for the Start Here accordions, using live numbers."""
|
| 269 |
-
|
|
|
|
| 270 |
hyatt, mayer, tinsley = _named("Jalin Hyatt"), _named("Michael Mayer"), _named("Mitchell Tinsley")
|
| 271 |
|
| 272 |
q1 = ("What is RSOE?",
|
|
@@ -290,10 +314,10 @@ def build_explainers():
|
|
| 290 |
"target count next to the number.\n\n"
|
| 291 |
+ (f"Face validity check: {_fmt(hyatt)}, the 2022 Biletnikoff winner, rates "
|
| 292 |
f"**{int(hyatt['sep_rating'])}**, and his coverage schedule ranked "
|
| 293 |
-
f"{int(hyatt['sched_rank'])} of {len(
|
| 294 |
f"not a soft-schedule artifact." if hyatt is not None else ""))
|
| 295 |
|
| 296 |
-
med_n = int(
|
| 297 |
q2b = ("How do we know the rating is skill and not luck?",
|
| 298 |
"This was the central test in developing the metric: prove that average RSOE measures the "
|
| 299 |
"player, not his bounces.\n\n"
|
|
@@ -314,6 +338,9 @@ def build_explainers():
|
|
| 314 |
"gets pulled hard; a 110-target player barely moves. So the rating you see already carries the "
|
| 315 |
"appropriate skepticism, and a 90 on 25 targets means the raw number was spectacular enough to "
|
| 316 |
"survive aggressive shrinkage.\n\n"
|
|
|
|
|
|
|
|
|
|
| 317 |
+ (f"**The honest limit**: the median player on this board has {med_n} targets, well short of "
|
| 318 |
f"100, so most single-season ratings lean heavily on shrinkage. That is the reality of "
|
| 319 |
f"one-season data, not a flaw of the metric, and it is the case for adding more seasons. "
|
|
@@ -364,9 +391,10 @@ def build_explainers():
|
|
| 364 |
|
| 365 |
HOW_TO = """
|
| 366 |
### How to use the app
|
|
|
|
| 367 |
- **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.
|
| 368 |
- **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.
|
| 369 |
-
- **Recruiting Finder**: set the profile your room needs (position, eligibility, tier, primary route, rating floor, contested, red zone, size) and hit **Find players**.
|
| 370 |
- **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.
|
| 371 |
- **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.
|
| 372 |
"""
|
|
@@ -386,10 +414,13 @@ METHODOLOGY = """
|
|
| 386 |
- Predicted separation tracks reality across every zone of the field (red zone compression: actual 3.79 to 2.60,
|
| 387 |
expected 3.81 to 2.66, bias under 0.06 yards everywhere).
|
| 388 |
- Rankings survive model swaps (0.85-0.98 correlation across four model generations) and screen exclusion.
|
|
|
|
|
|
|
| 389 |
- Face validity: the 2022 Biletnikoff winner ranks top-10; famous contested-catch specialists correctly rank
|
| 390 |
low on separation and are flagged for the contested-catch lane instead.
|
| 391 |
- The same framework produces defender ratings (separation allowed on-field): the 2022 leaders include
|
| 392 |
-
Kool-Aid McKinstry, Brian Branch, and the Alabama secondary
|
|
|
|
| 393 |
|
| 394 |
### Honest limits
|
| 395 |
- One-play separation is noisy: a player's rating is half signal at roughly 100 targets, which is why shrinkage
|
|
@@ -428,9 +459,11 @@ THEME = gr.themes.Base(
|
|
| 428 |
)
|
| 429 |
|
| 430 |
with gr.Blocks(title="Separation Rating", theme=THEME, css=CSS, js=FORCE_DARK) as demo:
|
| 431 |
-
gr.
|
| 432 |
-
|
| 433 |
-
|
|
|
|
|
|
|
| 434 |
|
| 435 |
with gr.Tab("Start Here"):
|
| 436 |
gr.Markdown("## The metric, in plain football terms\n"
|
|
@@ -441,21 +474,21 @@ with gr.Blocks(title="Separation Rating", theme=THEME, css=CSS, js=FORCE_DARK) a
|
|
| 441 |
gr.Markdown(HOW_TO)
|
| 442 |
|
| 443 |
with gr.Tab("Leaderboard"):
|
| 444 |
-
gr.HTML(spotlight_row())
|
| 445 |
with gr.Row():
|
| 446 |
search = gr.Textbox(label="Search player", placeholder="e.g., Hyatt")
|
| 447 |
-
team = gr.Dropdown(_teams(), value="All", label="Team")
|
| 448 |
pos = gr.Dropdown(["All", "WR", "TE"], value="All", label="Position")
|
| 449 |
tierdd = gr.Dropdown(["All"] + TIER_ORDER, value="All", label="Tier")
|
| 450 |
min_t = gr.Slider(10, 80, value=15, step=5, label="Min targets")
|
| 451 |
-
table = gr.Dataframe(value=leaderboard("", "All", "All", "All", 15),
|
|
|
|
| 452 |
for c in (search, team, pos, tierdd, min_t):
|
| 453 |
-
c.change(leaderboard, [search, team, pos, tierdd, min_t], table)
|
| 454 |
gr.Markdown("---\n## Player card")
|
| 455 |
-
psel = gr.Dropdown(
|
| 456 |
-
label="Select player")
|
| 457 |
card = gr.Markdown()
|
| 458 |
-
psel.change(player_card, psel, card)
|
| 459 |
|
| 460 |
with gr.Tab("Recruiting Finder"):
|
| 461 |
gr.Markdown("## Build your target board\n"
|
|
@@ -466,7 +499,7 @@ with gr.Blocks(title="Separation Rating", theme=THEME, css=CSS, js=FORCE_DARK) a
|
|
| 466 |
r_pos = gr.Dropdown(["All", "WR", "TE"], value="All", label="Position")
|
| 467 |
r_elig = gr.Dropdown(ELIG_CHOICES, value="Any", label="Eligibility")
|
| 468 |
r_tier = gr.Dropdown(["All"] + TIER_ORDER, value="All", label="Tier")
|
| 469 |
-
r_route = gr.Dropdown(_route_choices(), value="Any", label="Primary route")
|
| 470 |
r_rating = gr.Slider(40, 99, value=80, step=1, label="Min Separation Rating")
|
| 471 |
r_targets = gr.Slider(10, 80, value=15, step=5, label="Min targets")
|
| 472 |
with gr.Row():
|
|
@@ -476,20 +509,34 @@ with gr.Blocks(title="Separation Rating", theme=THEME, css=CSS, js=FORCE_DARK) a
|
|
| 476 |
r_height = gr.Slider(66, 80, value=66, step=1, label="Min height, inches (66 = off)")
|
| 477 |
r_weight = gr.Slider(150, 260, value=150, step=5, label="Min weight, lbs (150 = off)")
|
| 478 |
r_btn = gr.Button("Find players", variant="primary")
|
| 479 |
-
_s0, _c0, _t0 = recruit_search("All", "Any", "All", 80, 15, "Any", 0, 40, 66, 150)
|
| 480 |
r_summary = gr.Markdown(_s0)
|
| 481 |
r_cards = gr.HTML(_c0)
|
| 482 |
r_table = gr.Dataframe(value=_t0, interactive=False, wrap=True)
|
| 483 |
-
r_inputs = [r_pos, r_elig, r_tier, r_rating, r_targets, r_route,
|
| 484 |
r_contested, r_rz, r_height, r_weight]
|
| 485 |
r_btn.click(recruit_search, r_inputs, [r_summary, r_cards, r_table])
|
| 486 |
gr.Markdown("*Low separation + high contested win rate is its own lane (the big-body "
|
| 487 |
"archetype), not a red flag: set the contested slider and drop the rating "
|
| 488 |
-
"floor to shop that profile. Eligibility is class-based from
|
| 489 |
"early draft declares and actual transfer decisions are not tracked.*")
|
| 490 |
|
| 491 |
with gr.Accordion("Methodology & validation", open=False):
|
| 492 |
gr.Markdown(METHODOLOGY)
|
| 493 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 494 |
if __name__ == "__main__":
|
| 495 |
demo.launch()
|
|
|
|
| 18 |
import gradio as gr
|
| 19 |
|
| 20 |
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
|
|
| 21 |
|
| 22 |
# Hudl IQ palette (sampled from hudl.com/products/football-iq)
|
| 23 |
ORANGE = "#FF6300" # Hudl brand orange
|
|
|
|
| 32 |
TIER_COLORS = {"Elite": ORANGE, "Star": "#FF8A3D", "Above Avg": BLUE,
|
| 33 |
"Starter": "#9DAAB8", "Rotation": "#7A8A99", "Below": "#4E5D6C"}
|
| 34 |
|
| 35 |
+
# ── season data: one flat file per season in data/ ─────────────────────────
|
| 36 |
+
SEASON_FILES = {"2022": "app_players.csv", "2023": "app_players_2023.csv"}
|
| 37 |
+
DFS = {}
|
| 38 |
+
for _season, _fname in SEASON_FILES.items():
|
| 39 |
+
_p = os.path.join(HERE, "data", _fname)
|
| 40 |
+
if os.path.exists(_p):
|
| 41 |
+
_d = pd.read_csv(_p)
|
| 42 |
+
# decode-file placeholder weights (e.g., 0 lbs) display as n/a instead
|
| 43 |
+
_d.loc[_d["weight_pounds"].notna() & (_d["weight_pounds"] < 100), "weight_pounds"] = np.nan
|
| 44 |
+
DFS[_season] = _d
|
| 45 |
+
SEASONS = list(DFS.keys()) or ["2022"]
|
| 46 |
+
DEFAULT_SEASON = SEASONS[-1] # latest season present
|
| 47 |
|
| 48 |
|
| 49 |
+
def _df(season):
|
| 50 |
+
return DFS.get(season, pd.DataFrame())
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def _teams(d):
|
| 54 |
+
return ["All"] + sorted(d["team_short_name"].dropna().unique().tolist()) if len(d) else ["All"]
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def _players(d):
|
| 58 |
+
return sorted(d["player_name"].dropna().unique().tolist()) if len(d) else []
|
| 59 |
|
| 60 |
|
| 61 |
def top_route(p):
|
|
|
|
| 69 |
return name.replace("_", "/")
|
| 70 |
|
| 71 |
|
| 72 |
+
def _route_choices(d):
|
| 73 |
+
if not len(d):
|
| 74 |
+
return ["Any"]
|
| 75 |
+
routes = sorted({top_route(p) for _, p in d.iterrows()} - {"n/a"})
|
| 76 |
+
return ["Any"] + routes
|
| 77 |
+
|
| 78 |
+
|
| 79 |
# ── card + carousel rendering ──────────────────────────────────────────────
|
| 80 |
|
| 81 |
def _subtitle(p):
|
|
|
|
| 143 |
return cards
|
| 144 |
|
| 145 |
|
| 146 |
+
def spotlight_row(season):
|
| 147 |
"""Leaderboard hero row: WR and TE callouts side by side, six across."""
|
| 148 |
+
d = _df(season)
|
| 149 |
+
if not len(d):
|
| 150 |
+
return f"<p>data/{SEASON_FILES.get(season, '?')} not found</p>"
|
| 151 |
+
q = d[d["n"] >= 15]
|
| 152 |
cards = []
|
| 153 |
for pos in ("WR", "TE"):
|
| 154 |
cards.extend(_spotlight_cards(q[q["position"] == pos], pos))
|
|
|
|
| 163 |
"Percentile", "Sched Rank", "RSOE (shrunk)"]
|
| 164 |
|
| 165 |
|
| 166 |
+
def leaderboard(season, search, team, pos, tier, min_targets):
|
| 167 |
+
d = _df(season)
|
| 168 |
+
if not len(d):
|
| 169 |
+
return pd.DataFrame({"error": [f"data/{SEASON_FILES.get(season, '?')} not found — run the prep notebook and add it"]})
|
| 170 |
+
out = d.copy()
|
| 171 |
if search:
|
| 172 |
out = out[out["player_name"].str.contains(search, case=False, na=False)]
|
| 173 |
if team != "All":
|
|
|
|
| 187 |
ELIG_CHOICES = ["Any", "Returning (transfer candidates)", "Seniors & grads (draft)"]
|
| 188 |
|
| 189 |
|
| 190 |
+
def recruit_search(season, pos, elig, tier, min_rating, min_targets, route, min_contested,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 191 |
min_rz, min_height, min_weight):
|
| 192 |
"""The recruiting finder: filter the board by what a staff is shopping for."""
|
| 193 |
+
d = _df(season)
|
| 194 |
+
if not len(d):
|
| 195 |
+
return f"data/{SEASON_FILES.get(season, '?')} not found", "", pd.DataFrame()
|
| 196 |
+
s = d.copy()
|
| 197 |
s["top_route"] = s.apply(top_route, axis=1)
|
| 198 |
if pos != "All":
|
| 199 |
s = s[s["position"] == pos]
|
|
|
|
| 228 |
view = s[["player_name", "team_short_name", "position", "class_type", "sep_rating",
|
| 229 |
"tier", "shrunk", "n", "catches", "top_route", "contested_win_rate",
|
| 230 |
"rz_rating"]].copy()
|
| 231 |
+
view.columns = ["Player", f"{season} Team", "Pos", "Class", "Separation Rating", "Tier",
|
| 232 |
"RSOE", "Targets", "Catches", "Most frequent route",
|
| 233 |
"Contested win %", "RZ rating"]
|
| 234 |
return summary, _carousel(cards), view.reset_index(drop=True)
|
| 235 |
|
| 236 |
|
| 237 |
+
def player_card(season, name):
|
| 238 |
+
d = _df(season)
|
| 239 |
+
if not len(d) or not name:
|
| 240 |
return "Select a player."
|
| 241 |
+
m = d[d["player_name"] == name]
|
| 242 |
if not len(m):
|
| 243 |
+
return f"No player named {name} in {season}."
|
| 244 |
p = m.iloc[0]
|
| 245 |
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"
|
| 246 |
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"
|
|
|
|
| 249 |
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 ""
|
| 250 |
cls = p.get("class_type") if pd.notna(p.get("class_type")) else "n/a"
|
| 251 |
card = f"""
|
| 252 |
+
## {p['player_name']} — {p['team_short_name']} ({p['position']}) · {season}
|
| 253 |
# Separation Rating: {int(p['sep_rating'])} · {p['tier']}
|
| 254 |
| | |
|
| 255 |
|---|---|
|
|
|
|
| 270 |
return card
|
| 271 |
|
| 272 |
|
| 273 |
+
# ── Start Here explainers (pinned to 2022, the season the numbers cite) ────
|
| 274 |
+
|
| 275 |
+
EXPLAIN_SEASON = "2022" if "2022" in DFS else DEFAULT_SEASON
|
| 276 |
+
|
| 277 |
|
| 278 |
def _named(name):
|
| 279 |
+
d = _df(EXPLAIN_SEASON)
|
| 280 |
+
if not len(d):
|
| 281 |
return None
|
| 282 |
+
m = d[d["player_name"] == name]
|
| 283 |
return m.iloc[0] if len(m) else None
|
| 284 |
|
| 285 |
|
|
|
|
| 289 |
|
| 290 |
def build_explainers():
|
| 291 |
"""(question, answer) pairs for the Start Here accordions, using live numbers."""
|
| 292 |
+
d = _df(EXPLAIN_SEASON)
|
| 293 |
+
top = d[d["n"] >= 15].sort_values(["sep_rating", "shrunk"], ascending=False).iloc[0] if len(d) else None
|
| 294 |
hyatt, mayer, tinsley = _named("Jalin Hyatt"), _named("Michael Mayer"), _named("Mitchell Tinsley")
|
| 295 |
|
| 296 |
q1 = ("What is RSOE?",
|
|
|
|
| 314 |
"target count next to the number.\n\n"
|
| 315 |
+ (f"Face validity check: {_fmt(hyatt)}, the 2022 Biletnikoff winner, rates "
|
| 316 |
f"**{int(hyatt['sep_rating'])}**, and his coverage schedule ranked "
|
| 317 |
+
f"{int(hyatt['sched_rank'])} of {len(d)} in difficulty (1 = toughest), so the number is "
|
| 318 |
f"not a soft-schedule artifact." if hyatt is not None else ""))
|
| 319 |
|
| 320 |
+
med_n = int(d["n"].median()) if len(d) else 0
|
| 321 |
q2b = ("How do we know the rating is skill and not luck?",
|
| 322 |
"This was the central test in developing the metric: prove that average RSOE measures the "
|
| 323 |
"player, not his bounces.\n\n"
|
|
|
|
| 338 |
"gets pulled hard; a 110-target player barely moves. So the rating you see already carries the "
|
| 339 |
"appropriate skepticism, and a 90 on 25 targets means the raw number was spectacular enough to "
|
| 340 |
"survive aggressive shrinkage.\n\n"
|
| 341 |
+
"**Season two confirmed it.** With 2023 in hand, a player's 2022 rating predicts his 2023 "
|
| 342 |
+
"result at roughly 85% of the theoretical maximum his sample size allows. The signal is real "
|
| 343 |
+
"and it persists.\n\n"
|
| 344 |
+ (f"**The honest limit**: the median player on this board has {med_n} targets, well short of "
|
| 345 |
f"100, so most single-season ratings lean heavily on shrinkage. That is the reality of "
|
| 346 |
f"one-season data, not a flaw of the metric, and it is the case for adding more seasons. "
|
|
|
|
| 391 |
|
| 392 |
HOW_TO = """
|
| 393 |
### How to use the app
|
| 394 |
+
- **Season**: the selector at the top switches every tab between the 2022 and 2023 boards.
|
| 395 |
- **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.
|
| 396 |
- **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.
|
| 397 |
+
- **Recruiting Finder**: set the profile your room needs (position, eligibility, tier, primary route, rating floor, contested, red zone, size) and hit **Find players**.
|
| 398 |
- **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.
|
| 399 |
- **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.
|
| 400 |
"""
|
|
|
|
| 414 |
- Predicted separation tracks reality across every zone of the field (red zone compression: actual 3.79 to 2.60,
|
| 415 |
expected 3.81 to 2.66, bias under 0.06 yards everywhere).
|
| 416 |
- Rankings survive model swaps (0.85-0.98 correlation across four model generations) and screen exclusion.
|
| 417 |
+
- The same frozen model spec generalizes across seasons (2022 R-squared 0.283, 2023 R-squared 0.295), and a
|
| 418 |
+
player's 2022 rating predicts his 2023 result at roughly 85% of the ceiling his sample size allows.
|
| 419 |
- Face validity: the 2022 Biletnikoff winner ranks top-10; famous contested-catch specialists correctly rank
|
| 420 |
low on separation and are flagged for the contested-catch lane instead.
|
| 421 |
- The same framework produces defender ratings (separation allowed on-field): the 2022 leaders include
|
| 422 |
+
Kool-Aid McKinstry, Brian Branch, and the Alabama secondary; the 2023 leaders are headlined by the
|
| 423 |
+
Florida State secondary.
|
| 424 |
|
| 425 |
### Honest limits
|
| 426 |
- One-play separation is noisy: a player's rating is half signal at roughly 100 targets, which is why shrinkage
|
|
|
|
| 459 |
)
|
| 460 |
|
| 461 |
with gr.Blocks(title="Separation Rating", theme=THEME, css=CSS, js=FORCE_DARK) as demo:
|
| 462 |
+
with gr.Row():
|
| 463 |
+
gr.Markdown("# **Separation** Rating\n**Who really gets open?** Scheme- and opponent-adjusted receiver "
|
| 464 |
+
"evaluation for D1 football, built on Hudl IQ data. UVA MSBA x Hudl.",
|
| 465 |
+
elem_id="hero-title", scale=4)
|
| 466 |
+
season = gr.Radio(SEASONS, value=DEFAULT_SEASON, label="Season", scale=1)
|
| 467 |
|
| 468 |
with gr.Tab("Start Here"):
|
| 469 |
gr.Markdown("## The metric, in plain football terms\n"
|
|
|
|
| 474 |
gr.Markdown(HOW_TO)
|
| 475 |
|
| 476 |
with gr.Tab("Leaderboard"):
|
| 477 |
+
spot = gr.HTML(spotlight_row(DEFAULT_SEASON))
|
| 478 |
with gr.Row():
|
| 479 |
search = gr.Textbox(label="Search player", placeholder="e.g., Hyatt")
|
| 480 |
+
team = gr.Dropdown(_teams(_df(DEFAULT_SEASON)), value="All", label="Team")
|
| 481 |
pos = gr.Dropdown(["All", "WR", "TE"], value="All", label="Position")
|
| 482 |
tierdd = gr.Dropdown(["All"] + TIER_ORDER, value="All", label="Tier")
|
| 483 |
min_t = gr.Slider(10, 80, value=15, step=5, label="Min targets")
|
| 484 |
+
table = gr.Dataframe(value=leaderboard(DEFAULT_SEASON, "", "All", "All", "All", 15),
|
| 485 |
+
interactive=False, wrap=True)
|
| 486 |
for c in (search, team, pos, tierdd, min_t):
|
| 487 |
+
c.change(leaderboard, [season, search, team, pos, tierdd, min_t], table)
|
| 488 |
gr.Markdown("---\n## Player card")
|
| 489 |
+
psel = gr.Dropdown(_players(_df(DEFAULT_SEASON)), label="Select player")
|
|
|
|
| 490 |
card = gr.Markdown()
|
| 491 |
+
psel.change(player_card, [season, psel], card)
|
| 492 |
|
| 493 |
with gr.Tab("Recruiting Finder"):
|
| 494 |
gr.Markdown("## Build your target board\n"
|
|
|
|
| 499 |
r_pos = gr.Dropdown(["All", "WR", "TE"], value="All", label="Position")
|
| 500 |
r_elig = gr.Dropdown(ELIG_CHOICES, value="Any", label="Eligibility")
|
| 501 |
r_tier = gr.Dropdown(["All"] + TIER_ORDER, value="All", label="Tier")
|
| 502 |
+
r_route = gr.Dropdown(_route_choices(_df(DEFAULT_SEASON)), value="Any", label="Primary route")
|
| 503 |
r_rating = gr.Slider(40, 99, value=80, step=1, label="Min Separation Rating")
|
| 504 |
r_targets = gr.Slider(10, 80, value=15, step=5, label="Min targets")
|
| 505 |
with gr.Row():
|
|
|
|
| 509 |
r_height = gr.Slider(66, 80, value=66, step=1, label="Min height, inches (66 = off)")
|
| 510 |
r_weight = gr.Slider(150, 260, value=150, step=5, label="Min weight, lbs (150 = off)")
|
| 511 |
r_btn = gr.Button("Find players", variant="primary")
|
| 512 |
+
_s0, _c0, _t0 = recruit_search(DEFAULT_SEASON, "All", "Any", "All", 80, 15, "Any", 0, 40, 66, 150)
|
| 513 |
r_summary = gr.Markdown(_s0)
|
| 514 |
r_cards = gr.HTML(_c0)
|
| 515 |
r_table = gr.Dataframe(value=_t0, interactive=False, wrap=True)
|
| 516 |
+
r_inputs = [season, r_pos, r_elig, r_tier, r_rating, r_targets, r_route,
|
| 517 |
r_contested, r_rz, r_height, r_weight]
|
| 518 |
r_btn.click(recruit_search, r_inputs, [r_summary, r_cards, r_table])
|
| 519 |
gr.Markdown("*Low separation + high contested win rate is its own lane (the big-body "
|
| 520 |
"archetype), not a red flag: set the contested slider and drop the rating "
|
| 521 |
+
"floor to shop that profile. Eligibility is class-based from the season's rosters; "
|
| 522 |
"early draft declares and actual transfer decisions are not tracked.*")
|
| 523 |
|
| 524 |
with gr.Accordion("Methodology & validation", open=False):
|
| 525 |
gr.Markdown(METHODOLOGY)
|
| 526 |
|
| 527 |
+
def _on_season(s):
|
| 528 |
+
d = _df(s)
|
| 529 |
+
s0, c0, t0 = recruit_search(s, "All", "Any", "All", 80, 15, "Any", 0, 40, 66, 150)
|
| 530 |
+
return (spotlight_row(s),
|
| 531 |
+
gr.update(choices=_teams(d), value="All"),
|
| 532 |
+
leaderboard(s, "", "All", "All", "All", 15),
|
| 533 |
+
gr.update(choices=_players(d), value=None),
|
| 534 |
+
"Select a player.",
|
| 535 |
+
gr.update(choices=_route_choices(d), value="Any"),
|
| 536 |
+
s0, c0, t0)
|
| 537 |
+
|
| 538 |
+
season.change(_on_season, season,
|
| 539 |
+
[spot, team, table, psel, card, r_route, r_summary, r_cards, r_table])
|
| 540 |
+
|
| 541 |
if __name__ == "__main__":
|
| 542 |
demo.launch()
|
app_players_2023.csv
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|