Spaces:
Sleeping
Sleeping
Download app.py from stevafernandes/Seth-Austin: direct link, hf CLI and curl.
- Browser
- Download file 21.6 kB
-
https://huggingface.co/spaces/stevafernandes/Seth-Austin/resolve/main/app.py
- Command line
-
hf download hf://spaces/stevafernandes/Seth-Austin/app.py
-
curl -L -o app.py https://huggingface.co/spaces/stevafernandes/Seth-Austin/resolve/main/app.py
21.6 kB
| """Player pay versus performance. | |
| Gradio application for Hugging Face Spaces. A TabICLv2 regressor fitted on the | |
| market-contract players (other than the one selected) estimates the salary | |
| distribution implied by a player's statistics, descriptive statistics place the | |
| player among the sample, and Gemini writes a narrative restricted to those numbers. | |
| """ | |
| from __future__ import annotations | |
| import math | |
| import os | |
| import gradio as gr | |
| import matplotlib | |
| matplotlib.use("Agg") | |
| import matplotlib.pyplot as plt # noqa: E402 | |
| import numpy as np # noqa: E402 | |
| import pandas as pd # noqa: E402 | |
| import analysis as A # noqa: E402 | |
| import narrative as N # noqa: E402 | |
| from baselines import load_cached_baselines # noqa: E402 | |
| DATA = A.load_players() | |
| PLAYER_NAMES = DATA[A.NAME_COLUMN].tolist() | |
| DEFAULT_PLAYER = PLAYER_NAMES[0] | |
| VALIDATION = A.ValidationRunner(DATA) | |
| VALIDATION.start() | |
| OVERVIEW = A.dataset_overview(DATA) | |
| BASELINES = load_cached_baselines(DATA) | |
| TITLE = "Player Pay and Performance" | |
| INTRO = ( | |
| f"Is a player's salary justified by his statistics? The file holds {len(DATA)} Major League hitters " | |
| "with 16 performance columns and the average annual value (AAV) of each contract. For the selected player " | |
| "the app fits a TabICLv2 regressor on the other players to estimate the salary his statistics imply, " | |
| "compares every statistic with the rest of the sample, finds the most similar players, and asks Gemini " | |
| "to write a narrative that uses only those computed figures. The model is fitted on the market-contract " | |
| f"players other than the selected one ({OVERVIEW['n_market_contracts']} players have AAV above " | |
| f"${A.MARKET_CONTRACT_THRESHOLD:,}); the {OVERVIEW['n_minimum_contracts']} players near the league minimum " | |
| "are paid by service-time rules rather than by performance." | |
| ) | |
| CSS = """ | |
| :root { color-scheme: light; } | |
| html, body, body.dark, gradio-app, .gradio-container, .gradio-container.dark { | |
| --body-background-fill: #ffffff !important; | |
| --body-text-color: #000000 !important; | |
| --body-text-color-subdued: #000000 !important; | |
| --background-fill-primary: #ffffff !important; | |
| --background-fill-secondary: #ffffff !important; | |
| --block-background-fill: #ffffff !important; | |
| --block-label-background-fill: #ffffff !important; | |
| --block-label-text-color: #000000 !important; | |
| --block-title-text-color: #000000 !important; | |
| --block-info-text-color: #000000 !important; | |
| --input-background-fill: #ffffff !important; | |
| --table-even-background-fill: #ffffff !important; | |
| --table-odd-background-fill: #ffffff !important; | |
| --table-text-color: #000000 !important; | |
| --button-primary-background-fill: #ffffff !important; | |
| --button-primary-text-color: #000000 !important; | |
| --button-secondary-background-fill: #ffffff !important; | |
| --button-secondary-text-color: #000000 !important; | |
| --border-color-primary: #cccccc !important; | |
| --shadow-drop: none !important; | |
| --shadow-drop-lg: none !important; | |
| } | |
| html, body, gradio-app, .gradio-container { | |
| background: #ffffff !important; | |
| color: #000000 !important; | |
| } | |
| .gradio-container *:not(svg):not(svg *):not(img):not(canvas):not(video) { | |
| background-color: #ffffff !important; | |
| color: #000000 !important; | |
| } | |
| .gradio-container { max-width: 1200px !important; margin: 0 auto !important; } | |
| .gradio-container button.primary, .gradio-container button.secondary, .gradio-container button.stop { | |
| border: 1px solid #000000 !important; | |
| box-shadow: none !important; | |
| font-weight: 600; | |
| } | |
| .gradio-container .table-wrap, .gradio-container .table-wrap *, .gradio-container .header-row * { font-family: var(--font) !important; } | |
| .gradio-container table, .gradio-container th, .gradio-container td { border-color: #cccccc !important; } | |
| .prose h1 { font-size: 1.8rem; margin-bottom: 0.3rem; } | |
| .prose h3 { font-size: 1.1rem; margin-top: 0.4rem; } | |
| .prose table { border-collapse: collapse; } | |
| .prose th, .prose td { border: 1px solid #cccccc !important; padding: 4px 10px; } | |
| footer, .built-with, a[href*="gradio.app"], .gradio-container .footer { display: none !important; } | |
| """ | |
| FORCE_LIGHT_HEAD = """ | |
| <meta name="color-scheme" content="light"> | |
| <script> | |
| (() => { | |
| const strip = () => { | |
| for (const el of [document.documentElement, document.body]) { | |
| if (el && el.classList.contains('dark')) el.classList.remove('dark'); | |
| } | |
| }; | |
| const watch = () => { | |
| strip(); | |
| const observer = new MutationObserver(strip); | |
| observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] }); | |
| if (document.body) observer.observe(document.body, { attributes: true, attributeFilter: ['class'] }); | |
| }; | |
| if (document.body) watch(); else document.addEventListener('DOMContentLoaded', watch); | |
| })(); | |
| </script> | |
| """ | |
| def baselines_markdown() -> str: | |
| """Leave-one-out comparison table from data/baselines.json, if it matches the loaded data.""" | |
| if not BASELINES: | |
| return ("No cached model comparison matches this dataset. Run `python baselines.py` to regenerate " | |
| "`data/baselines.json`.") | |
| lines = [ | |
| "The comparison below was computed by `baselines.py` on this file (leave-one-out, target log(AAV), " | |
| f"TabICLv2 with {BASELINES['n_estimators']} ensemble members).", | |
| "", | |
| "| Method | Rows | n | R² on log salary | Spearman rank correlation | Mean absolute error (log) |", | |
| "|---|---|---|---|---|---|", | |
| ] | |
| for row in BASELINES["regression"]: | |
| lines.append(f"| {row['method']} | {row['subset']} | {row['n']} | {row['r2_log_salary']} | " | |
| f"{row['spearman_rho']} | {row['mean_abs_error_log']} |") | |
| lines += [ | |
| "", | |
| "Predicting from the statistics whether a player is on a market contract or near the minimum " | |
| f"(majority-class accuracy {BASELINES['majority_class_accuracy']}):", | |
| "", | |
| "| Classifier | Leave-one-out AUC | Accuracy |", | |
| "|---|---|---|", | |
| ] | |
| for row in BASELINES["classification"]: | |
| lines.append(f"| {row['method']} | {row['auc']} | {row['accuracy']} |") | |
| return "\n".join(lines) | |
| METHOD_NOTES = f""" | |
| ### Data | |
| {OVERVIEW['n_players']} hitters from a FanGraphs leaderboard export with the columns {", ".join(A.STAT_COLUMNS)} | |
| and AAV (average annual value of the current contract, in US dollars). Plate appearances range from | |
| {OVERVIEW['min_pa']} to {OVERVIEW['max_pa']}, so the statistics cover part of a season. | |
| No column records age, service time, contract year, or position. | |
| ### Descriptive comparison | |
| Percentile is the share of the other {len(DATA) - 1} players the selected player beats on that statistic | |
| (ties count half). For K% a lower value is better, so its percentile and z-score are inverted. | |
| The z-score is the distance from the mean of the other players in standard deviations. | |
| ### TabICLv2 salary model | |
| TabICLv2 (Qu, Holzmüller, Varoquaux, Le Morvan; ICML 2026; arXiv 2602.11139) is a pretrained tabular | |
| foundation model that predicts by in-context learning without gradient training on the target data. | |
| The app uses the `tabicl` package, class `TabICLRegressor`, checkpoint `tabicl-regressor-v2-20260212.ckpt`, | |
| {A._model_n_estimators()} ensemble members, on CPU. The model is fitted on the players with AAV above | |
| ${A.MARKET_CONTRACT_THRESHOLD:,} other than the selected player, with the 16 statistics as inputs and log(AAV) | |
| as the target, and returns the predicted salary distribution for the selected player's statistics. The | |
| reported percentile is where the actual salary sits in that predicted distribution (1 to 99). For a player | |
| near the league minimum the prediction shows what market contracts pay for comparable statistics. | |
| The minimum-salary players are excluded from fitting because their pay is fixed by service-time rules. | |
| {baselines_markdown()} | |
| Model validity is measured by leave-one-out over the market-contract players: each one is predicted from the | |
| others, then the predicted medians are compared with the actual salaries. The reported R² is computed on log | |
| salary, the Spearman correlation measures rank agreement, and the coverage figures give the share of players | |
| whose actual salary fell inside the predicted 10-90 and 25-75 intervals (ideal values are 80% and 50%). | |
| TabICLv2 was pretrained on data sets of 300 to 48,000 rows; this file is smaller than that range. | |
| ### Comparable players | |
| The five nearest market-contract players (AAV above ${A.MARKET_CONTRACT_THRESHOLD:,}) by Euclidean distance over | |
| the 16 standardized statistics (K% sign reversed). Minimum-salary players are left out for the same reason they | |
| are left out of the model. | |
| ### Cost per WAR | |
| AAV divided by WAR, compared with the same ratio for players earning more than | |
| ${A.MARKET_CONTRACT_THRESHOLD:,} with positive WAR. A lower ratio means less money per win. | |
| ### Reading of the evidence | |
| The headline follows the model: the actual salary is below, inside, or above the 25th to 75th percentile of | |
| the predicted distribution. Below or inside means performance supports the salary; above means it does not. | |
| The rank comparison (WAR percentile minus salary percentile, with 20 points as the threshold) turns the | |
| headline into a mixed reading when it points the other way. The comparables reading (salary under 0.8, | |
| between 0.8 and 1.25, or over 1.25 times the median salary of the five comparables) is reported as context. | |
| ### Gemini narrative | |
| The computed figures are sent to Gemini as JSON with instructions to use nothing else. Every number in the | |
| returned text is then matched against the computed figures, allowing rounding to the decimals written in the | |
| narrative, dollar amounts expressed in millions, fractions expressed as percentages, and the quantile labels | |
| 10, 25, 50, 75 and 90. Anything that cannot be matched is listed under the narrative so it can be disregarded. | |
| The check catches invented dollar amounts, rates and rank figures; a small invented integer can pass when it | |
| coincides with some computed value. | |
| ### Limitations | |
| Salary in this league depends heavily on contract status (players in their first years earn near the league | |
| minimum regardless of production) and on past seasons, none of which is in the file. In this file the Spearman | |
| rank correlation between WAR and AAV is {OVERVIEW['spearman_war_salary_all']} across all | |
| {OVERVIEW['n_players']} players (p = {OVERVIEW['spearman_war_salary_all_p']}) and | |
| {OVERVIEW['spearman_war_salary_market']} within the {OVERVIEW['n_market_contracts']} market contracts | |
| (p = {OVERVIEW['spearman_war_salary_market_p']}). The model therefore describes the salary implied by current | |
| statistics alone, and the leave-one-out figures show how much of the salary variation those statistics explain. | |
| """ | |
| def _money(value: float) -> str: | |
| return A.format_money(value) | |
| def display_performance_table(perf: pd.DataFrame) -> pd.DataFrame: | |
| """Fixed decimals for the interface; the analysis keeps the numeric columns.""" | |
| table = perf.copy() | |
| table["Percentile"] = table["Percentile"].map(lambda v: f"{v:.1f}") | |
| table["z-score"] = table["z-score"].map(lambda v: f"{v:.2f}") | |
| return table | |
| def summary_markdown(result: dict) -> str: | |
| p = result["player"] | |
| s = result["salary"] | |
| v = result["assessment"] | |
| m = result["model"] | |
| c = result["comparables"] | |
| cost = result["cost_per_war"] | |
| perf = result["performance_table"].set_index("Statistic") | |
| war_rank = perf.loc["WAR", "Rank"] | |
| lines = [ | |
| f"### {p['name']} ({p['team']})", | |
| f"**{v['headline']}**", | |
| "", | |
| "| Indicator | Value |", | |
| "|---|---|", | |
| f"| AAV | {_money(s['salary'])} (rank {s['salary_rank']} of {s['n_players']}, percentile {s['salary_percentile']}" | |
| + (f"; percentile {s['salary_percentile_among_market']} among the other {s['n_market_others']} market contracts) |" | |
| if s['salary_percentile_among_market'] is not None else | |
| f"; one of the {s['n_minimum_contracts']} players near the league minimum) |"), | |
| f"| WAR | {perf.loc['WAR', 'Player']} (rank {war_rank}, percentile {v['war_percentile']}) |", | |
| f"| Mean percentile across the 16 statistics | {v['mean_stat_percentile']} |", | |
| f"| TabICLv2 predicted median salary for these statistics | {_money(m['predicted_median_salary'])} |", | |
| f"| Actual salary as a percentile of the predicted distribution | {m['actual_percentile_in_predicted']} ({v['model_reading']}) |", | |
| f"| Median salary of the five most similar market-contract players | {_money(c['comparable_median_salary'])} ({v['comparables_reading']}) |", | |
| ] | |
| if cost["cost_per_war"] is not None: | |
| lines.append( | |
| f"| AAV per WAR | {_money(cost['cost_per_war'])} against a market median of " | |
| f"{_money(cost['market_median_cost_per_war'])}; cheaper than {cost['cost_per_war_percentile_among_market']}% of market contracts |" | |
| ) | |
| else: | |
| lines.append("| AAV per WAR | not defined (WAR is zero or negative) |") | |
| lines.append(f"| Rank comparison | {v['rank_reading']} |") | |
| return "\n".join(lines) | |
| def model_markdown(result: dict) -> str: | |
| m = result["model"] | |
| q = m["predicted_quantiles"] | |
| lines = [ | |
| "### TabICLv2 salary model", | |
| f"Fitted on the {m['n_train']} market-contract players (AAV above ${A.MARKET_CONTRACT_THRESHOLD:,}) other than " | |
| f"the selected player, {m['n_estimators']} ensemble members, inputs: the 16 statistics, target: log(AAV).", | |
| "", | |
| "| Predicted salary for these statistics | Amount |", | |
| "|---|---|", | |
| f"| 10th percentile | {_money(q['q10'])} |", | |
| f"| 25th percentile | {_money(q['q25'])} |", | |
| f"| Median | {_money(q['q50'])} |", | |
| f"| 75th percentile | {_money(q['q75'])} |", | |
| f"| 90th percentile | {_money(q['q90'])} |", | |
| f"| Actual AAV | {_money(m['actual_salary'])} |", | |
| f"| Actual as a percentile of the predicted distribution | {m['actual_percentile_in_predicted']} |", | |
| f"| Actual divided by predicted median | {m['actual_over_predicted_median']:.2f} |", | |
| "", | |
| ] | |
| if not m["selected_player_is_market_contract"]: | |
| lines.append( | |
| f"This player earns at most ${A.MARKET_CONTRACT_THRESHOLD:,}, a salary set by service-time rules rather " | |
| "than by performance, so the figures above show what market contracts pay for comparable statistics." | |
| ) | |
| lines.append("") | |
| lines.append(validation_markdown()) | |
| return "\n".join(lines) | |
| def validation_markdown() -> str: | |
| status = VALIDATION.status() | |
| if status["error"]: | |
| return f"Leave-one-out validation failed: {status['error']}" | |
| res = status["result"] | |
| if res is None: | |
| return (f"Leave-one-out validation over the {status['total']} market-contract players is running " | |
| f"({status['completed']} of {status['total']} done). Run the analysis again later to see it.") | |
| return "\n".join([ | |
| f"**Model validity, leave-one-out over the {res['n_players']} market-contract players**", | |
| "", | |
| "| Measure | Value |", | |
| "|---|---|", | |
| f"| R² on log salary | {res['r2_log_salary']} |", | |
| f"| Spearman rank correlation, predicted vs actual | {res['spearman_rho']} (p = {res['spearman_p_value']}) |", | |
| f"| Median absolute error | {res['median_abs_pct_error']}% of actual salary |", | |
| f"| Typical error of the predicted median | a factor of {math.exp(res['mean_abs_error_log']):.1f} in either direction |", | |
| f"| Share of actual salaries inside the predicted 10-90 interval | {res['coverage_10_90']}% (ideal 80%) |", | |
| f"| Share of actual salaries inside the predicted 25-75 interval | {res['coverage_25_75']}% (ideal 50%) |", | |
| ]) | |
| def narrative_markdown(out: dict) -> str: | |
| if out["error"]: | |
| return f"### Gemini narrative\nNarrative unavailable. {out['error']}" | |
| paragraphs = [part.strip() for part in out["text"].split("\n") if part.strip()] | |
| text = "\n\n".join(paragraphs) | |
| parts = [f"### Gemini narrative ({out['model']})", text] | |
| if out["untraceable_numbers"]: | |
| parts.append( | |
| "Figures in this narrative that could not be matched to the computed results and should be " | |
| "disregarded: " + ", ".join(out["untraceable_numbers"]) | |
| ) | |
| else: | |
| parts.append("All figures in this narrative matched the computed results after rounding.") | |
| return "\n\n".join(parts) | |
| def make_figure(result: dict) -> plt.Figure: | |
| name = result["player"]["name"] | |
| idx = result["player"]["index"] | |
| comps = result["comparables"]["comparable_names"] | |
| m = result["model"] | |
| q = m["predicted_quantiles"] | |
| war = DATA["WAR"].to_numpy(dtype=float) | |
| aav = DATA[A.SALARY_COLUMN].to_numpy(dtype=float) / 1e6 | |
| comp_mask = DATA[A.NAME_COLUMN].isin(comps).to_numpy() | |
| fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(11, 4.2), gridspec_kw={"width_ratios": [3, 2]}) | |
| fig.patch.set_facecolor("white") | |
| for ax in (ax1, ax2): | |
| ax.set_facecolor("white") | |
| for spine in ax.spines.values(): | |
| spine.set_color("black") | |
| ax.tick_params(colors="black") | |
| ax1.scatter(war, aav, s=18, color="#9a9a9a", label="Other players") | |
| ax1.scatter(war[comp_mask], aav[comp_mask], s=40, facecolors="white", edgecolors="black", linewidths=1.2, | |
| label="Five most similar players") | |
| ax1.scatter([war[idx]], [aav[idx]], s=90, color="black", marker="D", label=name) | |
| ax1.set_yscale("log") | |
| ax1.set_xlabel("WAR", color="black") | |
| ax1.set_ylabel("AAV, millions of dollars (log scale)", color="black") | |
| ax1.set_title("Salary against WAR for all players", color="black", fontsize=11) | |
| ax1.legend(frameon=False, fontsize=8, loc="upper center", bbox_to_anchor=(0.5, -0.16), ncol=3) | |
| ax1.grid(True, color="#e6e6e6", linewidth=0.6) | |
| levels = [("10th", q["q10"]), ("25th", q["q25"]), ("Median", q["q50"]), ("75th", q["q75"]), ("90th", q["q90"])] | |
| ys = np.arange(len(levels)) | |
| ax2.barh(ys, [v / 1e6 for _, v in levels], color="#d9d9d9", edgecolor="black", height=0.6) | |
| ax2.axvline(m["actual_salary"] / 1e6, color="black", linewidth=2) | |
| ax2.text(m["actual_salary"] / 1e6, len(levels) - 0.45, f" actual {A.format_money_short(m['actual_salary'])}", | |
| color="black", fontsize=8, va="bottom") | |
| ax2.set_yticks(ys) | |
| ax2.set_yticklabels([label for label, _ in levels], color="black") | |
| ax2.set_xlabel("Predicted salary, millions of dollars", color="black") | |
| ax2.set_title("TabICLv2 predicted salary distribution", color="black", fontsize=11) | |
| ax2.set_ylim(-0.6, len(levels) + 0.2) | |
| ax2.grid(True, axis="x", color="#e6e6e6", linewidth=0.6) | |
| fig.tight_layout() | |
| return fig | |
| def run_analysis(player: str, model_name: str, api_key: str): | |
| if not player or player not in PLAYER_NAMES: | |
| raise gr.Error("Select a player from the list.") | |
| try: | |
| result = A.analyze_player(DATA, player) | |
| except Exception as exc: # noqa: BLE001 - surfaced in the interface | |
| raise gr.Error(f"Analysis failed: {type(exc).__name__}: {exc}") from exc | |
| payload = A.analysis_to_json(result) | |
| validation = VALIDATION.status()["result"] | |
| story = N.generate_narrative(payload, validation, api_key, model=model_name or N.DEFAULT_MODEL) | |
| return ( | |
| summary_markdown(result), | |
| display_performance_table(result["performance_table"]), | |
| result["comparables_table"], | |
| make_figure(result), | |
| model_markdown(result), | |
| narrative_markdown(story), | |
| ) | |
| with gr.Blocks(title=TITLE) as demo: | |
| gr.Markdown(f"# {TITLE}\n{INTRO}") | |
| with gr.Row(): | |
| player_dd = gr.Dropdown(choices=PLAYER_NAMES, value=DEFAULT_PLAYER, label="Player", scale=2) | |
| model_dd = gr.Dropdown(choices=N.MODEL_CHOICES, value=N.DEFAULT_MODEL, label="Gemini model", scale=1) | |
| key_tb = gr.Textbox( | |
| label="Gemini API key", | |
| info="Optional when the GEMINI_API_KEY secret is set", | |
| type="password", | |
| scale=2, | |
| ) | |
| run_btn = gr.Button("Analyze", variant="primary", scale=1) | |
| summary_md = gr.Markdown() | |
| perf_df = gr.Dataframe( | |
| label=f"Performance compared with the other {len(DATA) - 1} players (K%: lower is better)", | |
| interactive=False, | |
| wrap=True, | |
| buttons=[], | |
| max_height=760, | |
| ) | |
| comps_df = gr.Dataframe( | |
| label=f"Five most similar players on market contracts (AAV above ${A.MARKET_CONTRACT_THRESHOLD:,}), by standardized statistics", | |
| interactive=False, | |
| wrap=True, | |
| buttons=[], | |
| ) | |
| plot = gr.Plot(show_label=False, format="png") | |
| model_md = gr.Markdown() | |
| narrative_md = gr.Markdown() | |
| with gr.Accordion("Method and limitations", open=False): | |
| gr.Markdown(METHOD_NOTES) | |
| outputs = [summary_md, perf_df, comps_df, plot, model_md, narrative_md] | |
| run_btn.click(run_analysis, inputs=[player_dd, model_dd, key_tb], outputs=outputs, api_name="run_analysis") | |
| # The page opens with Player 1 analysed. Each page load therefore costs one model fit and one Gemini request. | |
| demo.load(run_analysis, inputs=[player_dd, model_dd, key_tb], outputs=outputs, api_visibility="private") | |
| if __name__ == "__main__": | |
| demo.launch( | |
| server_name=os.environ.get("GRADIO_SERVER_NAME", "0.0.0.0"), | |
| server_port=int(os.environ.get("GRADIO_SERVER_PORT", "7860")), | |
| theme=gr.themes.Base(primary_hue="neutral", secondary_hue="neutral", neutral_hue="neutral"), | |
| css=CSS, | |
| head=FORCE_LIGHT_HEAD, | |
| footer_links=[], | |
| quiet=True, | |
| ) | |