Regresja: czas okrążenia¶
Ile trwa okrążenie w zależności od opon, paliwa i pogody?
Poniżej staramy się utworzyć model, który odpowie na to pytanie.
import time
import numpy as np
import pandas as pd
import plotly.express as px
from sklearn.compose import ColumnTransformer
from sklearn.preprocessing import OneHotEncoder, StandardScaler
from sklearn.impute import SimpleImputer
from sklearn.pipeline import Pipeline, make_pipeline
from sklearn.dummy import DummyRegressor
from sklearn.linear_model import LinearRegression, Ridge
from sklearn.neighbors import KNeighborsRegressor
from sklearn.ensemble import RandomForestRegressor, HistGradientBoostingRegressor
from sklearn.model_selection import GroupShuffleSplit
from sklearn.metrics import mean_absolute_error, mean_squared_error, r2_score
from sklearn.inspection import permutation_importance
laps = pd.read_parquet('../data/laps_clean.parquet')
laps['FreshTyre'] = laps['FreshTyre'].astype(int)
laps['circuit'] = laps['EventName']
print('laps:', laps.shape, '| sezony:', sorted(laps.Season.unique()),
'| torów:', laps.EventName.nunique(), '| wyścigów (race_id):', laps.race_id.nunique())
laps: (39647, 19) | sezony: [np.int64(2023), np.int64(2024)] | torów: 24 | wyścigów (race_id): 46
cat_cols = ['Compound', 'Team']
num_cols = ['TyreLife', 'FreshTyre', 'Stint', 'LapNumber',
'AirTemp', 'TrackTemp', 'Humidity', 'Pressure', 'WindSpeed', 'Rainfall']
def report(name, y_true, y_pred):
mae = mean_absolute_error(y_true, y_pred)
rmse = mean_squared_error(y_true, y_pred) ** 0.5
r2 = r2_score(y_true, y_pred)
print(f'{name:<28} MAE={mae:6.3f}s RMSE={rmse:6.3f}s R2={r2:7.3f}')
return dict(model=name, MAE=mae, RMSE=rmse, R2=r2)
def split_per_race(df):
gss = GroupShuffleSplit(n_splits=1, test_size=0.25, random_state=42)
return next(gss.split(df, groups=df['race_id'].values))
Każdy model będziemy oceniać trzema miarami - funkcja report liczy je wszystkie naraz. Warto je poznać od razu, bo padną już przy pierwszej próbie:
- MAE (Mean Absolute Error, średni błąd bezwzględny) - o ile sekund średnio model się myli. Najprostsza w odczycie:
MAE = 0.5 sznaczy "przeciętnie pomyłka rzędu pół sekundy". - RMSE (pierwiastek błędu średniokwadratowego) - podobna do MAE, ale mocniej karze duże pomyłki (podnosi błędy do kwadratu). Gdy RMSE jest wyraźnie większe od MAE, to znak, że zdarzają się pojedyncze duże wpadki.
- $R^2$ (współczynnik determinacji) - jaką część zmienności celu model wyjaśnia, w skali od
0do1. Tu kluczowa jest interpretacja punktów odniesienia: $R^2 = 1$ to model idealny, $R^2 = 0$ to tyle, ile dałoby zwykłe zgadywanie średniej (model nie wnosi nic), a $R^2$ ujemne oznacza model gorszy od zgadywania średniej. To ostatnie zaraz zobaczymy na własne oczy.
Pierwsza próba - czas bezwzględny na jednym sezonie¶
Zaczynamy najprościej. Pod uwagę bierzemy tylko sezon 2024, przewidujemy bezwzględny LapTime_s, a tor (circuit) dorzucamy jako kolejną cechę kategoryczną.
Dane dzielimy na wyścigi (GroupShuffleSplit po race_id) - tak, aby okrążenia jednego Grand Prix nie trafiły jednocześnie do treningu i testu. Inaczej model podejrzałby warunki danego wyścigu, a ocena byłaby zawyżona.
one = laps[laps['Season'] == 2024].reset_index(drop=True)
tr, te = split_per_race(one)
feat = ['circuit'] + cat_cols + num_cols
Xtr, Xte = one[feat].iloc[tr], one[feat].iloc[te]
ytr, yte = one['LapTime_s'].values[tr], one['LapTime_s'].values[te]
pre = ColumnTransformer([
('cat', OneHotEncoder(handle_unknown='ignore', sparse_output=False), ['circuit'] + cat_cols),
('num', SimpleImputer(strategy='median'), num_cols)])
lin = Pipeline([('pre', pre), ('reg', LinearRegression())]).fit(Xtr, ytr)
report('Akt 1: Linear (1 sezon)', yte, lin.predict(Xte))
Xtr_h, Xte_h = Xtr.copy(), Xte.copy()
for col in ['circuit'] + cat_cols:
Xtr_h[col] = Xtr_h[col].astype('category')
Xte_h[col] = Xte_h[col].astype('category')
mask = [col in (['circuit'] + cat_cols) for col in feat]
hgb = HistGradientBoostingRegressor(categorical_features=mask, random_state=42).fit(Xtr_h, ytr)
report('Akt 1: HistGB (1 sezon)', yte, hgb.predict(Xte_h))
Akt 1: Linear (1 sezon) MAE=12.249s RMSE=13.776s R2= -0.762
Akt 1: HistGB (1 sezon) MAE=12.016s RMSE=13.861s R2= -0.784
{'model': 'Akt 1: HistGB (1 sezon)',
'MAE': 12.01614779429148,
'RMSE': 13.861168193665701,
'R2': -0.7841030157067987}
Wynik jest rozczarowujący - $R^2$ jest ujemne. Oznacza to, że model jest gorszy niż przewidywanie stałej średniej.
Jak się okazuje - zarówno model liniowy, jak i gradient boosting zwracają niemal identyczne wyniki, pomimo znaczących różnic w mocy. Skoro moc modelu nic nie zmienia - problem musi leżeć w sposobie sformułowania zadania.
Dlaczego model zawiódł¶
Z notebooka 02 wynika, że sam tor odpowiada za ~99% zmienności czasu okrążenia. Dodatkowo - w jednym sezonie każdy tor pojawia się tylko raz!
Skoro dzielimy dane per wyścig, to tory ze zbioru testowego w ogóle nie występują w treningu!
Kodowanie one-hot zamienia wówczas nieznany tor na same zera. Model nie ma pojęcia, gdzie zmierza. Należy jednak to sprawdzić wprost:
Kodowanie one-hot. Modele liczą na liczbach, nie na tekście, a circuit czy Team to kategorie - nazwy.
One-hot zamienia każdą kategorię na osobną kolumnę przyjmującą wartość 0 lub 1: w danym wierszu jedynkę (1) ma tylko kolumna jego kategorii, a wszystkie pozostałe to zera.
Dzięki temu Monza i Monaco stają się dwiema niezależnymi kolumnami, które model może ważyć oddzielnie.
Kodowanie takich cech w formie 0, 1, 2, ... narzucałoby sztuczny porządek i odległości. Model mógłby uznać na przykład, że tor 3 jest większy albo bliższy torowi 2 niż 1.
tory_tr = set(one['circuit'].iloc[tr].unique())
tory_te = set(one['circuit'].iloc[te].unique())
print('1 SEZON:')
print(' torów w treningu:', len(tory_tr), '| w teście:', len(tory_te))
print(' tory testowe obecne też w treningu:', len(tory_te & tory_tr), ' <-- ZERO')
print(' rozrzut median czasu między torami:',
round(one.groupby('circuit')['LapTime_s'].median().std(), 1), 's')
print('\nModel dostaje tor, którego nie widział, i strzela średnią z innych torów -')
print('dlatego myli się o kilkanaście sekund (tyle wynosi rozrzut między torami).')
1 SEZON: torów w treningu: 18 | w teście: 6 tory testowe obecne też w treningu: 0 <-- ZERO rozrzut median czasu między torami: 10.3 s Model dostaje tor, którego nie widział, i strzela średnią z innych torów - dlatego myli się o kilkanaście sekund (tyle wynosi rozrzut między torami).
Pierwsza poprawka - więcej danych¶
Najtańsza naprawa nie dotyka modelu. Dokładamy drugi sezon (2023). Teraz każdy tor pojawia się zwykle dwa razy (raz na sezon).
Używamy dokładnie tego samego modelu i tej samej walidacji. Zmienia się jedynie ilość danych.
tr, te = split_per_race(laps)
feat = ['circuit'] + cat_cols + num_cols
Xtr, Xte = laps[feat].iloc[tr], laps[feat].iloc[te]
ytr, yte = laps['LapTime_s'].values[tr], laps['LapTime_s'].values[te]
tory_tr = set(laps['circuit'].iloc[tr].unique())
tory_te = set(laps['circuit'].iloc[te].unique())
print('2 SEZONY: tory testowe obecne też w treningu:',
len(tory_te & tory_tr), 'z', len(tory_te), ' <-- teraz niezerowe\n')
pre = ColumnTransformer([
('cat', OneHotEncoder(handle_unknown='ignore', sparse_output=False), ['circuit'] + cat_cols),
('num', SimpleImputer(strategy='median'), num_cols)])
lin = Pipeline([('pre', pre), ('reg', LinearRegression())]).fit(Xtr, ytr)
report('Akt 3: Linear (2 sezony)', yte, lin.predict(Xte))
2 SEZONY: tory testowe obecne też w treningu: 11 z 12 <-- teraz niezerowe Akt 3: Linear (2 sezony) MAE= 2.017s RMSE= 3.713s R2= 0.904
{'model': 'Akt 3: Linear (2 sezony)',
'MAE': 2.0169686276421195,
'RMSE': 3.7134827563886827,
'R2': 0.9039316983258943}
$R^2$ poprawiło się: z -0.76 do ~0.90.
Jednak tak wysokie $R^2$ jest nieco niepokojące - zważywszy na wpływ samego toru na dane.
Druga poprawka - zmiana celu¶
Wiemy już, że tor to ~99% zmienności. Skoro tak, to model osiągający $R^2=0.90$ nauczył się przede wszystkim, na którym torze jesteśmy.
W takim wypadku należałoby w jakiś sposób ograniczyć wpływ toru. Przenośne między torami może być tempo wewnątrz wyścigu, to znaczy: o ile dane okrążenie jest szybsze lub wolniejsze od mediany tego wyścigu, w zależności od opon, paliwa i pogody.
Nowy cel: LapTime_rel = LapTime_s - mediana_czasu_wyścigu. Tor jako cecha znika - jest już zawarty w odejmowanej bazie:
laps['base'] = laps.groupby('race_id')['LapTime_s'].transform('median')
laps['LapTime_rel'] = laps['LapTime_s'] - laps['base']
print('cel względny - odchylenie std:', round(laps['LapTime_rel'].std(), 2),
's (typowe wahanie tempa w obrębie wyścigu)')
tr, te = split_per_race(laps)
Xtr, Xte = laps[cat_cols + num_cols].iloc[tr], laps[cat_cols + num_cols].iloc[te]
ytr, yte = laps['LapTime_rel'].values[tr], laps['LapTime_rel'].values[te]
Xtr_h, Xte_h = Xtr.copy(), Xte.copy()
for col in cat_cols:
Xtr_h[col] = Xtr_h[col].astype('category')
Xte_h[col] = Xte_h[col].astype('category')
mask = [col in cat_cols for col in cat_cols + num_cols]
hgb = HistGradientBoostingRegressor(categorical_features=mask, learning_rate=0.08,
max_iter=400, l2_regularization=1.0,
random_state=42).fit(Xtr_h, ytr)
report('Akt 4: HistGB (tempo względne)', yte, hgb.predict(Xte_h))
cel względny - odchylenie std: 1.13 s (typowe wahanie tempa w obrębie wyścigu)
Akt 4: HistGB (tempo względne) MAE= 0.627s RMSE= 0.808s R2= 0.486
{'model': 'Akt 4: HistGB (tempo względne)',
'MAE': 0.6272653916602322,
'RMSE': 0.8081744244273222,
'R2': 0.48555492588058535}
$R^2$ spadło z ~0.90 do ~0.5. Zadanie jest jednak uczciwsze - pozbyliśmy się darmowego sygnału toru.
Ten cel uznajemy obecnie za poprawny i na nim przeprowadzimy benchmark algorytmów.
Benchmark - porównanie algorytmów¶
Mając poprawnie postawione zadanie (tempo względne), porównujemy kilka algorytmów na tych samych danych i tym samym podziale. Sprawdzamy sześć podejść:
DummyRegressor- nie uczy się niczego, zawsze przewiduje średnią. To punkt odniesienia: każdy sensowny model musi być od niego lepszy.LinearRegressioniRidge- modele liniowe (Ridge to wariant z regularyzacją, ograniczającą wielkość współczynników). Dane dostają zakodowane one-hot, przeskalowane i z uzupełnionymi brakami.kNN(k najbliższych sąsiadów) - przewiduje na podstawie podobnych okrążeń z treningu.RandomForestiHistGradientBoosting- modele drzewiaste, zdolne wychwytywać nieliniowości i interakcje.HistGradientBoostingprzyjmuje kategorie natywnie,RandomForestpo one-hot.
Oceniamy je poznanymi już miarami jakości (MAE, RMSE, $R^2$), a dodatkowo notujemy czas treningu i czas predykcji. Ten ostatni jest istotny, bo "jak dobrze model się sprawuje" to nie tylko dokładność, ale i koszt obliczeniowy.
pre_lin = ColumnTransformer([
('cat', OneHotEncoder(handle_unknown='ignore', sparse_output=False), cat_cols),
('num', make_pipeline(SimpleImputer(strategy='median'), StandardScaler()), num_cols)])
rf_pre = ColumnTransformer([
('cat', OneHotEncoder(handle_unknown='ignore', sparse_output=False), cat_cols),
('num', SimpleImputer(strategy='median'), num_cols)])
def timed(model, Xtr_, ytr_, Xte_, yte_, name):
t = time.perf_counter(); model.fit(Xtr_, ytr_); fit_t = time.perf_counter() - t
t = time.perf_counter(); pred = model.predict(Xte_); pred_t = time.perf_counter() - t
r = report(name, yte_, pred); r['fit_s'] = fit_t; r['pred_s'] = pred_t
return r
rows = []
rows.append(timed(Pipeline([('pre', pre_lin), ('reg', DummyRegressor())]), Xtr, ytr, Xte, yte, 'Dummy (średnia)'))
rows.append(timed(Pipeline([('pre', pre_lin), ('reg', LinearRegression())]), Xtr, ytr, Xte, yte, 'Linear Regression'))
rows.append(timed(Pipeline([('pre', pre_lin), ('reg', Ridge(alpha=1.0))]), Xtr, ytr, Xte, yte, 'Ridge'))
rows.append(timed(Pipeline([('pre', pre_lin), ('reg', KNeighborsRegressor(n_neighbors=15))]), Xtr, ytr, Xte, yte, 'kNN (k=15)'))
rows.append(timed(Pipeline([('pre', rf_pre), ('reg', RandomForestRegressor(n_estimators=200, n_jobs=-1, random_state=42))]), Xtr, ytr, Xte, yte, 'Random Forest'))
rows.append(timed(HistGradientBoostingRegressor(categorical_features=mask, learning_rate=0.08, max_iter=400, l2_regularization=1.0, random_state=42), Xtr_h, ytr, Xte_h, yte, 'HistGradientBoosting'))
bench = pd.DataFrame(rows).set_index('model').round(3)
bench
Dummy (średnia) MAE= 0.912s RMSE= 1.127s R2= -0.000 Linear Regression MAE= 0.585s RMSE= 0.758s R2= 0.548 Ridge MAE= 0.585s RMSE= 0.758s R2= 0.548
kNN (k=15) MAE= 0.736s RMSE= 0.950s R2= 0.289
Random Forest MAE= 0.622s RMSE= 0.799s R2= 0.497
HistGradientBoosting MAE= 0.627s RMSE= 0.808s R2= 0.486
| MAE | RMSE | R2 | fit_s | pred_s | |
|---|---|---|---|---|---|
| model | |||||
| Dummy (średnia) | 0.912 | 1.127 | -0.000 | 0.033 | 0.006 |
| Linear Regression | 0.585 | 0.758 | 0.548 | 0.038 | 0.008 |
| Ridge | 0.585 | 0.758 | 0.548 | 0.036 | 0.009 |
| kNN (k=15) | 0.736 | 0.950 | 0.289 | 0.037 | 0.174 |
| Random Forest | 0.622 | 0.799 | 0.497 | 1.842 | 0.039 |
| HistGradientBoosting | 0.627 | 0.808 | 0.486 | 0.564 | 0.022 |
Tabela zestawia wszystkie modele. Pierwszy wniosek dotyczy punktu odniesienia: Dummy osiąga MAE = 0.912 s i $R^2$ równe zeru, więc każdy model, który schodzi poniżej tego błędu, faktycznie czegoś się nauczył.
Zaskoczeniem jest to, że najlepsze okazują się modele liniowe - LinearRegression i Ridge dają identyczny wynik (MAE = 0.585 s, $R^2 = 0.548$), wyprzedzając oba modele drzewiaste (RandomForest 0.497, HistGradientBoosting 0.486). To samo zadanie, które wcześniej wymagało drzew do pokazania degradacji opon, po przeformułowaniu na tempo względne jest na tyle "liniowe", że prosta regresja wystarcza. Dominuje w nim efekt paliwa (LapNumber), który jest w przybliżeniu liniowy, a degradacja opon - choć nieliniowa - jest efektem o wiele słabszym. Przy ograniczonym zestawie cech drzewa nie mają tu czego dodać i lekko się przeuczają.
Ridge i LinearRegression dają ten sam wynik, bo przy tej liczbie obserwacji regularyzacja nie ma już co ograniczać - współczynniki i bez niej są stabilne.
b = bench.reset_index()
fig = px.scatter(b, x='fit_s', y='MAE', text='model', color='model',
title='Dokładność (MAE, niżej=lepiej) vs czas treningu',
labels={'fit_s': 'czas treningu [s]', 'MAE': 'MAE [s]'})
fig.update_traces(textposition='top center', marker_size=12)
fig.update_layout(showlegend=False)
fig.show()
fig = px.bar(b.sort_values('MAE'), x='MAE', y='model', orientation='h',
title='Ranking modeli wg MAE (tempo względne)',
labels={'MAE': 'średni błąd bezwzględny [s]', 'model': ''})
fig.update_layout(height=380)
fig.show()
Oba wykresy pokazują to samo z dwóch stron. Wykres rankingu porządkuje modele według dokładności (MAE), a wykres punktowy zestawia tę dokładność z czasem treningu - czyli pokazuje, ile kosztuje dany poziom jakości.
Płynie z tego klarowny wniosek: najprostszy model jest tu jednocześnie najlepszy i najtańszy. LinearRegression ma najniższy błąd, a trenuje się w setnych częściach sekundy. Dla kontrastu:
RandomForestosiąga gorszy wynik, a trenuje się około czterdziestokrotnie dłużej (~1.8 swobec~0.04 s),kNNwypada najsłabiej z modeli faktycznie uczących się ($R^2 = 0.289$) i dodatkowo jest najwolniejszy w predykcji, bo przy każdym zapytaniu musi przeszukać cały zbiór treningowy w poszukiwaniu sąsiadów.
Tak więc bardziej złożony model nie zawsze jest lepszy. Gdy zadanie jest odpowiednio sformułowane, prostsze rozwiązanie bywa zarazem dokładniejsze, szybsze i łatwiejsze do zrozumienia. Złożoność warto wprowadzać dopiero wtedy, gdy realnie poprawia wynik.
Warto przy okazji wyjaśnić, co właściwie oznacza $R^2 = 0.55$ najlepszego modelu. Liczba ta mówi, że model wyjaśnia 55% zmienności tempa względnego - pozostałe 45% to czynniki nieobecne w naszych cechach (ruch na torze, tryb pracy silnika, forma kierowcy danego dnia) plus zwykły szum.
Przełóżmy to na sekundy za pomocą MAE: model myli się średnio o ~0.585 s, podczas gdy samo zgadywanie średniej (Dummy) myli się o ~0.91 s - czyli redukcja błędu o mniej więcej jedną trzecią. To wystarcza, by rozumieć, co napędza tempo (paliwo, klasa auta, opony), ale 0.585 s to wciąż dużo jak na F1, gdzie o pozycje walczy się setnymi sekundy. Model jest więc narzędziem analitycznym, a nie wyrocznią co do pojedynczego okrążenia - do precyzyjnej strategii się nie nadaje.
Diagnostyka modelu¶
Skoro modele dają zbliżoną jakość, do diagnostyki bierzemy HistGradientBoosting jako reprezentanta podejścia nieliniowego - chcemy zobaczyć, gdzie model trafia, a gdzie się myli, i które cechy są dla niego najważniejsze. Zaczynamy od zestawienia wartości przewidzianych z rzeczywistymi.
best_name = bench['MAE'].idxmin()
print('najlepszy wg MAE:', best_name)
p_best = hgb.predict(Xte_h) # HGB jako reprezentatywny model nieliniowy
res_df = pd.DataFrame({'rzeczywiste': yte, 'przewidziane': p_best})
fig = px.scatter(res_df, x='rzeczywiste', y='przewidziane', opacity=0.25,
title='HistGB: przewidziane vs rzeczywiste tempo względne [s]')
lo, hi = res_df.rzeczywiste.min(), res_df.rzeczywiste.max()
fig.add_shape(type='line', x0=lo, y0=lo, x1=hi, y1=hi, line=dict(dash='dash', color='red'))
fig.show()
najlepszy wg MAE: Linear Regression
Wykres zestawia tempo przewidziane (oś pionowa) z rzeczywistym (oś pozioma); każdy punkt to jedno okrążenie ze zbioru testowego. Czerwona przerywana linia to idealne przewidywanie - gdyby model był bezbłędny, wszystkie punkty leżałyby dokładnie na niej.
Chmura układa się wzdłuż tej linii (korelacja ~0.70), co potwierdza, że model uchwycił główny sygnał. Widać jednak wyraźny, systematyczny wzorzec: punkty są spłaszczone wokół zera względem przekątnej. Dla okrążeń naprawdę wolnych (rzeczywiste tempo powyżej +2 s) model przewiduje zbyt nisko, a dla bardzo szybkich - zbyt wysoko. Przewidywany zakres (od -3 do 2.6 s) jest węższy niż rzeczywisty (od -4.3 do 3.3 s).
Efekt ten to regresja do średniej: model, nie znając przyczyny skrajnego wyniku, asekuracyjnie typuje wartości bliższe przeciętnej. Skrajne okrążenia w F1 biorą się zwykle z czynników, których w danych nie mamy - chwilowego ruchu na torze, błędu kierowcy, resztek po neutralizacji. Model nie jest w stanie ich przewidzieć, więc dla pewności trzyma się środka. To ta sama granica, o której mówiliśmy przy $R^2$: część zmienności tempa jest po prostu nieobecna w cechach, którymi dysponujemy.
perm = permutation_importance(hgb, Xte_h, yte, n_repeats=8, random_state=42)
imp = pd.DataFrame({'cecha': cat_cols + num_cols,
'ważność': perm.importances_mean}).sort_values('ważność')
fig = px.bar(imp, x='ważność', y='cecha', orientation='h',
title='Ważność cech (permutation importance) - HistGB, tempo względne')
fig.update_layout(height=480)
fig.show()
print('Uwaga: LapNumber (proxy paliwa) jest skorelowany z TyreLife - ważności czytaj łącznie.')
Uwaga: LapNumber (proxy paliwa) jest skorelowany z TyreLife - ważności czytaj łącznie.
Ranking ważności mówi sporo o naturze problemu. Najsilniejszą cechą jest LapNumber, czyli przybliżenie paliwa - potwierdza to obserwację z EDA, że ubywające paliwo to największy efekt czasowy w wyścigu. Zaskakiwać może natomiast Team na drugim miejscu, wyżej niż TyreLife.
Team to przybliżenie tego, jak szybkie jest samo auto, a różnice konstrukcyjne między zespołami są ogromne. W sezonach 2023-24 mediana tempa względnego wahała się od -0.77 s (Red Bull) do +0.72 s (Kick Sauber) - rozrzut rzędu 1.5 s na okrążeniu, większy niż całkowity efekt degradacji opony. Sama informacja "które to auto" niesie więc więcej niż wiek opony.
Do tego TyreLife jest osłabiony: jak pokazaliśmy w EDA, jest skorelowany z LapNumber. Część jego sygnału przejmuje numer okrążenia, więc jego samodzielna ważność spada. Team takiego zastępnika nie ma - jego informacja jest unikalna.
Uderza natomiast, jak mało znaczą cechy pogodowe. AirTemp, TrackTemp, Humidity i WindSpeed mają ważność zerową lub wręcz ujemną (ich przetasowanie nie pogarsza predykcji), Rainfall ledwo odrywa się od zera, a Pressure (~0.02) to raczej identyfikator toru niż pogoda - ciśnienie w obrębie wyścigu praktycznie się nie zmienia, ale odróżnia tory położone na różnej wysokości. Nie znaczy to, że pogoda nie wpływa na czas okrążenia - najprawdopodobniej znaczy, że różnicuje głównie wyścigi między sobą, a tego tu nie mierzymy: ten składnik zmienności usunęliśmy razem z torem, przechodząc na tempo względne. W obrębie jednego wyścigu temperatura, wilgotność i ciśnienie zmieniają się niewiele, a to, co się zmienia (na przykład stygnący tor), idzie w parze z numerem okrążenia - po odjęciu mediany wyścigu pogoda nie ma już czego wyjaśniać. Deszcz i wiatr zmieniają się częściej, ale deszczowych okrążeń jest w oczyszczonym zbiorze niecałe 2%, a wiatr - jak widać - nic nie wnosi. Do tego przy podziale per wyścig model widzi w teście kombinacje temperatur, których nie znał z treningu - cechy pogodowe działają wtedy raczej jak szum niż jak sygnał.
Wracając do Team: trzeba pamiętać, że jego siła zależy od jednorodności danych. Jest dobrym wskaźnikiem tylko w obrębie jednej ery technicznej - "Mercedes 2019" i "Mercedes 2024" to przy tej samej nazwie zupełnie różne tempo. Wrócimy do tego za chwilę.
Czy więcej sezonów poprawi model?¶
Skoro w pierwszej poprawce uratowały nas dane (dołożenie drugiego sezonu), narzuca się pytanie: czy kolejne sezony podniosą obecne ~0.55? FastF1 udostępnia dane od 2018 roku, więc możemy to sprawdzić. Wczytujemy szerszy zbiór (laps_all - sezony 2018-2024) i liczymy $R^2$ dla rosnącej liczby sezonów, dokładanych od najnowszego wstecz.
Plik laps_all.parquet zbudowaliśmy tym samym kodem czyszczenia co laps_clean z notebooka 01 (identyczne filtry), uruchomionym dla sezonów 2018-2024, a wynik zapisaliśmy pod inną nazwą. Pobieranie siedmiu sezonów trwa godzinami, dlatego gotowy parquet leży w data/, a notebook tylko go czyta.
Policzmy:
laps_all = pd.read_parquet('../data/laps_all.parquet')
laps_all['FreshTyre'] = laps_all['FreshTyre'].astype(int)
seasons_all = sorted(laps_all['Season'].unique())
print('dostępne sezony:', seasons_all, '| okrążeń:', len(laps_all))
def ocena_podzbioru(df):
df = df.copy()
df['base'] = df.groupby('race_id')['LapTime_s'].transform('median')
df['rel'] = df['LapTime_s'] - df['base']
g = df['race_id'].values
tr_, te_ = next(GroupShuffleSplit(1, test_size=0.25, random_state=42).split(df, groups=g))
pre = ColumnTransformer([
('cat', OneHotEncoder(handle_unknown='ignore', sparse_output=False), cat_cols),
('num', make_pipeline(SimpleImputer(strategy='median'), StandardScaler()), num_cols)])
m = Pipeline([('pre', pre), ('reg', LinearRegression())]).fit(
df[cat_cols + num_cols].iloc[tr_], df['rel'].values[tr_])
p = m.predict(df[cat_cols + num_cols].iloc[te_])
return r2_score(df['rel'].values[te_], p), mean_absolute_error(df['rel'].values[te_], p)
rows = []
for k in range(1, len(seasons_all) + 1):
chosen = seasons_all[-k:]
r2, mae = ocena_podzbioru(laps_all[laps_all['Season'].isin(chosen)])
rows.append({'liczba_sezonów': k, 'zakres': f'{chosen[0]}-{chosen[-1]}', 'R2': r2, 'MAE': mae})
wynik_sez = pd.DataFrame(rows)
wynik_sez.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)] | okrążeń: 113622
| liczba_sezonów | zakres | R2 | MAE | |
|---|---|---|---|---|
| 0 | 1 | 2024-2024 | 0.573 | 0.615 |
| 1 | 2 | 2023-2024 | 0.548 | 0.585 |
| 2 | 3 | 2022-2024 | 0.509 | 0.616 |
| 3 | 4 | 2021-2024 | 0.540 | 0.568 |
| 4 | 5 | 2020-2024 | 0.465 | 0.637 |
| 5 | 6 | 2019-2024 | 0.449 | 0.642 |
| 6 | 7 | 2018-2024 | 0.500 | 0.646 |
fig = px.line(wynik_sez, x='liczba_sezonów', y='R2', markers=True,
title='R² regresji a liczba sezonów treningowych',
labels={'liczba_sezonów': 'liczba sezonów', 'R2': 'R² (tempo względne)'})
fig.update_yaxes(range=[0, 0.7])
fig.show()
$R^2$ nie rośnie wraz z liczbą sezonów - oscyluje, a nawet lekko spada. Najlepszy wynik daje jeden, najnowszy sezon. Dlaczego dokładanie danych tym razem nie pomaga, skoro wcześniej pomogło modelowi?
Kluczem zdaje się być jednorodność danych. W 2022 roku F1 przeszła wielką zmianę regulaminu (nowa aerodynamika, większe opony), więc auta z lat 2018-19 i 2023-24 zachowują się zupełnie inaczej. Sprawdźmy, czy mieszanie tych dwóch er szkodzi - porównując model uczony na każdej erze osobno z modelem uczonym na wszystkim naraz:
era_nowa = laps_all[laps_all['Season'] >= 2022]
era_stara = laps_all[laps_all['Season'] < 2022]
for nazwa, df in [('era >=2022 (osobno)', era_nowa),
('era <2022 (osobno)', era_stara),
('wszystko zmieszane', laps_all)]:
r2, mae = ocena_podzbioru(df)
print(f'{nazwa:<22} R2={r2:.3f} MAE={mae:.3f}s (okrążeń: {len(df)})')
era >=2022 (osobno) R2=0.509 MAE=0.616s (okrążeń: 56217) era <2022 (osobno) R2=0.547 MAE=0.637s (okrążeń: 57405)
wszystko zmieszane R2=0.500 MAE=0.646s (okrążeń: 113622)
To potwierdza diagnozę: model uczony na każdej erze z osobna wypada lepiej niż uczony na wszystkim naraz. Zmieszanie obu er daje wynik gorszy od każdej z nich osobno - dorzucanie sezonów z innej epoki technicznej nie wnosi sygnału, tylko szum. Model dostaje sprzeczne wzorce (inną degradację opon, inny układ sił) pod tymi samymi cechami. Najmocniej cierpi na tym Team: jest dobrym wyznacznikiem siły auta tylko w obrębie jednej ery, a przy mieszaniu lat ta sama nazwa zespołu oznacza zupełnie różne tempo.
Wniosek jest istotny i nieoczywisty: problemem nie jest ilość danych, lecz brak cech. Granica ~0.55 to sufit przy obecnym zestawie cech, a nie efekt zbyt małej próbki - widać to wyraźnie, bo siedem sezonów wypada gorzej niż jeden czy dwa. Tempo okrążenia zależy od czynników, których w danych nie mamy - ruchu na torze (jazda za rywalem), trybu pracy silnika czy formy kierowcy danego dnia. Aby przesunąć tę granicę, trzeba dodać nowe cechy, a nie kolejne sezony tych samych danych.
Zapis predykcji i wyników benchmarku¶
Predykcje oraz tabelę wyników zapisujemy do plików parquet - skorzysta z nich notebook 05 (analiza błędów).
Zapisujemy predykcje HistGradientBoosting - modelu, na którym prowadziliśmy diagnostykę - a nie zwycięzcy benchmarku (LinearRegression). Kosztuje to niewiele (MAE = 0.627 s wobec 0.585 s), a w zamian analiza błędów w notebooku 05 dotyczy tego samego modelu, którego ważności cech i wykres przewidziane vs rzeczywiste już oglądaliśmy.
out = laps.iloc[te][['race_id', 'EventName', 'Compound', 'TyreLife', 'LapNumber']].copy()
out['actual_rel'] = yte
out['pred_rel'] = p_best
out['base_laptime'] = laps['base'].values[te]
out.to_parquet('../data/reg_predictions.parquet', index=False)
bench.to_parquet('../data/reg_benchmark.parquet')
print('zapisano reg_predictions.parquet', out.shape, 'oraz reg_benchmark.parquet')
zapisano reg_predictions.parquet (10477, 8) oraz reg_benchmark.parquet
Podsumowanie¶
Trzy obserwacje:
- Naiwny model na jednym sezonie osiągnął $R^2 = -0.76$ - gorzej niż średnia. Wina leżała nie po stronie modelu, lecz zadania: tor (
~99%zmienności) był unikalny dla każdego wyścigu, więc przy podziale per wyścig tory testowe pozostawały nieznane. - Dołożenie drugiego sezonu podniosło $R^2$ do
~0.90. Czasem naprawą są dane, a nie model. - Wysokie $R^2$ bywa jednak złudne - model uczył się głównie toru, który znamy z góry. Po zmianie celu na tempo względne w obrębie wyścigu $R^2$ spadło do
~0.5, ale model zaczął uczyć się tego, co istotne: efektu paliwa, klasy auta (Team) i degradacji opon. Cechy pogodowe okazały się dla tak postawionego celu niemal bezużyteczne - w obrębie wyścigu pogoda prawie się nie zmienia, więc zapewne różnicuje raczej wyścigi między sobą niż okrążenia w jednym wyścigu.
Benchmark pokazał dodatkowo, że przy dobrze postawionym zadaniu prosty model liniowy potrafi dorównać drzewom, a działa znacznie szybciej.