Klasyfikacja: czy kierowca skończy na podium?¶
Czy na podstawie tego, co wiemy przed startem, da się przewidzieć podium?
Korzystamy z cech przedwyścigowych: pozycji startowej, zespołu oraz różnicy czasu do najszybszego okrążenia kwalifikacji.
import time
import numpy as np
import pandas as pd
import plotly.express as px
import plotly.graph_objects as go
from sklearn.compose import ColumnTransformer
from sklearn.preprocessing import OneHotEncoder, StandardScaler
from sklearn.pipeline import Pipeline
from sklearn.dummy import DummyClassifier
from sklearn.linear_model import LogisticRegression
from sklearn.neighbors import KNeighborsClassifier
from sklearn.ensemble import RandomForestClassifier, HistGradientBoostingClassifier
from sklearn.model_selection import StratifiedKFold, cross_val_predict
from sklearn.metrics import (accuracy_score, confusion_matrix, classification_report,
roc_auc_score, average_precision_score,
roc_curve, precision_recall_curve)
dr = pd.read_parquet('../data/driver_race.parquet')
dr = dr.dropna(subset=['GridPosition']).reset_index(drop=True)
print('wierszy:', len(dr), '| podiów:', int(dr.podium.sum()), f'({dr.podium.mean():.1%})')
print('sezony:', sorted(dr.Season.unique()))
wierszy: 918 | podiów: 138 (15.0%) sezony: [np.int64(2023), np.int64(2024)]
Cechy i sposób walidacji¶
Bierzemy wyłącznie cechy przedwyścigowe. Cechę quali_gap_s dołączamy tylko wtedy, gdy jest realnie wypełniona - w pierwotnej wersji projektu była w całości pusta (opisaliśmy to w notebooku 01, Krok 1).
Danych jest niewiele, a klasy są niezbalansowane, dlatego zamiast jednego podziału stosujemy walidację krzyżową z warstwowaniem (StratifiedKFold) i oceniamy model na predykcjach out-of-fold (dla każdego wiersza predykcja pochodzi z modelu, który go nie widział na treningu).
Rozwińmy, czym jest walidacja krzyżowa z warstwowaniem, bo to klucz do uczciwej oceny przy tak małym zbiorze. Walidacja krzyżowa dzieli dane na k = 5 równych części. Model trenujemy na czterech z nich, a testujemy na piątej - i powtarzamy to pięć razy, za każdym razem wydzielając inną część jako testową. W efekcie każdy wiersz dokładnie raz jest testowy, a jego predykcja pochodzi zawsze z modelu, który go nie widział na treningu (stąd out-of-fold). To znacznie uczciwsze niż pojedynczy podział, bo oceniamy model na wszystkich danych, a nie na jednej losowo wybranej ćwiartce.
Warstwowanie (stratyfikacja) pilnuje przy tym, by każda z pięciu części miała ten sam odsetek podiów (~15%). Bez tego, przy tak rzadkiej klasie, mogłoby się zdarzyć, że do którejś części nie trafi ani jedno podium - i ocena na niej byłaby bezwartościowa. Stratyfikacja gwarantuje, że klasa mniejszościowa jest reprezentowana równomiernie w każdej części.
cat_cols = ['Team']
num_cols = ['GridPosition']
if dr['quali_gap_s'].notna().sum() > 0.5 * len(dr):
num_cols.append('quali_gap_s')
dr['quali_gap_s'] = dr['quali_gap_s'].fillna(dr['quali_gap_s'].median())
print('quali_gap_s UŻYTE jako cecha (wypełnione:', dr.quali_gap_s.notna().sum(), ')')
else:
print('quali_gap_s pominięte - za dużo braków')
features = cat_cols + num_cols
X = dr[features].copy()
y = dr['podium'].values
cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
print('cechy:', features)
quali_gap_s UŻYTE jako cecha (wypełnione: 918 ) cechy: ['Team', 'GridPosition', 'quali_gap_s']
Dlaczego w tym scenariuszu sama trafność myli¶
Wyobraźmy sobie najprostszy możliwy "model": zawsze twierdzi, że podium nie będzie. Zobaczmy, jaką osiągnie trafność:
zawsze_nie = np.zeros_like(y)
print(f'„zawsze NIE podium" - accuracy = {accuracy_score(y, zawsze_nie):.1%}')
print('...model, który nie przewiduje NICZEGO, ma ~85% trafności.')
print('Dlatego accuracy jest tu bezużyteczne - liczą się precision/recall, ROC-AUC, PR-AUC.')
„zawsze NIE podium" - accuracy = 85.0% ...model, który nie przewiduje NICZEGO, ma ~85% trafności. Dlatego accuracy jest tu bezużyteczne - liczą się precision/recall, ROC-AUC, PR-AUC.
Wyciek danych¶
Gdybyśmy chcieli sztucznie podbić wynik, moglibyśmy dorzucić cechę z przebiegu wyścigu - na przykład liczbę zdobytych punktów:
# CELOWY BLAD: dorzucamy 'Points'
X_leak = dr[features + ['Points']].copy()
pre_leak = ColumnTransformer([
('cat', OneHotEncoder(handle_unknown='ignore', sparse_output=False), cat_cols),
('num', StandardScaler(), num_cols + ['Points'])])
leak_model = Pipeline([('pre', pre_leak),
('clf', LogisticRegression(class_weight='balanced', max_iter=1000))])
proba_leak = cross_val_predict(leak_model, X_leak, y, cv=cv, method='predict_proba')[:, 1]
print(f'Z wyciekiem (Points): ROC-AUC = {roc_auc_score(y, proba_leak):.3f} <- idealne')
Z wyciekiem (Points): ROC-AUC = 1.000 <- idealne
Wynik wygląda idealnie - ROC-AUC bliskie 1.0.
Liczba punktów (Points) jest przyznawana za wynik wyścigu - kto zdobył ich dużo, ten z definicji był wysoko. Model nie tyle przewiduje podium, co odczytuje je z niemal gotowej odpowiedzi. Podobnie jak w poprzednim notebooku - pozostajemy tylko przy cechach przedwyścigowych.
Model główny - regresja logistyczna¶
Wracamy do uczciwych cech. Parametr class_weight='balanced' kompensuje przewagę liczebną klasy "nie-podium", a ocenę prowadzimy na predykcjach out-of-fold.
pre = ColumnTransformer([
('cat', OneHotEncoder(handle_unknown='ignore', sparse_output=False), cat_cols),
('num', StandardScaler(), num_cols)])
logit = Pipeline([('pre', pre),
('clf', LogisticRegression(class_weight='balanced', max_iter=1000))])
proba_logit = cross_val_predict(logit, X, y, cv=cv, method='predict_proba')[:, 1]
pred_logit = (proba_logit >= 0.5).astype(int)
print('Regresja logistyczna (cechy przedwyścigowe):')
print(f' ROC-AUC = {roc_auc_score(y, proba_logit):.3f} PR-AUC = {average_precision_score(y, proba_logit):.3f}')
print(classification_report(y, pred_logit, target_names=['nie podium', 'podium']))
Regresja logistyczna (cechy przedwyścigowe):
ROC-AUC = 0.921 PR-AUC = 0.703
precision recall f1-score support
nie podium 0.98 0.80 0.88 780
podium 0.44 0.89 0.59 138
accuracy 0.81 918
macro avg 0.71 0.85 0.73 918
weighted avg 0.90 0.81 0.84 918
ROC-AUC = 0.921 oraz PR-AUC = 0.703 - co to znaczy?
recall (czułość) dla klasy podium wynosi 0.89 - spośród wszystkich rzeczywistych podiów model wyłapuje niemal dziewięć na dziesięć.
precision (precyzja) wynosi jednak tylko 0.44 - gdy model orzeka "podium", ma rację w niespełna połowie przypadków.
recall oznacza, jaką część prawdziwych podiów udało się złapać, a precision - jaka część naszych typów na podium okazała się trafna. f1-score = 0.59 to średnia harmoniczna obu miar, czyli jeden wskaźnik godzący czułość z precyzją.
class_weight='balanced' sprawia, że model chętnie typuje podium, byle nie przeoczyć prawdziwego (wysoki recall), a płaci za to częstymi fałszywymi alarmami (niski precision).
Dla klasy nie-podium proporcje są odwrotne i korzystne - precision = 0.98, recall = 0.80. Zwróćmy uwagę, że ogólna accuracy = 0.81 jest tu nawet niższa niż ~85% modelu "zawsze nie" z pierwszej pułapki.
Mimo to ten model jest bez porównania użyteczniejszy. Model "zawsze nie" ma wprawdzie 85% trafności, ale jego recall podium to 0 - nie wykrywa ani jednego podium, więc jest całkowicie bezużyteczny. Nasz model wyłapuje 89% podiów, czyli realnie odpowiada na pytanie "kto ma szansę na podium", płacąc za to fałszywymi alarmami. accuracy myli właśnie dlatego, że nagradza bierność wobec licznej klasy nie-podium: bezczynne "zawsze nie" wypada według niej lepiej niż model, który faktycznie coś przewiduje.
Jak rozumieć ROC-AUC i PR-AUC?¶
Aby zrozumieć ROC-AUC, wybierzmy losowo jednego kierowcę, który był na podium, i jednego, który nie był.
ROC-AUC to prawdopodobieństwo, że model przypisał wyższą szansę temu właściwemu. Sprawdźmy:
pod = proba_logit[y == 1] # prawdopodobieństwa dla prawdziwych podiów
nie = proba_logit[y == 0] # prawdopodobieństwa dla nie-podiów
roznice = pod[:, None] - nie[None, :] # każda para (podium, nie-podium)
auc_z_par = (roznice > 0).mean() + 0.5 * (roznice == 0).mean()
print(f'par (podium x nie-podium): {roznice.size}')
print(f'model dał wyższą szansę podium w: {auc_z_par:.1%} par')
print(f'ROC-AUC ze sklearn: {roc_auc_score(y, proba_logit):.3f} (to samo)')
par (podium x nie-podium): 107640 model dał wyższą szansę podium w: 92.1% par ROC-AUC ze sklearn: 0.921 (to samo)
Czyli ROC-AUC = 0.5 oznaczałoby rzut monetą (model nie odróżnia podium od reszty), a 1.0 - model idealny. Nasze ~0.92 znaczy, że w ponad dziewięciu na dziesięć przypadków model poprawnie wskazuje, który z dwóch kierowców stanął na podium.
A PR-AUC? To pole pod krzywą precyzja-czułość (precision-recall). Mierzy, jak dobrze model wykrywa rzadką klasę - tutaj podium - przy różnych progach odcięcia: dla każdego progu odczytujemy parę (precyzja, czułość), a PR-AUC zbiera je w jedną liczbę. Punktem odniesienia jest dla niej sam odsetek podiów (~0.15) - tyle wyniosłaby precyzja losowego zgadywania - więc nasze ~0.70 to wynik znacznie powyżej poziomu losowego.
W odróżnieniu od ROC-AUC, PR-AUC nie nagradza modelu za łatwe trafianie licznej klasy nie-podium: skupia się wyłącznie na tym, jak celnie i kompletnie model wyłapuje nieliczne podia. Dlatego przy niezbalansowanych klasach jest surowszą i bardziej wymagającą miarą niż ROC-AUC.
Benchmark - porównanie klasyfikatorów¶
Porównujemy kilka algorytmów na tych samych danych i przy tej samej walidacji. Notujemy ROC-AUC, PR-AUC oraz czas pełnej walidacji krzyżowej. DummyClassifier, przewidujący zawsze klasę większościową, jest punktem odniesienia.
def make(est, scale=True):
steps = [('cat', OneHotEncoder(handle_unknown='ignore', sparse_output=False), cat_cols)]
steps.append(('num', StandardScaler() if scale else 'passthrough', num_cols))
return Pipeline([('pre', ColumnTransformer(steps)), ('clf', est)])
models = {
'Dummy (większość)': make(DummyClassifier(strategy='most_frequent')),
'Logistic Regression': make(LogisticRegression(class_weight='balanced', max_iter=1000)),
'kNN (k=15)': make(KNeighborsClassifier(n_neighbors=15)),
'Random Forest': make(RandomForestClassifier(n_estimators=300, class_weight='balanced',
n_jobs=-1, random_state=42), scale=False),
'HistGradientBoosting': make(HistGradientBoostingClassifier(
learning_rate=0.05, max_iter=200, l2_regularization=1.0,
class_weight='balanced', random_state=42), scale=False),
}
rows = []
for name, model in models.items():
t = time.perf_counter()
try:
proba = cross_val_predict(model, X, y, cv=cv, method='predict_proba')[:, 1]
auc = roc_auc_score(y, proba); ap = average_precision_score(y, proba)
except Exception:
pred = cross_val_predict(model, X, y, cv=cv)
auc = roc_auc_score(y, pred); ap = average_precision_score(y, pred)
dt = time.perf_counter() - t
rows.append(dict(model=name, ROC_AUC=auc, PR_AUC=ap, cv_time_s=dt))
print(f'{name:<22} ROC-AUC={auc:.3f} PR-AUC={ap:.3f} czas_CV={dt:.2f}s')
bench = pd.DataFrame(rows).set_index('model').round(3)
bench
Dummy (większość) ROC-AUC=0.500 PR-AUC=0.150 czas_CV=0.03s
Logistic Regression ROC-AUC=0.921 PR-AUC=0.703 czas_CV=0.05s kNN (k=15) ROC-AUC=0.918 PR-AUC=0.666 czas_CV=0.04s
Random Forest ROC-AUC=0.907 PR-AUC=0.607 czas_CV=1.72s
HistGradientBoosting ROC-AUC=0.912 PR-AUC=0.643 czas_CV=0.82s
| ROC_AUC | PR_AUC | cv_time_s | |
|---|---|---|---|
| model | |||
| Dummy (większość) | 0.500 | 0.150 | 0.032 |
| Logistic Regression | 0.921 | 0.703 | 0.048 |
| kNN (k=15) | 0.918 | 0.666 | 0.043 |
| Random Forest | 0.907 | 0.607 | 1.723 |
| HistGradientBoosting | 0.912 | 0.643 | 0.816 |
Tabela zestawia wszystkie klasyfikatory na tych samych danych i przy tej samej walidacji krzyżowej. Każdy wiersz to jeden model, a kolumny to ROC-AUC, PR-AUC oraz cv_time_s (czas pełnej 5-krotnej walidacji w sekundach).
Punktem odniesienia jest Dummy (większość). Jego ROC-AUC = 0.500 to poziom losowy - rzut monetą. To model, który w ogóle nie odróżnia podium od reszty. Z kolei PR-AUC = 0.150 to odsetek podiów w danych.
Użyteczny model powinien oba te progi przebić - i wszystkie pozostałe to robią, z ROC-AUC w przedziale od 0.907 do 0.921. Różnice między modelami omawiamy szczegółowo pod wykresem.
b = bench.reset_index()
fig = px.scatter(b, x='cv_time_s', y='ROC_AUC', text='model', color='model',
title='ROC-AUC (wyżej=lepiej) vs czas walidacji',
labels={'cv_time_s': 'czas 5-fold CV [s]', 'ROC_AUC': 'ROC-AUC'})
fig.update_traces(textposition='top center', marker_size=12)
fig.update_layout(showlegend=False)
fig.show()
Przy małym zbiorze (dwa sezony, raptem 138 podiów) prosta regresja logistyczna wypada co najmniej tak dobrze jak modele drzewiaste - a przy tym jest szybsza i łatwiejsza do interpretacji.
Diagnostyka modelu głównego¶
Przyglądamy się trzem rzeczom: macierzy pomyłek, krzywym ROC oraz Precision-Recall, a na końcu współczynnikom modelu - by zobaczyć, które cechy najmocniej wpływają na szansę na podium.
cm = confusion_matrix(y, pred_logit)
fig = px.imshow(cm, text_auto=True, color_continuous_scale='Blues',
x=['pred: nie', 'pred: podium'],
y=['rzecz.: nie', 'rzecz.: podium'],
title='Macierz pomyłek - regresja logistyczna')
fig.update_layout(height=420, width=520)
fig.show()
Macierz pomyłek rozkłada wszystkie 918 predykcji na cztery pola. Wiersze to stan rzeczywisty, kolumny to predykcja modelu. Jeśli traktujemy podium jako klasę "pozytywną", pola noszą standardowe nazwy:
- TN (true negative, prawidłowe nie-podium):
624- model powiedział "nie" i miał rację, - FP (false positive, fałszywy alarm):
156- model typował podium, którego nie było, - FN (false negative, przeoczenie):
15- prawdziwe podium, które model przegapił, - TP (true positive, trafione podium):
123- model wskazał podium i było podium.
Na podstawie wykresu możemy wyliczyć liczby z powyższego raportu. recall podium to TP / (TP + FN) = 123 / 138 = 0.89 - spośród 138 faktycznych podiów wymknęło się tylko 15. precision podium to TP / (TP + FP) = 123 / 279 = 0.44 - obok 123 trafień stoi aż 156 fałszywych alarmów. Model jest więc czujny, ale nadgorliwy: prawie nie przeocza podium, lecz po drodze zapowiada wiele podiów, które się nie ziszczą.
fpr, tpr, _ = roc_curve(y, proba_logit)
prec, rec, _ = precision_recall_curve(y, proba_logit)
fig = go.Figure()
fig.add_trace(go.Scatter(x=fpr, y=tpr, name='ROC (logit)', mode='lines'))
fig.add_trace(go.Scatter(x=[0, 1], y=[0, 1], line=dict(dash='dash'), name='losowy'))
fig.update_layout(title='Krzywa ROC', xaxis_title='FPR (1 - swoistość)', yaxis_title='TPR (czułość)')
fig.show()
fig2 = px.area(x=rec, y=prec, title='Krzywa Precision-Recall',
labels={'x': 'Recall (czułość)', 'y': 'Precision'})
fig2.add_hline(y=y.mean(), line_dash='dash', annotation_text='baza (odsetek podiów)')
fig2.show()
Powyższe wykresy pokazują, jak model zachowuje się przy każdym możliwym progu odcięcia, a nie tylko przy domyślnym 0.5.
Krzywa ROC zestawia czułość (TPR, oś pionowa - jaka część prawdziwych podiów zostaje wyłapana) z odsetkiem fałszywych alarmów (FPR, oś pozioma - jaka część nie-podiów zostaje błędnie oznaczona jako podium). Przerywana przekątna to model losowy: na niej każdy zysk w czułości jest okupiony równym wzrostem fałszywych alarmów. Im mocniej krzywa wygina się w stronę lewego górnego rogu, tym lepiej - zyskujemy dużo prawdziwych podiów małym kosztem. Pole pod tą krzywą to ROC-AUC = 0.921.
Krzywa Precision-Recall pokazuje, jak precision (precyzja typów na podium) zmienia się wraz z recall (czułością) przy przesuwaniu progu. Tutaj punktem odniesienia jest pozioma linia przerywana na wysokości odsetka podiów (~0.15) - tyle wyniosłaby precyzja losowego zgadywania. Nasza krzywa leży znacznie powyżej niej, a pole pod nią to PR-AUC = 0.703.
logit.fit(X, y)
ohe = logit.named_steps['pre'].named_transformers_['cat']
names = list(ohe.get_feature_names_out(cat_cols)) + num_cols
coefs = logit.named_steps['clf'].coef_[0]
imp = pd.DataFrame({'cecha': names, 'współczynnik': coefs}).sort_values('współczynnik')
fig = px.bar(imp, x='współczynnik', y='cecha', orientation='h',
title='Współczynniki regresji logistycznej (log-szanse)')
fig.update_layout(height=520)
fig.show()
print('Dodatni współczynnik = zwiększa szansę na podium. Ujemny GridPosition = im dalej z tyłu, tym gorzej.')
Dodatni współczynnik = zwiększa szansę na podium. Ujemny GridPosition = im dalej z tyłu, tym gorzej.
To najważniejszy wykres dla zrozumienia, czym kieruje się model. Każdy słupek to jeden współczynnik regresji logistycznej, wyrażony w logarytmie szansy na podium. Model ma 14 cech (dwanaście zespołów po one-hot oraz GridPosition i quali_gap_s) i na wykresie widać je teraz wszystkie.
Interpretacja: współczynnik dodatni podnosi szansę na podium, ujemny ją obniża, a im słupek dłuższy, tym silniejszy wpływ cechy.
Najbardziej ujemny współczynnik wśród cech liczbowych ma GridPosition (-1.88).
Zgodnie z intuicją - im dalej z tyłu startuje kierowca, tym mniejsza szansa na podium. Po stronie zespołów obraz jest równie czytelny i to one dają najsilniejsze wychylenia.
Zdecydowanie dominuje Red Bull Racing ze współczynnikiem +2.42 - najsilniejszy dodatni sygnał w całym modelu, odzwierciedlający przewagę tego auta w sezonach 2023-24. Dodatnie współczynniki mają też pozostałe czołowe ekipy: McLaren (+1.25), Ferrari (+1.13) i Mercedes (+0.71). Po przeciwnej stronie skupiają się zespoły z końca stawki: Haas (-1.43), Williams (-1.41) i RB (-1.24), których słupki są wyraźnie ujemne. Całe uszeregowanie współczynników zespołowych odzwierciedla realny układ sił w stawce.
quali_gap_s ma współczynnik dodatni i niewielki (+0.37), choć intuicyjnie większa strata do najszybszego okrążenia powinna szansę raczej obniżać.
To skutek silnej korelacji z GridPosition - kwalifikacje wprost ustalają pole startowe, więc duża część tego sygnału jest już zawarta w pozycji, a quali_gap_s dokłada jedynie słaby, niestabilny naddatek.
O szansie na podium decyduje przede wszystkim pozycja startowa, wzmocniona klasą zespołu.
Korelacja quali_gap_s z GridPosition¶
Ciekawym elementem wykresu jest quali_gap_s ze współczynnikiem dodatnim i dość niewielkim (+0.37). Zdawałoby się, że strata do najlepszego okrążenia powinna szansę obniżać.
Powodem może być korelacja z GridPosition. W końcu kwalifikacje ustalają pole startowe - należy sprawdzić, czy sygnał nie jest już zawarty w pozycji.
from scipy.stats import pearsonr, spearmanr
r_pear = pearsonr(dr['GridPosition'], dr['quali_gap_s'])[0]
r_spear = spearmanr(dr['GridPosition'], dr['quali_gap_s'])[0]
print(f'korelacja Pearsona (liniowa): {r_pear:.3f}')
print(f'korelacja Spearmana (monotoniczna): {r_spear:.3f}')
fig = px.scatter(dr, x='GridPosition', y='quali_gap_s', opacity=0.35,
title='Strata w kwalifikacjach a pozycja startowa',
labels={'GridPosition': 'pozycja startowa',
'quali_gap_s': 'strata do najszybszego okrążenia [s]'})
med = dr.groupby('GridPosition')['quali_gap_s'].median().reset_index()
fig.add_scatter(x=med['GridPosition'], y=med['quali_gap_s'], mode='lines+markers',
name='mediana', line=dict(color='red'))
# przycinamy oś Y - ~4% skrajnych (kierowcy bez czystego czasu w kwalifikacjach) psuje czytelność
fig.update_yaxes(range=[-0.5, 6])
fig.show()
korelacja Pearsona (liniowa): 0.440 korelacja Spearmana (monotoniczna): 0.853
Korelacja Spearmana wynosi 0.85 - silna zależność: im dalsza pozycja startowa, tym większa strata w kwalifikacjach. Jest to zgodne z intuicją.
Korelacja Pearsona jest jednak znacznie niższa (0.44), bo zależność nie jest liniowa. Dodatkowo zaburzają ją wartości skrajne (kilka procent kierowców, którzy nie postawili czystego czasu w kwalifikacjach i mają stratę kilkunastu sekund - dla czytelności przycięliśmy oś pionową). Czerwona linia mediany rośnie coraz stromiej: kierowców z pierwszych rzędów dzieli zaledwie kilkanaście setnych sekundy (mediana straty dla P1-P3 to ~0.15 s), ale w środku i na końcu stawki różnice gwałtownie rosną (P4-P10 to ~0.68 s, a P11-P20 aż ~1.63 s).
Z tego względu trudno jest modelowi przypisać stabilny znak quali_gap_s. Niemal cała informacja zawarta w tej cesze jest już zawarta w GridPosition.
Czy więcej sezonów pomoże?¶
To samo pytanie zadaliśmy przy regresji - i warto je tu powtórzyć, bo sytuacja jest inna. Klasyfikacja ma do dyspozycji znacznie mniej danych: nie dziesiątki tysięcy okrążeń, lecz kilkaset startów kierowców, z czego podia to tylko ~15%. Tutaj więcej sezonów oznacza więcej podiów - a tego właśnie modelowi brakuje. Sprawdźmy, czy to pomaga.
Korzystamy z szerszego zbioru driver_race_all (sezony 2018-2024) i liczymy jakość dla rosnącej liczby sezonów, dokładanych od najnowszego wstecz. Zbiór powstał według tej samej logiki i z tymi samymi filtrami co driver_race w notebooku 01, tylko dla siedmiu sezonów - tak jak laps_all w notebooku 03:
dr_all = pd.read_parquet('../data/driver_race_all.parquet').dropna(subset=['GridPosition'])
seasons_all = sorted(dr_all['Season'].unique())
print('dostępne sezony:', seasons_all, '| wierszy:', len(dr_all),
'| podiów:', int(dr_all.podium.sum()))
def ocena_clf(df, use_team=True):
df = df.copy()
cat = ['Team'] if use_team else []
num = ['GridPosition']
if df['quali_gap_s'].notna().sum() > 0.5 * len(df):
num.append('quali_gap_s')
df['quali_gap_s'] = df['quali_gap_s'].fillna(df['quali_gap_s'].median())
Xx, yy = df[cat + num], df['podium'].values
steps = ([('cat', OneHotEncoder(handle_unknown='ignore', sparse_output=False), cat)]
if cat else [])
steps.append(('num', StandardScaler(), num))
m = Pipeline([('pre', ColumnTransformer(steps)),
('clf', LogisticRegression(class_weight='balanced', max_iter=1000))])
cv5 = StratifiedKFold(5, shuffle=True, random_state=42)
pr = cross_val_predict(m, Xx, yy, cv=cv5, method='predict_proba')[:, 1]
return roc_auc_score(yy, pr), average_precision_score(yy, pr)
rows = []
for k in range(1, len(seasons_all) + 1):
chosen = seasons_all[-k:]
sub = dr_all[dr_all['Season'].isin(chosen)]
auc, ap = ocena_clf(sub)
rows.append({'liczba_sezonów': k, 'zakres': f'{chosen[0]}-{chosen[-1]}',
'podia': int(sub.podium.sum()), 'ROC_AUC': auc, 'PR_AUC': ap})
wynik_clf = pd.DataFrame(rows)
wynik_clf.round(3)
dostępne sezony: [np.int64(2018), np.int64(2019), np.int64(2020), np.int64(2021), np.int64(2022), np.int64(2023), np.int64(2024)] | wierszy: 2956 | podiów: 444
| liczba_sezonów | zakres | podia | ROC_AUC | PR_AUC | |
|---|---|---|---|---|---|
| 0 | 1 | 2024-2024 | 72 | 0.929 | 0.722 |
| 1 | 2 | 2023-2024 | 138 | 0.921 | 0.703 |
| 2 | 3 | 2022-2024 | 204 | 0.927 | 0.696 |
| 3 | 4 | 2021-2024 | 270 | 0.923 | 0.679 |
| 4 | 5 | 2020-2024 | 321 | 0.921 | 0.679 |
| 5 | 6 | 2019-2024 | 384 | 0.923 | 0.687 |
| 6 | 7 | 2018-2024 | 444 | 0.924 | 0.681 |
fig = px.line(wynik_clf, x='liczba_sezonów', y=['ROC_AUC', 'PR_AUC'], markers=True,
title='Jakość klasyfikacji a liczba sezonów',
labels={'liczba_sezonów': 'liczba sezonów', 'value': 'wartość metryki',
'variable': 'metryka'})
fig.update_yaxes(range=[0, 1])
fig.show()
Mimo że liczba podiów rośnie wraz z sezonami, jakość praktycznie stoi w miejscu - ROC-AUC trzyma się okolic 0.92, a PR-AUC ~0.70. Co ciekawe, podobnie jak w regresji, dodawanie danych nie podnosi wyniku. Przyczyna jest tu jednak inna, co najlepiej widać, gdy porównamy ery techniczne (przed zmianą regulaminu w 2022 i po niej) oraz sprawdzimy rolę zespołu:
print('era vs era vs zmieszane:')
for nazwa, df in [('era >=2022', dr_all[dr_all.Season >= 2022]),
('era <2022 ', dr_all[dr_all.Season < 2022]),
('zmieszane ', dr_all)]:
auc, ap = ocena_clf(df)
print(f' {nazwa} podiów={int(df.podium.sum()):>3} ROC-AUC={auc:.3f} PR-AUC={ap:.3f}')
print('\nrola cechy Team:')
for nazwa, ut in [('z Team ', True), ('bez Team', False)]:
auc, ap = ocena_clf(dr_all, use_team=ut)
print(f' {nazwa} ROC-AUC={auc:.3f} PR-AUC={ap:.3f}')
era vs era vs zmieszane: era >=2022 podiów=204 ROC-AUC=0.927 PR-AUC=0.696 era <2022 podiów=240 ROC-AUC=0.921 PR-AUC=0.699 zmieszane podiów=444 ROC-AUC=0.924 PR-AUC=0.681 rola cechy Team:
z Team ROC-AUC=0.924 PR-AUC=0.681 bez Team ROC-AUC=0.916 PR-AUC=0.661
W regresji mieszanie er wyraźnie szkodziło, bo cecha Team była drugą co do ważności, a między erami niespójną ("Mercedes 2019" to inne tempo niż "Mercedes 2024"). W klasyfikacji jest inaczej:
- mieszanie er nie szkodzi - wynik na wszystkich sezonach jest taki sam jak dla każdej ery z osobna,
- usunięcie
Teamprawie nic nie zmienia - model opiera się głównie naGridPositioniquali_gap_s.
A te dwie cechy są ponadczasowe: reguła "kto startuje z przodu, ma szansę na podium" działa tak samo w 2018 i w 2024, niezależnie od regulaminu. Dlatego klasyfikacja jest odporna na zmianę ery, podczas gdy regresja na niej traciła.
W regresji sufit brał się z braku cech opisujących tempo. Tutaj ROC-AUC ~0.92 to już bardzo wysoki poziom, a granicę wyznacza nieprzewidywalność samego wyścigu i jego okoliczności.
Zapis predykcji¶
Predykcje modelu głównego (balanced) oraz tabelę benchmarku zapisujemy do plików parquet - skorzysta z nich notebook 05 (analiza błędów). Dwie kolejne sekcje to dodatek: eksperymenty z wagami klas i kalibracją na tym samym modelu.
out = dr.copy()
out['proba_logit'] = proba_logit
out['pred_logit'] = pred_logit
out.to_parquet('../data/clf_predictions.parquet', index=False)
bench.to_parquet('../data/clf_benchmark.parquet')
print('zapisano clf_predictions.parquet', out.shape, 'oraz clf_benchmark.parquet')
zapisano clf_predictions.parquet (918, 14) oraz clf_benchmark.parquet
Dobór wag klas i kalibracja prawdopodobieństw¶
W całym notebooku wielokrotnie sięgaliśmy po class_weight='balanced', traktując to ustawienie jak rzecz oczywistą - ani razu nie sprawdziliśmy, czy alternatywy nie zachowałyby się lepiej. W tej sekcji postaramy się to zbadać.
Nasz model główny - regresja logistyczna na cechach Team, GridPosition i quali_gap_s, oceniana out-of-fold na StratifiedKFold - osiąga
ROC-AUC = 0.921 oraz PR-AUC = 0.703 i to jest punkt odniesienia, względem którego będziemy mierzyć każdą zmianę.
Odpowiemy na dwa pytania:
- Jak różne ustawienia wag klas wpływają na model? Porównamy brak wag,
class_weight='balanced'oraz ręcznie dobrane wagi. - Czy prawdopodobieństwa modelu da się czytać dosłownie? Wagi klas podbijają prawdopodobieństwa klasy mniejszościowej, więc można podejrzewać, że model z
class_weight='balanced'zawyża szanse na podium. Sprawdzimy, jak bardzo - i czy da się to naprawić kalibracją.
# Porównujemy różne ustawienia `class_weight` na tej samej walidacji out-of-fold.
# `None` - brak wag (klasy traktowane równo), `'balanced'` - wagi odwrotne do liczebności,
# oraz ręczne wagi {0:1, 1:k}, które k-krotnie mocniej karzą przeoczenie podium.
from sklearn.metrics import (roc_auc_score, average_precision_score, confusion_matrix,
precision_score, recall_score, f1_score)
def pre_cw():
# świeży preprocesor dla każdego wariantu (one-hot dla zespołu, skalowanie cech liczbowych)
return ColumnTransformer([
('cat', OneHotEncoder(handle_unknown='ignore', sparse_output=False), cat_cols),
('num', StandardScaler(), num_cols)])
# warianty wag klasy: brak, automatyczne 'balanced' oraz trzy ręczne nasilenia
warianty = {
'None (brak wag)': None,
"'balanced'": 'balanced',
'{0:1, 1:3}': {0: 1, 1: 3},
'{0:1, 1:5}': {0: 1, 1: 5},
'{0:1, 1:10}': {0: 1, 1: 10},
}
wiersze = []
for nazwa, cw in warianty.items():
model = Pipeline([('pre', pre_cw()),
('clf', LogisticRegression(class_weight=cw, max_iter=1000))])
# predykcje out-of-fold - każdy wiersz oceniany przez model, który go nie widział
proba = cross_val_predict(model, X, y, cv=cv, method='predict_proba')[:, 1]
pred = (proba >= 0.5).astype(int) # decyzja przy domyślnym progu 0.5
tn, fp, fn, tp = confusion_matrix(y, pred).ravel()
wiersze.append({
'class_weight': nazwa,
'ROC_AUC': roc_auc_score(y, proba),
'PR_AUC': average_precision_score(y, proba),
'precision': precision_score(y, pred, zero_division=0),
'recall': recall_score(y, pred, zero_division=0),
'F1': f1_score(y, pred, zero_division=0),
'TP': tp, 'FP': fp, 'FN': fn, 'TN': tn,
})
tabela_cw = pd.DataFrame(wiersze).set_index('class_weight').round(3)
print('Porównanie opcji class_weight (próg 0.5, predykcje out-of-fold):')
tabela_cw
Porównanie opcji class_weight (próg 0.5, predykcje out-of-fold):
| ROC_AUC | PR_AUC | precision | recall | F1 | TP | FP | FN | TN | |
|---|---|---|---|---|---|---|---|---|---|
| class_weight | |||||||||
| None (brak wag) | 0.920 | 0.713 | 0.706 | 0.522 | 0.600 | 72 | 30 | 66 | 750 |
| 'balanced' | 0.921 | 0.703 | 0.441 | 0.891 | 0.590 | 123 | 156 | 15 | 624 |
| {0:1, 1:3} | 0.922 | 0.708 | 0.534 | 0.804 | 0.642 | 111 | 97 | 27 | 683 |
| {0:1, 1:5} | 0.922 | 0.703 | 0.454 | 0.891 | 0.601 | 123 | 148 | 15 | 632 |
| {0:1, 1:10} | 0.921 | 0.698 | 0.389 | 0.942 | 0.551 | 130 | 204 | 8 | 576 |
Niezależnie od wag klas ROC-AUC mieści się w przedziale 0.920-0.922, a PR-AUC - w przedziale 0.698-0.713.
ROC-AUC i PR-AUC mierzą porządkowanie - to, jak dobrze model układa kierowców od najmniejszej do największej szansy na podium.
Są liczone z całego zakresu prawdopodobieństw, więc nie zależą od progu 0.5, a od wag klas zależą tylko śladowo: wagi zmieniają dopasowane współczynniki, ale - jak widać w tabeli - kolejność kierowców prawie się nie rusza.
class_weight przesuwa wszystkie prawdopodobieństwa w górę lub w dół, ale kolejność pozostaje praktycznie taka sama.
Co się zmienia? Kompromis precyzja-czułość przy progu 0.5:
- brak wag (
None):precision = 0.71, alerecall = 0.52. Model typuje podium oszczędnie i pewnie - na102typy ma72trafienia (TP) i tylko30fałszywych alarmów (FP) - lecz przeocza aż66z138prawdziwych podiów (FN). 'balanced':precision = 0.44,recall = 0.89. Model trafnie wskazuje123podia, przegapia ledwie15, ale kosztem156fałszywych alarmów.- mocniejsza waga
{0:1, 1:10}:recall = 0.94(przeoczone już tylko8podiów), zprecision = 0.39i aż204fałszywych alarmów.
Najwyższe F1 = 0.642 daje umiarkowany wariant {0:1, 1:3} - kompromis między obiema skrajnościami.
Im mocniej ważymy klasę podium, tym chętniej model je typuje: recall rośnie, precision spada, a TP i FP rosną razem.
Skoro ROC-AUC i PR-AUC są niemal stałe, to zdolność modelu do odróżniania podiów jest cały czas taka sama. O wyborze wag decyduje to, czego chcemy.
Kalibracja prawdopodobieństw¶
Wysoki ROC-AUC mówi, że model dobrze porządkuje kierowców - ale nie mówi nic o tym, czy jego prawdopodobieństwa są wiarygodne. Podejrzenie jest konkretne: ważenie klas, które ratuje recall, zarazem podbija wszystkie predykcje, więc model z class_weight='balanced' powinien systematycznie przeszacowywać szansę na podium.
Miarą tego przeszacowania jest ECE (Expected Calibration Error): dzielimy predykcje na przedziały prawdopodobieństwa i uśredniamy - z wagą równą liczności przedziału - różnicę między deklarowanym prawdopodobieństwem a faktycznym odsetkiem podiów.
Poniżej liczymy ECE wprost, na predykcjach out-of-fold, dla czterech wariantów: surowego modelu balanced, zwykłego logitu bez wag oraz dwóch technik kalibracji - skalowania Platta (sigmoid) i regresji izotonicznej (dowolne niemalejące przekształcenie). Zobaczymy też, jakim kosztem: czy poprawa kalibracji nie psuje rankingu mierzonego przez ROC-AUC i PR-AUC.
# Eksperyment: kalibracja prawdopodobieństw
# Wysoki ROC-AUC stwierdza, czy model dobrze *porządkuje* kierowców, ale NIE czy jego
# prawdopodobieństwa są wiarygodne.
# Mierzymy ECE: dzielimy prognozy na 10 koszyków i sprawdzamy, jak bardzo średnia deklarowana szansa
# odbiega od rzeczywistego odsetka podiów w koszyku. Im niżej, tym lepiej.
from sklearn.calibration import CalibratedClassifierCV
def ece(p, y, bins=10):
krawedzie = np.linspace(0, 1, bins + 1)
e = 0.0
for i in range(bins):
maska = (p >= krawedzie[i]) & (p < krawedzie[i + 1])
if maska.sum():
e += maska.sum() * abs(y[maska].mean() - p[maska].mean())
return e / len(y)
def baza(balanced):
pre_k = ColumnTransformer([
('cat', OneHotEncoder(handle_unknown='ignore', sparse_output=False), cat_cols),
('num', StandardScaler(), num_cols)])
return Pipeline([('pre', pre_k),
('clf', LogisticRegression(max_iter=1000,
class_weight='balanced' if balanced else None))])
warianty = {
'(a) balanced (baseline)': baza(True),
'(b) zwykły logit': baza(False),
'(c) balanced + Platt': CalibratedClassifierCV(baza(True), method='sigmoid', cv=5),
'(d) balanced + isotonic': CalibratedClassifierCV(baza(True), method='isotonic', cv=5),
}
wiersze = []
for nazwa, est in warianty.items():
p = cross_val_predict(est, X, y, cv=cv, method='predict_proba')[:, 1]
wiersze.append(dict(wariant=nazwa,
ROC_AUC=roc_auc_score(y, p),
PR_AUC=average_precision_score(y, p),
ECE=ece(p, y)))
kalib = pd.DataFrame(wiersze).set_index('wariant').round(4)
print(kalib)
kalib
ROC_AUC PR_AUC ECE wariant (a) balanced (baseline) 0.9213 0.7034 0.1535 (b) zwykły logit 0.9196 0.7128 0.0168 (c) balanced + Platt 0.9216 0.6955 0.0229 (d) balanced + isotonic 0.9198 0.6742 0.0201
| ROC_AUC | PR_AUC | ECE | |
|---|---|---|---|
| wariant | |||
| (a) balanced (baseline) | 0.9213 | 0.7034 | 0.1535 |
| (b) zwykły logit | 0.9196 | 0.7128 | 0.0168 |
| (c) balanced + Platt | 0.9216 | 0.6955 | 0.0229 |
| (d) balanced + isotonic | 0.9198 | 0.6742 | 0.0201 |
Czy kalibracja jest potrzebna?¶
| wariant | ECE |
ROC-AUC |
PR-AUC |
|---|---|---|---|
balanced (surowy) |
0.1535 |
0.921 |
0.703 |
zwykły logit (bez balanced) |
0.0168 |
0.920 |
0.713 |
balanced + Platt (sigmoid) |
0.0229 |
0.922 |
0.696 |
balanced + izotoniczna |
0.0201 |
0.920 |
0.674 |
Podejrzenie się potwierdza, a kalibracja działa. Surowy model z class_weight='balanced' ma ECE = 0.1535, czyli mocno przeszacowuje szanse na podium: przy progu 0.5 typuje 279 podiów wobec 138 realnych (tabela wag klas wyżej: TP + FP = 123 + 156), a jego deklarowane prawdopodobieństwa leżą wyraźnie powyżej realnego odsetka podiów (~15% danych). Krzywą kalibracji tego modelu obejrzymy koszyk po koszyku w notebooku 05. Zarówno skalowanie Platta, jak i regresja izotoniczna obniżają ECE do okolic 0.02.
Kalibracja nie narusza kolejności mierzonej przez ROC-AUC, ale ma swoją cenę. ROC-AUC trzyma się ~0.92 we wszystkich czterech wariantach, natomiast PR-AUC lekko spada: z 0.703 do 0.696 (Platt) i 0.674 (izotoniczna). Spadek przy Platcie mieści się w szumie - sigmoida jest ściśle monotoniczna, więc sama kolejności nie zmienia, a różnica bierze się z uśredniania pięciu modeli w CalibratedClassifierCV. Realny koszt ponosi regresja izotoniczna: dopasowuje funkcję schodkową, a każdy zbiór kalibracyjny ma tu ledwie ~20 podiów, więc schodki są szerokie - sąsiednie prawdopodobieństwa się sklejają i w rzadkiej klasie ginie część rozdzielczości rankingu.
Zwykła regresja logistyczna (bez balanced) jest skalibrowana sama z siebie (ECE=0.017). To znana własność logitu z wyrazem wolnym: średnie dopasowane prawdopodobieństwo równa się odsetkowi klasy pozytywnej w danych treningowych. Jeśli kluczowe są wiarygodne procenty, w tym przypadku rozsądniej wydaje się porzucić balanced.
Werdykt dla naszego przypadku¶
Celem jest szeregowanie kandydatów na podium - ustalenie, kto ma największe szanse, i porównanie kierowców między sobą. Do tego liczy się wyłącznie kolejność, a tę mierzy ROC-AUC, które kalibracja zostawia bez zmian.
Dlatego dla naszego zastosowania kalibracja jest opcjonalna - nie odczytujemy wyniku dosłownie jako ten kierowca ma 44% szansy, lecz jako ten jest wyżej w rankingu niż tamten.
Gdybyśmy chcieli podawać kibicowi realne prawdopodobieństwo podium albo wykorzystywać te liczby w dalszych obliczeniach (np. wartości oczekiwanej zakładu) - wtedy kalibracja miałaby sens i byłaby konieczna, bo surowy balanced wyraźnie zawyżałby szanse (jak pokażemy w notebooku 05 - średnio dwukrotnie).
Podsumowanie¶
- Sama trafność myli przy niezbalansowanych klasach - model "zawsze nie" osiąga
~85%, nie przewidując niczego. - Wyciek danych (cecha
Points) daje wynik pozornie idealny, lecz bezużyteczny w praktyce - dlatego trzymamy się wyłącznie cech znanych przed startem. - Przy tej ilości danych regresja logistyczna wygrywa z modelami drzewiastymi jednocześnie pod względem jakości, czasu i interpretowalności.
- Najsilniejszym sygnałem okazuje się pozycja startowa (
GridPosition) - co zgadza się z intuicją kibica F1;quali_gap_sniesie niemal tę samą informację co ona, aTeamdodaje niewiele (bez tej cechy model traci ledwie~0.01ROC-AUC). - Więcej sezonów nie pomaga:
ROC-AUCutrzymuje się na poziomie~0.92od jednego do siedmiu sezonów, a mieszanie er technicznych nie szkodzi, bo reguła "kto startuje z przodu, ma szansę na podium" jest ponadczasowa. - Wagi klas nie zmieniają zdolności modelu do porządkowania kierowców (
ROC-AUC0.920-0.922we wszystkich wariantach), tylko kompromis międzyprecisionirecallprzy progu0.5; najlepszeF1 = 0.642dał umiarkowany wariant{0:1, 1:3}. - Model
balancedprzeszacowuje szanse na podium (ECE = 0.1535); kalibracja Platta lub izotoniczna obniżaECEdo~0.02przy niezmienionymROC-AUC, kosztem lekkiego spadkuPR-AUC(zwłaszcza izotoniczna). Dla naszego celu - szeregowania kandydatów na podium - kalibracja jest opcjonalna.