Eksploracja danych (EDA)¶
Po utworzeniu zbioru danych należy najpierw stwierdzić, czy jest on w ogóle użyteczny oraz czy możliwe jest jego wykorzystanie do wytrenowania modelu.
import pandas as pd
import numpy as np
import plotly.express as px
laps = pd.read_parquet('../data/laps_clean.parquet')
dr = pd.read_parquet('../data/driver_race.parquet')
print('laps:', laps.shape, '| driver_race:', dr.shape)
print('sezony:', sorted(laps.Season.unique()), '| torów:', laps.EventName.nunique())
laps: (39647, 18) | driver_race: (918, 12) sezony: [np.int64(2023), np.int64(2024)] | torów: 24
Rozkład czasów okrążeń¶
fig = px.histogram(laps, x='LapTime_s', nbins=80, color='Season',
title='Rozkład czasów okrążeń (po czyszczeniu)')
fig.update_layout(xaxis_title='czas okrążenia [s]', yaxis_title='liczba okrążeń')
fig.show()
Na powyższym wykresie widać, że rozkład rozciąga się od około 67 do 116 sekund.
Rozpiętość wynosi więc prawie 50 sekund. Na histogramie widać również parę garbów. Wynikają one z czasów na poszczególnych torach i zależą przede wszystkim od długości oraz rodzaju torów. Każde skupisko odpowiada w przybliżeniu jednemu z torów. Na przykład Austria, z najkrótszym z torów, tworzy grzbiet w okolicach ~71 s.
Może to być wyzwaniem w trakcie trenowania modelu do przewidywania czasu okrążenia. Skoro wynik zależy w dużej mierze od tego, na którym torze jedziemy, to model będzie uczył się głównie rozpoznawania toru, a nie tego, co naprawdę chcemy badać (wpływu opon, paliwa czy pogody).
Jaką część zmienności czasu tłumaczy tor?¶
Powyżej już zaznaczono, że dla czasu okrążenia decydujący jest tor. Należy jednak to policzyć:
var_total = laps['LapTime_s'].var()
resid = laps.groupby('race_id')['LapTime_s'].transform(lambda s: s - s.median())
udzial_toru = 1 - resid.var() / var_total
print(f'Odchylenie std czasu (globalnie): {laps.LapTime_s.std():.2f} s')
print(f'Odchylenie std PO odjęciu mediany toru: {resid.std():.2f} s')
print(f'=> sam tor tłumaczy {udzial_toru:.1%} zmienności czasu okrążenia')
Odchylenie std czasu (globalnie): 10.53 s Odchylenie std PO odjęciu mediany toru: 1.13 s => sam tor tłumaczy 98.8% zmienności czasu okrążenia
med = laps.groupby('EventName')['LapTime_s'].median().sort_values()
fig = px.bar(med, title='Mediana czasu okrążenia per tor - to jest „tło", które dominuje',
labels={'value': 'mediana czasu [s]', 'EventName': 'tor'})
fig.update_layout(showlegend=False, height=520)
fig.show()
Tor odpowiada za 98.8% zmienności.
Wynika z tego, że model uczony na czasie okrążenia w obecnej postaci nauczy się głównie, ile trwa okrążenie na danym torze. Jednak to wiemy już przed wyścigiem!
Należy więc pomyśleć, jak inaczej wyrazić ten czas, aby miara była znormalizowana pomiędzy torami.
Degradacja opon¶
Jak zmienia się czas okrążenia w miarę zużywania się opony?
Zużycie opony (TyreLife - liczba okrążeń przejechanych na danym komplecie) to jedna z cech, które bierzemy pod uwagę. Intuicja podpowiada, że im starsza opona, tym mniejsza przyczepność, a więc wolniejsze okrążenie:
g = laps.groupby(['Compound', 'TyreLife'])['LapTime_s']
deg = g.agg(['median', 'count']).reset_index()
deg = deg[deg['count'] >= 20]
fig = px.line(deg, x='TyreLife', y='median', color='Compound', markers=True,
title='Mediana czasu vs wiek opony per mieszanka',
labels={'median': 'mediana czasu [s]', 'TyreLife': 'wiek opony [okr.]'})
fig.show()
Wynik jest sprzeczny z intuicją: mediana czasu spada wraz z wiekiem opony.
Zanim uznamy, że opony z wiekiem przyspieszają, sprawdźmy, czego ten wykres nie uwzględnia: momentu wyścigu. Stary komplet opon oznacza zwykle późną fazę wyścigu, a wyścig ma własny przebieg w czasie - zmienia się ilość paliwa, tor się rozjeżdża. Zbadamy więc, jak postęp wyścigu wiąże się z wiekiem opony i z czasem okrążenia:
korelacja = laps[['TyreLife', 'LapNumber']].corr().iloc[0, 1]
print(f'korelacja TyreLife vs LapNumber: {korelacja:.2f}')
nachylenia = []
for race_id, g in laps.groupby('race_id'):
if len(g) < 20:
continue
a, _ = np.polyfit(g['LapNumber'].astype(float), g['LapTime_s'], 1)
nachylenia.append(a)
efekt_paliwa = np.median(nachylenia)
print(f'wypadkowy trend tempa (paliwo + degradacja + ewolucja toru): {efekt_paliwa:.3f} s/okrążenie')
print(f'w typowym wyścigu (~57 okrążeń) auto przyspiesza łącznie o ~{abs(efekt_paliwa) * 57:.1f} s')
print('-> mimo że opony się zużywają (spowalniają), auto przyspiesza:')
print(' efekt paliwa jest silniejszy niż degradacja i przykrywa ją w surowych danych')
korelacja TyreLife vs LapNumber: 0.53 wypadkowy trend tempa (paliwo + degradacja + ewolucja toru): -0.045 s/okrążenie w typowym wyścigu (~57 okrążeń) auto przyspiesza łącznie o ~2.5 s -> mimo że opony się zużywają (spowalniają), auto przyspiesza: efekt paliwa jest silniejszy niż degradacja i przykrywa ją w surowych danych
Dwie liczby. Po pierwsze, korelacja 0.53: wiek opony i numer okrążenia rzeczywiście idą w parze - im starszy komplet, tym dalej w wyścigu. Po drugie, czas okrążenia spada z każdym kolejnym okrążeniem, średnio o 0.045 s, czyli o około 2.5 s w typowym wyścigu. Auto przyspiesza w trakcie wyścigu mimo zużywających się opon.
Najprostsze wytłumaczenie to ubywające paliwo: auto, które w trakcie wyścigu spala około 100 kg paliwa, jest pod koniec wyraźnie szybsze. Ilości paliwa w danych nie mamy - numer okrążenia służy tu jako jej przybliżenie, przy założeniu, że paliwo ubywa mniej więcej równomiernie. Zmierzony trend splata zresztą kilka czynników: nie tylko paliwo, ale i degradację opon oraz rozjeżdżanie się toru. Nie da się ich tu rozdzielić, ale ich wypadkowa jest jasna: to, co przyspiesza auto, jest silniejsze od tego, co je spowalnia, i w surowych danych przykrywa degradację opon.
Z tego powodu pierwszy wykres mylił. Aby zobaczyć samą degradację, musimy pozbyć się tego dominującego trendu. Dlatego w obrębie każdego wyścigu dopasowujemy liniowy trend czasu względem numeru okrążenia i go odejmujemy. Pozostaje pace_resid - odchylenie oczyszczone z trendu paliwa i bazowego czasu toru:
def korekta_paliwa(g):
x = g['LapNumber'].values.astype(float)
y = g['LapTime_s'].values
a, b = np.polyfit(x, y, 1)
g['pace_resid'] = y - (a * x + b)
return g
laps_fc = laps.groupby('race_id', group_keys=False).apply(korekta_paliwa)
# tylko opony na sucho (INTERMEDIATE/WET to wyścigi w deszczu - inna historia)
suche = laps_fc[laps_fc['Compound'].isin(['SOFT', 'MEDIUM', 'HARD'])]
deg = (suche.groupby(['Compound', 'TyreLife'])['pace_resid']
.agg(['median', 'count']).reset_index())
deg = deg[deg['count'] >= 20]
fig = px.line(deg, x='TyreLife', y='median', color='Compound', markers=True,
title='Degradacja opony po usunięciu efektu paliwa',
labels={'median': 'odchylenie tempa [s]', 'TyreLife': 'wiek opony [okr.]'})
fig.add_hline(y=0, line_dash='dash')
fig.show()
/tmp/ipykernel_1208528/646244157.py:8: FutureWarning: DataFrameGroupBy.apply operated on the grouping columns. This behavior is deprecated, and in a future version of pandas the grouping columns will be excluded from the operation. Either pass `include_groups=False` to exclude the groupings or explicitly select the grouping columns after groupby to silence this warning.
laps_fc = laps.groupby('race_id', group_keys=False).apply(korekta_paliwa)
Po usunięciu efektu paliwa obraz się odwraca i potwierdza początkowe założenie. Okrążenia stają się wolniejsze wraz z wiekiem opony.
Zależność jest nieliniowa i inna dla każdej mieszanki - miękka (SOFT) degraduje się szybciej niż twarda (HARD). Już na tym etapie analizy widać, że jedna prosta (regresja liniowa) nie jest idealna dla tego zastosowania.
Klasyfikacja: pozycja startowa a podium¶
rate = dr.groupby('GridPosition')['podium'].mean().reset_index()
fig = px.bar(rate, x='GridPosition', y='podium',
title='Szansa na podium wg pozycji startowej',
labels={'podium': 'P(podium)', 'GridPosition': 'pozycja startowa'})
fig.show()
Powyższy wykres dosyć jasno prezentuje zależność - im bardziej z przodu jest auto, tym wyższą szansę ma na podium.
print(f'odsetek podiów: {dr.podium.mean():.1%} (klasy NIEzbalansowane!)')
fig = px.histogram(dr, x='podium', color='podium',
title=f'Balans klas: podium={dr.podium.mean():.1%} reszty')
fig.show()
odsetek podiów: 15.0% (klasy NIEzbalansowane!)
Widać, że podia to jedynie ~15% przypadków. Gdyby model założył, że podium nie ma nigdy, osiągnąłby około 85% trafności! Z tego też względu będziemy musieli się przyglądać innym metrykom.