Predire le partite di tennis con il machine learning
Tempo fa mi sono chiesto se fosse possibile battere la previsione più pigra del mondo: vince quello con il ranking più alto. La risposta breve è sì. La risposta un filo più lunga è "sì, di circa sei punti percentuali, e ti costerà qualche weekend". Questo progetto è la risposta lunga.
L'obiettivo era facile da enunciare. Dai al sistema due giocatori e il contesto del torneo, e ottieni una probabilità di vittoria. Enunciarlo è stata la parte semplice.
I dati
Tutto parte da tennis_atp, il repository pubblico di Jeff Sackmann con i risultati ATP (Nota che per ora sono stati rimossi per qualche motivo, lascio il link nel caso dovessero tornare). L'archivio arriva fino al 1968, ma la pipeline carica solo dal 1990 in poi, perché nelle righe precedenti mancano i punti ranking e quasi tutte le statistiche di match, e una feature assente su un terzo dei dati non è una feature, è una zavorra.
Restano 112.056 partite. Ogni riga è un match: vincitore, perdente, torneo, superficie, turno, e per le partite più recenti anche la roba dettagliata (ace, doppi falli, percentuale di prime, punti vinti con la seconda).
Rileggere trent'anni di CSV a ogni esecuzione è qualche secondo di puro spreco, quindi davanti c'è una cache in Parquet. La prima volta ti costa un caffè, tutte le altre è istantaneo.
Poi la pulizia, che è sostanzialmente un elenco di cose tecnicamente registrate come partite ma che non dicono niente su chi sia più forte:
- ritiri e walkover, perché uno che si ferma con uno strappo addominale non è un'informazione sul livello
- durate impossibili, sotto i 20 minuti o sopra le 6 ore
- ranking oltre la posizione 2000, che in pratica significa riga sporca
Sopravvive circa il 96% dei dati, cioè 107.921 partite utilizzabili.
Il problema del formato
I file originali sono asimmetrici. Ogni riga ha le colonne winner_* e loser_*, il che vuol dire che il target è già dentro lo schema. Addestri su quella roba e il modello impara, subito e con grande sicurezza, che chi sta nella colonna del vincitore tende a vincere. Accuratezza: 100%. Utilità: zero.
La soluzione è riscrivere ogni partita come record simmetrico con player_a e player_b, assegnando il suffisso a testa o croce. Metà delle partite viene ribaltata, quindi il perdente diventa player_a e il vincitore player_b. Il target vale 1 quando vince player_a.
Adesso il dataset è antisimmetrico per costruzione. La posizione della colonna non porta segnale, quindi al modello non resta che imparare le differenze vere tra i due giocatori.
La pipeline
Perché XGBoost, in un repo che si chiama random-forest
La cartella si chiama random-forest e il modello è XGBoost. Ho cambiato estimatore all'inizio dello sviluppo e non ho mai rinominato la directory, cosa che preferisco considerare archeologia più che negligenza.
Sono entrambi ensemble di alberi, ma ci arrivano in modo diverso. Random Forest costruisce alberi in parallelo su bootstrap sample e ne fa la media. XGBoost li costruisce in sequenza, dove ognuno prova a correggere i residui di tutti quelli prima. Per questo problema il boosting vince su tre fronti che contano davvero:
- Regolarizzazione esplicita. L1 e L2 sono manopole separate. Con 40 feature correlate, diverse delle quali misurano la stessa cosa in modi diversi, quel controllo è la differenza tra un modello e un dispositivo di memorizzazione.
- Gestione nativa dei missing. Impara da sola su quale ramo mandare un NaN, invece di costringermi a scegliere una strategia di imputazione e poi a difenderla.
- Interazioni più fini. Il boosting sequenziale ritaglia combinazioni di feature a una granularità che la media di alberi indipendenti non raggiunge.
Non farò finta che la velocità sia stata un criterio. Su 108k righe entrambi finiscono prima che tu faccia in tempo ad alzarti a prendere un bicchiere d'acqua.
Il prezzo del boosting è che va in overfitting con grande entusiasmo, se glielo lasci fare. Da qui la configurazione finale: reg_alpha=5.0, reg_lambda=8.0, max_depth=5, max_leaves=32. Alberi bassi, penalizzati pesantemente, leggermente noiosi. Che è esattamente il punto.
Feature engineering, dove si decide davvero tutto
Entrano sessanta feature candidate, la selezione SHAP ne tiene 40. Se il modello funziona, gran parte del merito sta qui e non nell'estimatore.
ELO, ovvero "quanto è forte davvero questo qui"
L'ELO è il sistema di rating nato per gli scacchi: batti uno più forte e guadagni tanto, perdi contro uno più debole e restituisci altrettanto. Nel modello sono finite tre varianti.
ELO globale su tutte le superfici. ELO per superficie, separato tra terra, cemento ed erba, perché comprimere Nadal sulla terra e Federer sull'erba in un unico numero è un atto di violenza verso entrambi. ELO di picco, il massimo storico raggiunto, che è una discreta approssimazione di "quanto era forte questo giocatore quando stava bene".
Il fattore K non è la costante 32 da manuale. Decresce con l'esperienza e scala con il livello del torneo:
K = 250 / (partite_giocate + 5)^0.4 × moltiplicatore_livello × moltiplicatore_margine
I giocatori nuovi si muovono in fretta perché non sappiamo niente di loro; i veterani con 400 match a referto si muovono piano perché lo sappiamo già. Il moltiplicatore di livello va da 1.5 per uno Slam a 0.8 per un Challenger, quindi battere Djokovic a Wimbledon vale circa il doppio che batterlo in un 250. Cosa che ha senso, e soprattutto misura meglio.
elo_diff è di gran lunga la singola feature più predittiva del modello. Non c'è partita.
Rolling win rate
Percentuale di vittorie sulle ultime 3, 5, 10 e 20 partite. La forma recente conta. Molto meno di quanto suggeriscano i telecronisti, ma conta.
Il dettaglio implementativo che morde davvero: il calcolo deve essere rigorosamente look-ahead free. Per ogni partita usi solo risultati strettamente precedenti, ordinati per data. Lo sanno tutti. Tutti scrivono comunque almeno una volta uno .shift() su un frame ordinato male, e passano un pomeriggio entusiasti delle metriche che ne escono.
Head-to-head
Quante volte i due si sono già incontrati e il win rate reciproco. Sorprendentemente affilato su certe coppie. Alcuni giocatori possiedono letteralmente un avversario in un modo che né il ranking né l'ELO prevedono, e la colonna H2H è l'unico posto dove la cosa emerge.
Fatica e momentum
- Partite negli ultimi 7 giorni, un indicatore grezzo di carico di lavoro
- Vittorie e sconfitte consecutive, per il momentum di brevissimo periodo
- Trend della forma recente, la pendenza sulle ultime 5 partite, così che migliorare e peggiorare non si assomiglino
Una vittima, qui: avevo una feature days_since_last_match e l'ho tagliata, perché tourney_date è la data di inizio del torneo, non quella del match. Tutte le partite di uno Slam di due settimane condividono lo stesso timestamp, il che rende la feature sicura di sé e sbagliata. Meglio assente che fuorviante.
Contesto del torneo
Superficie come one-hot. Prestigio del torneo su una scala che va da Grand Slam (4) a Challenger (1). Turno codificato dal primo (1) alla finale (7), che cattura la difficoltà ma anche la preparazione, visto che nessuno affronta un mercoledì di sedicesimi come affronta una finale.
C'è anche il formato best-of, e si guadagna il posto. I cinque set comprimono la varianza, e la varianza compressa favorisce il più forte. Le vittorie fortunate hanno bisogno di partite corte.
Termini di interazione
Sette prodotti espliciti, per dinamiche che nessuna colonna singola espone:
elo_surface_diff × sign(rank_diff), per quando l'ELO di superficie e il ranking globale si contraddicono, che è esattamente quando succede l'upsetage_diff × tournament_prestige, dato che i giovani rendono di più nei tornei minori e i più esperti reggono meglio nei grandiform × prestige, perché la forma recente viaggia più lontano agli Slamheight_diff × surface_speed, visto che l'altezza paga sull'erba veloce molto più che sulla terra lenta
Pesatura per recency
I pesi dei campioni decadono in modo esponenziale con un'emivita di 7 anni. Una partita del 2004 contribuisce ancora, con un quarto del peso di una del 2025. Il tennis del 2004 era uno sport diverso, con racchette diverse e campi di velocità diversa, e il modello deve saperlo senza che io butti via i dati.
Temporal cross-validation, la parte che sbagliano tutti
È qui che i progetti di ML sportivo vanno a morire.
Fai una k-fold standard e il tuo set di validazione contiene partite del 2015 predette da un modello che ha già letto il 2019. Il leakage è sottile, non solleva mai un errore, e gonfia ogni metrica a cui tieni. Poi lo metti su partite vere e tutto si sgretola in silenzio.
La soluzione è uno split temporale a finestra espansiva. Addestri sul passato, validi sul futuro immediato, sposti il confine in avanti, ripeti. I confini dei fold sono scelti per quantili di data invece che ad anni tondi, così ogni finestra di validazione contiene un numero confrontabile di partite:
Il fold 1 si addestra su 36k partite, il fold 5 su 91k. L'holdout 2023-2024 (5.839 partite) non viene toccato durante il tuning, la selezione delle feature o qualunque altra cosa. È l'unico numero di questo articolo di cui mi fido completamente.
Il costo è la varianza. I fold più vecchi hanno meno storia dietro l'ELO e le statistiche rolling, quindi l'accuratezza per fold oscilla di circa 1,7 punti. Metriche oneste sono metriche più rumorose. È uno scambio che conviene sempre fare.
Ottimizzazione degli iperparametri con Optuna
Optuna fa ottimizzazione bayesiana con un sampler TPE. Invece di macinare una griglia o campionare alla cieca, costruisce un modello probabilistico dello spazio di ricerca e continua a pescare dalle zone promettenti. Un MedianPruner uccide a metà strada i trial che chiaramente non vanno da nessuna parte, ed è da lì che arriva la maggior parte del tempo risparmiato.
| Iperparametro | Range |
|---|---|
n_estimators | da 800 a 2500 |
max_depth | da 4 a 7 |
learning_rate | da 0.01 a 0.05 (scala log) |
subsample | da 0.65 a 0.90 |
colsample_bytree | da 0.55 a 0.85 |
reg_alpha | da 0.5 a 8.0 |
reg_lambda | da 1.0 a 12.0 |
min_child_weight | da 5 a 20 |
L'obiettivo è il ROC-AUC sul fold di validazione del trial corrente. 25 trial con un tetto di 30 minuti, che con il pruning coprono all'incirca lo stesso terreno di 50 trial non potati. L'intera pipeline, dal caricamento agli artifact, gira in circa sei minuti su CPU di un portatile.
Risultati
Cross-validation, media su 5 fold temporali:
- Accuratezza: 68,9% ± 1,8%
- ROC-AUC: 75,8% ± 2,0%
- Log loss: 0,581
Holdout 2023-2024, mai visto in nessuna fase dell'addestramento:
- Accuratezza: 65,5%
- ROC-AUC: 72,2%
- Log loss: 0,613
Sono 3,6 punti di AUC persi tra CV e holdout. Non è poco, ma non è nemmeno preoccupante. Una parte è strutturale: un modello addestrato fino al 2022 non ha mai sentito nominare i giocatori esplosi nel 2023, e deve valutare il gioco sulla terra di Alcaraz su una manciata di partite.
Analisi segmentata, dove diventa interessante
Per superficie, cemento 65,4%, erba 65,7%, terra 65,8%. Cioè: nessuna differenza. Mi aspettavo con una certa sicurezza che la terra fosse più difficile, in base alla teoria che scambi più lunghi diano più occasioni allo sfavorito. I dati non sono d'accordo, e l'ELO per superficie sembra fare abbastanza bene il suo lavoro da togliere alla superficie il ruolo di fonte di errore.
Per livello di torneo, Grand Slam 71,0%, Challenger 66,9%, Masters 1000 64,5%, ATP 250/500 63,8%. Il numero degli Slam è di nuovo l'effetto dei cinque set: tre set da vincere sono un tempo lunghissimo in cui essere fortunati. Il risultato sui Challenger è meno lusinghiero e più banale, perché là sotto i dislivelli sono enormi e indovinare il favorito è facile.
Per turno, primi turni 66,2%, turni intermedi 64,1%, semifinali e finali 63,2%. Esattamente la forma che ti aspetti. Più si va avanti nel tabellone, più i sopravvissuti si somigliano, e una finale tra due giocatori separati da meno di 50 punti ELO è un testa o croce con la fascetta tergisudore.
Top 10 per importanza SHAP
elo_diff(0,41), differenza di ELO globaleelo_surface_diff(0,20), differenza di ELO sulla superficierank_diff(0,12), differenza di ranking ATPvenue_experience_diff(0,09), quanto ciascuno conosce quella sedeage_prestige_interaction(0,09), età × prestigio del torneopeak_elo_diff(0,09), differenza di ELO di piccosurface_elo_rank_interaction(0,07), ELO superficie × segno del rankingrank_points_diff(0,06), differenza di punti rankingform_elo_diff(0,06), ELO pesato per la formawin_rate_diff_last_20(0,06), forma di medio periodo
Che ELO e ranking dominino non è una notizia. Quella che mi ha fatto alzare un sopracciglio è venue_experience_diff al quarto posto. Aver già giocato quel torneo, indipendentemente da come sia andata, vale segnale vero. La familiarità con i campi, con le palle, con l'altitudine, con il percorso dallo spogliatoio. I tennisti sono creature abitudinarie e il modello se n'è accorto prima di me.
Calibrazione
Il modello restituisce una probabilità, non un verdetto, e una probabilità serve a qualcosa solo se significa qualcosa. Se dice 70%, quel giocatore deve vincere circa il 70% delle volte, altrimenti non puoi confrontarla con la quota di un bookmaker né dimensionare una puntata né fare nulla che non sia ammirarla.
Dopo l'addestramento viene applicato un CalibratedClassifierCV con metodo sigmoid (Platt scaling), fittato sul set di validazione sopra un estimatore congelato, così il modello base non viene riaddestrato. La versione calibrata sostituisce quella grezza solo se migliora davvero la log loss di validazione, cosa che sembra paranoia finché la prima volta non vedi una isotonic regression andare allegramente in overfitting su un fold di validazione e ti ritrovi in produzione un modello peggiore.
Expected Calibration Error misurato sull'holdout: 0,037. Non perfetto. Abbastanza buono da poter leggere le probabilità come probabilità.
Cosa ho imparato
Il tetto è basso, ed è colpa dello sport. Il tennis ha una varianza intrinseca enorme. Un set gira su tre punti giocati male. Il modello non vede il polso che tira, la notte dormita male, la crisi personale, o un campo che alle 14 e alle 19 sotto i riflettori è un altro campo. Un 65% su dati genuinamente mai visti, in quel contesto, è un risultato vero.
L'ELO su misura si ripaga. Per superficie, con K adattivo all'esperienza, pesato per livello di torneo. Ogni pezzo è un piccolo guadagno rispetto all'ELO da manuale, e i piccoli guadagni si sommano. elo_surface_diff al secondo posto nella tabella delle importanze è tutto l'argomento di cui ho bisogno.
La validazione temporale non è negoziabile. Al primo tentativo con k-fold standard ero arrivato al 72% di accuratezza ed ero brevemente entusiasta. Con la CV temporale è sceso a 68,9%, e sull'holdout a 65,5%. Non è il modello che peggiora, è il numero che diventa onesto.
Più dati battono dati più puliti, almeno qui. Ho provato a tagliare tutto prima del 2005, ragionando che il tennis pre-2005 fosse un altro sport. Il gap CV-holdout si è chiuso da 0,034 a 0,024, cosa che è sembrata una vittoria per circa quattro minuti, finché non ho notato che l'AUC sull'holdout non si era mossa (da 0,7223 a 0,7214). Il gap si era chiuso perché la CV era scesa fino a incontrare l'holdout, non perché l'holdout fosse salito. Avevo buttato il 40% dei dati per rendere più carino un numero diagnostico. La pesatura per recency stava già gestendo il problema delle ere nel modo giusto. Tutto revertito.
Le feature di interazione sono una faccenda più sottile di "servono o no". Due delle sette entrano nella top ten SHAP, il che sembra un successo clamoroso, ma il guadagno di AUC nell'aggiungerle tutte e sette è stato circa lo 0,5%. Sono vere entrambe le cose: sono genuinamente informative, e sono informative soprattutto su cose che il gradient boosting stava già cogliendo per conto suo. Importanza e valore marginale sono domande diverse, e mi ci è voluto più tempo di quanto vorrei ammettere per interiorizzarlo.
Cancellare feature è sottovalutato. Le percentuali di vittoria per superficie erano mancanti tra il 65 e il 71% dei casi e imputate a 0.0, il che diceva sottovoce al modello che "nessun dato" e "perfetta parità" sono la stessa cosa. Erano anche ridondanti rispetto all'ELO di superficie. Toglierle non ha cambiato nulla sull'holdout e ha reso il modello più facile da ragionare.
Dove si va da qui
- Le quote dei bookmaker come feature. Le quote incorporano tutto quello che io non vedo: voci sugli infortuni, report dai campi di allenamento, la sicurezza di chi sta dentro l'ambiente. Aggiungerle probabilmente aiuterebbe, ma la domanda più interessante è di quanto aiutano, perché è una misura diretta di quanta parte del vantaggio del mercato sia informazione a cui semplicemente non ho accesso.
- Le statistiche di match come input di prima classe. Ace, percentuale di prime e palle break salvate esistono solo per una frazione delle partite storiche, quindi al momento sono ridotte a un'unica feature di dominanza al servizio. Un modello addestrato solo su partite completamente strumentate avrebbe meno dati e dati molto più ricchi. Vale la pena provare.
- Un modello per superficie. Un modello specializzato sulla terra potrebbe battere quello generalista sulla terra. Avrebbe anche un terzo dei dati, che è esattamente lo scambio che i risultati segmentati suggeriscono non convenga, ma mi piacerebbe essere smentito.
- Embedding dei giocatori. Invece di aggregati costruiti a mano, un vettore appreso per ogni giocatore. Le partite per giocatore sono abbastanza da rendere la cosa plausibile, e potrebbe catturare matchup stilistici che nessuna differenza tra scalari riesce a esprimere.
Il codice è su GitHub. Il modello addestrato è incluso nel repo, così puoi lanciare le predizioni senza riaddestrare nulla, anche se riaddestrare richiede circa sei minuti se hai voglia di guardarlo lavorare.