Seth-Austin / app.py
stevafernandes's picture
Upload 14 files
451ab90 verified
Raw History Blame Contribute Delete
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,
)