nenzilea's picture
Fix error in position gain calculation
c4afb70 verified
Raw
History Blame Contribute Delete
25.1 kB
import json
import os
import pickle
import numpy as np
import pandas as pd
import streamlit as st
from pathlib import Path
from openai import OpenAI
# Config
_ROOT = Path(__file__).resolve().parent
MODELS_DIR = _ROOT / "models"
PROCESSED_DIR = _ROOT / "data" / "processed"
_GPT_MODEL = "gpt-4o-mini"
# Lookup tables
DRIVER_ENC = {
"aitken": 0, "albon": 1, "alonso": 2, "antonelli": 3, "bearman": 4,
"bortoleto": 5, "bottas": 6, "colapinto": 7, "de_vries": 8, "doohan": 9,
"gasly": 10, "giovinazzi": 11, "grosjean": 12, "hadjar": 13,
"hamilton": 14, "hulkenberg": 15, "kevin_magnussen": 16, "kubica": 17,
"kvyat": 18, "latifi": 19, "lawson": 20, "leclerc": 21,
"max_verstappen": 22, "mazepin": 23, "mick_schumacher": 24, "norris": 25,
"ocon": 26, "perez": 27, "piastri": 28, "pietro_fittipaldi": 29,
"raikkonen": 30, "ricciardo": 31, "russell": 32, "sainz": 33,
"sargeant": 34, "stroll": 35, "tsunoda": 36, "vettel": 37, "zhou": 38,
}
CONSTRUCTOR_ENC = {
"alfa": 0, "alphatauri": 1, "alpine": 2, "aston_martin": 3,
"ferrari": 4, "haas": 5, "mclaren": 6, "mercedes": 7,
"racing_point": 8, "rb": 9, "red_bull": 10, "renault": 11,
"sauber": 12, "williams": 13,
}
CIRCUIT_ENC = {
"albert_park": 0, "americas": 1, "bahrain": 2, "baku": 3,
"catalunya": 4, "hungaroring": 5, "imola": 6, "interlagos": 7,
"istanbul": 8, "jeddah": 9, "losail": 10, "marina_bay": 11,
"miami": 12, "monaco": 13, "monza": 14, "mugello": 15,
"nurburgring": 16, "portimao": 17, "red_bull_ring": 18, "ricard": 19,
"rodriguez": 20, "shanghai": 21, "silverstone": 22, "sochi": 23,
"spa": 24, "suzuka": 25, "vegas": 26, "villeneuve": 27,
"yas_marina": 28, "zandvoort": 29,
}
DRIVER_DISPLAY = {
"aitken": "Jack Aitken", "albon": "Alexander Albon",
"alonso": "Fernando Alonso", "antonelli": "Kimi Antonelli",
"bearman": "Oliver Bearman", "bortoleto": "Gabriel Bortoleto",
"bottas": "Valtteri Bottas", "colapinto": "Franco Colapinto",
"de_vries": "Nyck de Vries", "doohan": "Jack Doohan",
"gasly": "Pierre Gasly", "giovinazzi": "Antonio Giovinazzi",
"grosjean": "Romain Grosjean", "hadjar": "Isack Hadjar",
"hamilton": "Lewis Hamilton", "hulkenberg": "Nico Hulkenberg",
"kevin_magnussen": "Kevin Magnussen", "kubica": "Robert Kubica",
"kvyat": "Daniil Kvyat", "latifi": "Nicholas Latifi",
"lawson": "Liam Lawson", "leclerc": "Charles Leclerc",
"max_verstappen": "Max Verstappen", "mazepin": "Nikita Mazepin",
"mick_schumacher": "Mick Schumacher", "norris": "Lando Norris",
"ocon": "Esteban Ocon", "perez": "Sergio Perez",
"piastri": "Oscar Piastri", "pietro_fittipaldi": "Pietro Fittipaldi",
"raikkonen": "Kimi Raikkonen", "ricciardo": "Daniel Ricciardo",
"russell": "George Russell", "sainz": "Carlos Sainz",
"sargeant": "Logan Sargeant", "stroll": "Lance Stroll",
"tsunoda": "Yuki Tsunoda", "vettel": "Sebastian Vettel",
"zhou": "Guanyu Zhou",
}
CIRCUIT_DISPLAY = {
"albert_park": "Albert Park", "americas": "Circuit of the Americas",
"bahrain": "Bahrain International Circuit", "baku": "Baku City Circuit",
"catalunya": "Circuit de Barcelona-Catalunya", "hungaroring": "Hungaroring",
"imola": "Imola", "interlagos": "Interlagos",
"istanbul": "Istanbul Park", "jeddah": "Jeddah Corniche Circuit",
"losail": "Losail International Circuit", "marina_bay": "Marina Bay Street Circuit",
"miami": "Miami International Autodrome", "monaco": "Circuit de Monaco",
"monza": "Monza", "mugello": "Mugello",
"nurburgring": "Nurburgring", "portimao": "Algarve International Circuit",
"red_bull_ring": "Red Bull Ring", "ricard": "Circuit Paul Ricard",
"rodriguez": "Autodromo Hermanos Rodriguez", "shanghai": "Shanghai International Circuit",
"silverstone": "Silverstone", "sochi": "Sochi Autodrom",
"spa": "Circuit de Spa-Francorchamps", "suzuka": "Suzuka International Racing Course",
"vegas": "Las Vegas Street Circuit", "villeneuve": "Circuit Gilles Villeneuve",
"yas_marina": "Yas Marina Circuit", "zandvoort": "Circuit Zandvoort",
}
# Data and model loading
@st.cache_resource
def load_model():
with open(MODELS_DIR / "f1_prediction_model.pkl", "rb") as f:
model = pickle.load(f)
with open(MODELS_DIR / "metadata.json") as f:
metadata = json.load(f)
return model, metadata
@st.cache_data
def load_data():
return pd.read_csv(PROCESSED_DIR / "f1_features.csv")
# Feature engineering
def _driver_history(df: pd.DataFrame, driver: str, circuit: str) -> dict:
drv = df[df["driver_id"] == driver]
crc = drv[drv["circuit_id"] == circuit]
def _med(series, fallback=None):
v = series.dropna()
if len(v):
return float(v.median())
if fallback is not None:
v2 = fallback.dropna()
return float(v2.median()) if len(v2) else np.nan
return np.nan
return {
"driver_avg_change": _med(drv["driver_avg_change"]),
"driver_circuit_avg": _med(crc["driver_circuit_avg"], drv["driver_avg_change"]),
"constructor_avg_change": _med(drv["constructor_avg_change"]),
"driver_dnf_rate": _med(drv["driver_dnf_rate"]),
"constructor_dnf_rate": _med(drv["constructor_dnf_rate"]),
"championship_position": _med(drv["championship_position"]),
"quali_gap_to_pole": _med(crc["quali_gap_to_pole"], drv["quali_gap_to_pole"]),
"quali_time_sec": _med(crc["quali_time_sec"], drv["quali_time_sec"]),
"quali_speed_fl": _med(crc["quali_speed_fl"], drv["quali_speed_fl"]),
"driver_race_pace": _med(drv["driver_race_pace"]),
}
def build_feature_row(
df, driver, circuit, grid_position,
temp_max, precipitation, windspeed_max,
latest_season, latest_round, driver_constructor,
):
constructor = driver_constructor.get(driver, "williams")
history = _driver_history(df, driver, circuit)
row = pd.DataFrame([{
"grid_position": grid_position,
"driver_id_enc": DRIVER_ENC.get(driver, 0),
"constructor_id_enc": CONSTRUCTOR_ENC.get(constructor, 0),
"circuit_id_enc": CIRCUIT_ENC.get(circuit, 0),
"season": latest_season,
"round": latest_round,
"temp_max": temp_max,
"precipitation": precipitation,
"windspeed_max": windspeed_max,
"driver_avg_change": history["driver_avg_change"],
"driver_circuit_avg": history["driver_circuit_avg"],
"constructor_avg_change": history["constructor_avg_change"],
"is_pitlane_start": 1 if grid_position == 0 else 0,
"driver_dnf_rate": history["driver_dnf_rate"],
"constructor_dnf_rate": history["constructor_dnf_rate"],
"championship_position": history["championship_position"],
"quali_time_sec": history["quali_time_sec"],
"quali_gap_to_pole": history["quali_gap_to_pole"],
"quali_speed_fl": history["quali_speed_fl"],
"driver_race_pace": history["driver_race_pace"],
}])
return row, history
# LLM analysis
_FEW_SHOT_EXAMPLES = [
{
"driver": "Max Verstappen", "grid": 1, "prediction": "Held", "confidence": 0.72,
"analysis": (
"Verstappen started from pole position and confidently defended his lead "
"throughout the race. The model predicted 'Held' with high confidence, "
"supported by Red Bull Racing's dominant pace and a flawless pit strategy. "
"Dry conditions at 28°C favoured a stable race with no strategic surprises."
),
},
{
"driver": "Carlos Sainz", "grid": 8, "prediction": "Gained", "confidence": 0.61,
"analysis": (
"Sainz moved up from P8 to P4, gaining four positions across the race. "
"The model predicted 'Gained' based on his historically strong recovery "
"drives and Ferrari's superior tyre management strategy. Light rain in the "
"second half of the race further played into the Spaniard's hands."
),
},
]
def _get_api_key() -> str | None:
if key := os.environ.get("OPENAI_API_KEY"):
return key
try:
return st.secrets["OPENAI_API_KEY"]
except (KeyError, FileNotFoundError):
return None
def _call_llm(system_prompt: str, user_prompt: str) -> str:
api_key = _get_api_key()
if not api_key:
raise EnvironmentError(
"OPENAI_API_KEY is not set. "
"Add it under Settings → Repository secrets in your HF Space."
)
client = OpenAI(api_key=api_key)
response = client.chat.completions.create(
model=_GPT_MODEL,
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
],
max_tokens=350,
temperature=0.7,
)
return response.choices[0].message.content.strip()
def run_analysis(
driver, grid, prediction, confidence,
history, temp_max, precipitation, windspeed_max,
scenario="",
) -> str:
drv_avg = history.get("driver_circuit_avg")
con_avg = history.get("constructor_avg_change")
examples_text = "\n\n".join([
f"Example:\nDriver: {ex['driver']}, Grid: {ex['grid']}, "
f"Prediction: {ex['prediction']} (Confidence: {ex['confidence']:.0%})\n"
f"Analysis: {ex['analysis']}"
for ex in _FEW_SHOT_EXAMPLES
])
system_prompt = (
"You are an F1 race analyst. Explain in 3–4 sentences why the ML model "
"predicts this position change for the driver. Reference the specific data "
f"provided. Follow the style of the examples below:\n\n{examples_text}"
)
user_prompt = (
f"Driver: {driver}\n"
f"Grid Position (Qualifying): {grid}\n"
f"ML Prediction: {prediction} (Confidence: {confidence:.0%})\n"
f"Weather: Temperature {temp_max}°C, Precipitation {precipitation} mm, "
f"Wind {windspeed_max} km/h\n"
f"Driver's historical avg position change at this circuit: "
f"{f'{drv_avg:.1f} positions' if drv_avg is not None and not np.isnan(drv_avg) else 'n/a'}\n"
f"Team avg position change: "
f"{f'{con_avg:.1f} positions' if con_avg is not None and not np.isnan(con_avg) else 'n/a'}"
)
if scenario.strip():
user_prompt += f"\n\nAdditional question / scenario: {scenario}"
return _call_llm(system_prompt, user_prompt)
# Prediction pipeline
def run_prediction(
df, model, feature_cols, label_inv, driver_constructor,
driver_key, circuit_key, grid_pos,
temp_max, precipitation, windspeed_max,
latest_season, latest_round,
) -> dict:
X_df, history = build_feature_row(
df, driver_key, circuit_key, grid_pos,
float(temp_max), float(precipitation), float(windspeed_max),
latest_season, latest_round, driver_constructor,
)
proba = model.predict_proba(X_df[feature_cols])[0].copy()
# Physical constraints: P1 cannot gain, P20 cannot lose
gained_idx = next((k for k, v in label_inv.items() if v == "Gained"), None)
lost_idx = next((k for k, v in label_inv.items() if v == "Lost"), None)
if grid_pos == 1 and gained_idx is not None:
proba[gained_idx] = 0.0
proba /= proba.sum()
if grid_pos == 20 and lost_idx is not None:
proba[lost_idx] = 0.0
proba /= proba.sum()
pred_label = label_inv[int(np.argmax(proba))]
confidence = float(proba.max())
constructor = driver_constructor.get(driver_key, "–").replace("_", " ").title()
return {
"label": pred_label,
"confidence": confidence,
"proba": proba.tolist(),
"constructor": constructor,
"driver_name": DRIVER_DISPLAY.get(driver_key, driver_key),
"circuit_name": CIRCUIT_DISPLAY.get(circuit_key, circuit_key),
"grid": grid_pos,
"history": history,
"temp_max": float(temp_max),
"precipitation": float(precipitation),
"windspeed_max": float(windspeed_max),
}
# UI
st.set_page_config(
page_title="F1 Grid-to-Flag Predictor",
page_icon="🏎️",
layout="wide",
)
st.markdown("""
<style>
/* Base */
html, body, [data-testid="stAppViewContainer"], [data-testid="stApp"] {
background-color: #0D0D0D !important;
color: #FFFFFF;
}
[data-testid="stHeader"] { background-color: #0D0D0D !important; }
[data-testid="stToolbar"] { display: none; }
footer { visibility: hidden; }
/* Sidebar */
[data-testid="stSidebar"] {
background-color: #111111 !important;
border-right: 1px solid #1E1E1E;
}
/* Typography */
h1 { font-weight: 800 !important; letter-spacing: 0.04em !important; }
h2 { font-weight: 700 !important; letter-spacing: 0.03em !important; }
h3 {
font-weight: 700 !important;
letter-spacing: 0.06em !important;
text-transform: uppercase;
font-size: 0.85rem !important;
color: #E10600 !important;
margin-bottom: 0.8rem !important;
}
label, .stSlider label, .stSelectbox label, .stNumberInput label, .stTextArea label {
color: #888888 !important;
font-size: 0.72rem !important;
text-transform: uppercase !important;
letter-spacing: 0.1em !important;
font-weight: 600 !important;
}
/* Divider */
hr {
border: none !important;
border-top: 1px solid #1E1E1E !important;
margin: 1.8rem 0 !important;
}
/* Metric cards */
[data-testid="metric-container"] {
background-color: #141414;
border: 1px solid #1E1E1E;
border-top: 3px solid #E10600;
padding: 1rem 1.2rem !important;
border-radius: 2px;
}
[data-testid="stMetricLabel"] > div {
color: #666666 !important;
font-size: 0.68rem !important;
text-transform: uppercase !important;
letter-spacing: 0.12em !important;
font-weight: 600 !important;
}
[data-testid="stMetricValue"] > div {
color: #FFFFFF !important;
font-size: 1.5rem !important;
font-weight: 700 !important;
}
/* Buttons */
button[kind="primary"] {
background-color: #E10600 !important;
color: #FFFFFF !important;
border: none !important;
border-radius: 2px !important;
font-weight: 700 !important;
letter-spacing: 0.12em !important;
text-transform: uppercase !important;
padding: 0.6rem 1.5rem !important;
}
button[kind="primary"]:hover {
background-color: #FF1A1A !important;
border: none !important;
}
button[kind="secondary"] {
background-color: transparent !important;
color: #E10600 !important;
border: 1px solid #E10600 !important;
border-radius: 2px !important;
font-weight: 600 !important;
letter-spacing: 0.1em !important;
text-transform: uppercase !important;
}
button[kind="secondary"]:hover {
background-color: #E10600 !important;
color: #FFFFFF !important;
}
/* Inputs */
[data-testid="stSelectbox"] div[data-baseweb="select"] > div,
[data-testid="stNumberInput"] input,
[data-testid="stTextArea"] textarea {
background-color: #141414 !important;
border-color: #2A2A2A !important;
color: #FFFFFF !important;
border-radius: 2px !important;
}
[data-testid="stSelectbox"] div[data-baseweb="select"] > div:focus-within,
[data-testid="stNumberInput"] input:focus,
[data-testid="stTextArea"] textarea:focus {
border-color: #E10600 !important;
box-shadow: 0 0 0 1px #E10600 !important;
}
/* Slider */
[data-testid="stSlider"] [data-baseweb="slider"] [role="slider"] {
background-color: #E10600 !important;
border-color: #E10600 !important;
}
[data-testid="stSlider"] [data-baseweb="slider"] div[class*="Track"] > div:first-child {
background-color: #E10600 !important;
}
/* Expander */
[data-testid="stExpander"] {
background-color: #141414 !important;
border: 1px solid #1E1E1E !important;
border-radius: 2px !important;
}
[data-testid="stExpander"] summary {
color: #888888 !important;
font-size: 0.75rem !important;
text-transform: uppercase !important;
letter-spacing: 0.1em !important;
}
/* Info box */
[data-testid="stAlert"] {
background-color: #141414 !important;
border-color: #E10600 !important;
color: #AAAAAA !important;
border-radius: 2px !important;
}
/* Bar chart */
[data-testid="stVegaLiteChart"] { background: transparent !important; }
</style>
""", unsafe_allow_html=True)
st.markdown("""
<div style="border-left:4px solid #E10600; padding-left:1rem; margin-bottom:0.25rem;">
<div style="font-size:1.8rem; font-weight:800; letter-spacing:0.06em; color:#FFFFFF;">
F1 GRID-TO-FLAG PREDICTOR
</div>
<div style="font-size:0.8rem; color:#666666; letter-spacing:0.08em; text-transform:uppercase; margin-top:0.2rem;">
ML-powered position change forecast · explained by AI
</div>
</div>
""", unsafe_allow_html=True)
st.markdown("<div style='margin-bottom:1.5rem'></div>", unsafe_allow_html=True)
try:
model, metadata = load_model()
df = load_data()
except FileNotFoundError:
st.error(
"Model or data files not found. "
"Make sure `models/f1_prediction_model.pkl`, `models/metadata.json`, and "
"`data/processed/f1_features.csv` exist."
)
st.stop()
feature_cols = metadata["feature_columns"]
label_inv = {v: k for k, v in metadata["label_map"].items()}
class_names = list(metadata["label_map"].keys())
latest_season = int(df["season"].max())
latest_round = int(df[df["season"] == latest_season]["round"].max())
min_season = int(df["season"].min())
driver_constructor = df.groupby("driver_id")["constructor_id"].last().to_dict()
st.markdown("### Race Setup")
col_d, col_c, col_g = st.columns([3, 3, 1])
with col_d:
driver_key = st.selectbox(
"Driver",
options=sorted(DRIVER_ENC.keys()),
format_func=lambda k: DRIVER_DISPLAY.get(k, k.replace("_", " ").title()),
index=sorted(DRIVER_ENC.keys()).index("max_verstappen"),
)
with col_c:
circuit_key = st.selectbox(
"Circuit",
options=sorted(CIRCUIT_ENC.keys()),
format_func=lambda k: CIRCUIT_DISPLAY.get(k, k.replace("_", " ").title()),
index=sorted(CIRCUIT_ENC.keys()).index("bahrain"),
)
with col_g:
grid_pos = st.number_input("Grid", min_value=0, max_value=20, value=1, help="0 = pit-lane start")
st.markdown("<div style='margin-top:0.5rem'></div>", unsafe_allow_html=True)
st.markdown("### Weather Conditions")
col_t, col_r, col_w = st.columns(3)
with col_t:
temp_max = st.slider("Temperature (°C)", 5, 45, 28)
with col_r:
precipitation = st.slider("Precipitation (mm)", 0, 30, 0)
with col_w:
windspeed_max = st.slider("Wind Speed (km/h)", 5, 60, 18)
st.markdown("<div style='margin-top:1rem'></div>", unsafe_allow_html=True)
predict_clicked = st.button("Run Prediction", type="primary", use_container_width=True)
if predict_clicked or "prediction" in st.session_state:
if predict_clicked:
st.session_state["prediction"] = run_prediction(
df, model, feature_cols, label_inv, driver_constructor,
driver_key, circuit_key, grid_pos,
temp_max, precipitation, windspeed_max,
latest_season, latest_round,
)
p = st.session_state["prediction"]
st.markdown("<div style='margin-top:1.5rem'></div>", unsafe_allow_html=True)
st.divider()
label_color = {"Gained": "#00C853", "Held": "#FFFFFF", "Lost": "#E10600"}.get(p["label"], "#FFFFFF")
label_icon = {"Gained": "▲", "Held": "◆", "Lost": "▼"}.get(p["label"], "")
st.markdown(f"""
<div style="
background:#141414;
border:1px solid #1E1E1E;
border-top:3px solid {label_color};
padding:1.5rem 2rem;
margin-bottom:1.2rem;
display:flex;
align-items:center;
justify-content:space-between;
">
<div>
<div style="color:#555; font-size:0.68rem; letter-spacing:0.15em; text-transform:uppercase; margin-bottom:0.3rem;">
{p['driver_name']} · {p['circuit_name']}
</div>
<div style="color:{label_color}; font-size:2.4rem; font-weight:800; letter-spacing:0.08em; line-height:1;">
{label_icon} {p['label'].upper()}
</div>
</div>
<div style="text-align:right;">
<div style="color:#555; font-size:0.68rem; letter-spacing:0.15em; text-transform:uppercase;">Confidence</div>
<div style="color:#FFFFFF; font-size:2rem; font-weight:700;">{p['confidence']:.0%}</div>
</div>
</div>
""", unsafe_allow_html=True)
m1, m2, m3 = st.columns(3)
m1.metric("Grid Position", f"P{p['grid']}")
m2.metric("Team", p["constructor"])
train_s = metadata["train_seasons"]
m3.metric("Trained On", f"{min(train_s)}{max(train_s)}")
st.markdown("<div style='margin-top:1.2rem'></div>", unsafe_allow_html=True)
st.markdown("### Class Probabilities")
proba_df = pd.DataFrame({"Probability": p["proba"]}, index=class_names)
st.bar_chart(proba_df, color="#E10600")
_label_colors = {"Gained": "#00C853", "Held": "#FFFFFF", "Lost": "#E10600"}
prob_cols = st.columns(len(class_names))
for col, name, prob in zip(prob_cols, class_names, p["proba"]):
color = _label_colors.get(name, "#FFFFFF")
col.markdown(
f"<div style='text-align:center;'>"
f"<div style='color:#555;font-size:0.68rem;letter-spacing:0.12em;text-transform:uppercase;font-weight:600;'>{name}</div>"
f"<div style='color:{color};font-size:1.4rem;font-weight:700;'>{prob:.0%}</div>"
f"</div>",
unsafe_allow_html=True,
)
st.divider()
st.markdown("### Race Analysis")
api_key_set = bool(_get_api_key())
if not api_key_set:
st.info(
"No OpenAI API key found. "
"Add `OPENAI_API_KEY` to `.streamlit/secrets.toml` (local) "
"or under **Settings → Repository secrets** on HF Space."
)
scenario = st.text_area(
"Scenario / Question (optional)",
placeholder="e.g. 'What if there is a safety car?' or 'How does rain affect the outcome?'",
)
if st.button("Analyse", type="secondary", disabled=not api_key_set, use_container_width=True):
with st.spinner("Analysing ..."):
try:
analysis = run_analysis(
driver=p["driver_name"],
grid=p["grid"],
prediction=p["label"],
confidence=p["confidence"],
history=p["history"],
temp_max=p["temp_max"],
precipitation=p["precipitation"],
windspeed_max=p["windspeed_max"],
scenario=scenario,
)
st.markdown(f"""
<div style="
background:#141414;
border:1px solid #1E1E1E;
border-left:3px solid #E10600;
padding:1.2rem 1.4rem;
border-radius:2px;
color:#CCCCCC;
line-height:1.7;
font-size:0.92rem;
">
{analysis}
</div>
""", unsafe_allow_html=True)
except Exception as e:
st.error(f"Analysis error: {e}")
st.markdown("<div style='margin-top:2rem'></div>", unsafe_allow_html=True)
with st.expander("Model Details"):
st.markdown(f"**Best Model:** {metadata['best_model_name']}")
st.markdown(f"**Splits:** Train {metadata['train_seasons']} · Val {metadata['val_seasons']} · Test {metadata['test_seasons']}")
if metadata.get("iteration_summary"):
st.markdown("**CV Iteration Comparison (5-fold, all models):**")
iter_df = pd.DataFrame(metadata["iteration_summary"])
iter_df = iter_df.rename(columns={
"iteration": "Iteration", "model": "Model",
"cv_f1": "CV F1", "cv_std": "± Std", "cv_acc": "CV Acc",
})
iter_df[["CV F1", "± Std", "CV Acc"]] = iter_df[["CV F1", "± Std", "CV Acc"]].round(4)
st.dataframe(iter_df.set_index("Iteration"), use_container_width=True)
st.markdown("**Final Holdout Evaluation (Val 2024 · Test 2025):**")
for name, r in metadata["results"].items():
val = r.get("val_2024", {})
test = r.get("test_2025", {})
marker = " ✓" if name == metadata["best_model_name"] else ""
st.markdown(
f"- **{name}{marker}** — "
f"Val 2024: Acc={val.get('accuracy', 0):.3f}, F1={val.get('f1_weighted', 0):.3f} · "
f"Test 2025: Acc={test.get('accuracy', 0):.3f}, F1={test.get('f1_weighted', 0):.3f}"
)
st.markdown(f"**Features ({len(feature_cols)}):** {', '.join(feature_cols)}")