Budowa zbioru danych¶
Iteracja po wszystkich wyścigach wybranych sezonów. Budujemy dwa zbiory:
laps_clean.parquet- jeden wiersz = jedno czyste okrążenie (pod regresję czasu),driver_race.parquet- jeden wiersz = jeden kierowca w jednym wyścigu (pod klasyfikację podium).
import os, warnings
import fastf1
import numpy as np
import pandas as pd
warnings.simplefilter('ignore')
fastf1.set_log_level('ERROR')
os.makedirs('../cache', exist_ok=True)
os.makedirs('../data', exist_ok=True)
fastf1.Cache.enable_cache('../cache')
SEASONS = [2023, 2024]
SEASONS
[2023, 2024]
Krok 1 - czasy kwalifikacji¶
Pierwszy popełniony błąd krył się w braku zrozumienia zbioru danych.
Pozycja startowa oraz wynik znajdują się w sesji wyścigu ('R'). Chcąc pobrać również czasy kwalifikacji,
napotykamy poniższy problem:
demo = fastf1.get_session(2024, 1, 'R')
demo.load(telemetry=False, weather=False, messages=False)
demo.results[['Abbreviation', 'Q1', 'Q2', 'Q3']].head()
| Abbreviation | Q1 | Q2 | Q3 | |
|---|---|---|---|---|
| 1 | VER | NaT | NaT | NaT |
| 11 | PER | NaT | NaT | NaT |
| 55 | SAI | NaT | NaT | NaT |
| 16 | LEC | NaT | NaT | NaT |
| 63 | RUS | NaT | NaT | NaT |
W wynikach sesji wyścigu czasy kwalifikacji Q1/Q2/Q3 są puste. Okazuje się, że znajdują się one w oddzielnej sesji 'Q':
demo = fastf1.get_session(2024, 1, 'Q')
demo.load(telemetry=False, weather=False, messages=False)
demo.results[['Abbreviation', 'Q1', 'Q2', 'Q3']].head()
| Abbreviation | Q1 | Q2 | Q3 | |
|---|---|---|---|---|
| 1 | VER | 0 days 00:01:30.031000 | 0 days 00:01:29.374000 | 0 days 00:01:29.179000 |
| 16 | LEC | 0 days 00:01:30.243000 | 0 days 00:01:29.165000 | 0 days 00:01:29.407000 |
| 63 | RUS | 0 days 00:01:30.350000 | 0 days 00:01:29.922000 | 0 days 00:01:29.485000 |
| 55 | SAI | 0 days 00:01:29.909000 | 0 days 00:01:29.573000 | 0 days 00:01:29.507000 |
| 11 | PER | 0 days 00:01:30.221000 | 0 days 00:01:29.932000 | 0 days 00:01:29.537000 |
Gdybyśmy budowali cechę quali_gap_s z sesji 'R', byłaby ona całkowicie pusta!
Zostałaby ona wówczas zignorowana przez model - w żaden sposób nie poprawiając jakości modelu. Był to błąd, który popełniliśmy w pierwszej wersji projektu.
def quali_gap(season, rnd):
"""
O ile sekund najlepsze okrążenie kierowcy w kwalifikacjach
było wolniejsze od najszybszego okrążenia w całych kwalifikacjach?
"""
q = fastf1.get_session(season, rnd, 'Q')
q.load(telemetry=False, weather=False, messages=False)
r = q.results.copy()
for col in ['Q1', 'Q2', 'Q3']:
r[col + '_s'] = pd.to_timedelta(r[col]).dt.total_seconds()
r['quali_best_s'] = r[['Q1_s', 'Q2_s', 'Q3_s']].min(axis=1)
r['quali_gap_s'] = r['quali_best_s'] - r['quali_best_s'].min()
return r[['Abbreviation', 'quali_gap_s']]
quali_gap(2024, 1).head()
| Abbreviation | quali_gap_s | |
|---|---|---|
| 1 | VER | 0.014 |
| 16 | LEC | 0.000 |
| 63 | RUS | 0.320 |
| 55 | SAI | 0.342 |
| 11 | PER | 0.372 |
Krok 2 - iteracja po wyścigach¶
Pojedynczy sezon składa się z ponad 20 wyścigów rozgrywanych w różnych krajach.
W tej pętli przechodzimy przez wszystkie wyścigi (Grand Prix) z wybranych sezonów i dla każdego z nich zbieramy w jeden zestaw trzy rodzaje danych:
- okrążenia - każde przejechane okrążenie wraz z pogodą; jest to podstawa do przewidywania czasu okrążenia
- wyniki - pozycja końcowa, pozycja startowa i zespół każdego z kierowców; podstawa do klasyfikacji podium
- różnica czasu z kwalifikacji - wynik powyższej funkcji jako oddzielna kolumna; o ile sekund kierowca był wolniejszy od najszybszego okrążenia kwalifikacji.
Wyścigi są identyfikowane przez klucz składający się z sezonu oraz rundy (sezon_runda).
Celem tego kroku jest scalenie danych w konkretne tabele. Tabele te możemy zapisać do plików parquet - z nich będą korzystać następne notebooki.
all_laps, all_dr = [], []
for season in SEASONS:
sched = fastf1.get_event_schedule(season, include_testing=False)
rounds = sched[sched['RoundNumber'] > 0][['RoundNumber', 'EventName']]
print(f'== sezon {season}: {len(rounds)} rund ==')
for _, row in rounds.iterrows():
rnd, name = int(row['RoundNumber']), row['EventName']
race_id = f'{season}_{rnd:02d}'
try:
s = fastf1.get_session(season, rnd, 'R')
s.load()
except Exception as e:
print(f' [pomijam] {race_id} {name}: {e}')
continue
laps = s.laps
if laps is None or len(laps) == 0:
print(f' [pomijam] {race_id} {name}: brak okrążeń'); continue
w = laps.get_weather_data().reset_index(drop=True)
laps = laps.reset_index(drop=True)
laps = pd.concat([laps, w.drop(columns=['Time'])], axis=1)
laps['Season'], laps['Round'] = season, rnd
laps['EventName'], laps['race_id'] = name, race_id
all_laps.append(laps)
res = s.results.copy()
res['Season'], res['Round'] = season, rnd
res['EventName'], res['race_id'] = name, race_id
try:
res = res.merge(quali_gap(season, rnd), on='Abbreviation', how='left')
except Exception as e:
print(f' [quali skip] {race_id}: {e}'); res['quali_gap_s'] = np.nan
all_dr.append(res)
print(f' [ok] {race_id} {name:<26} okr.={len(laps):>4} quali={res.quali_gap_s.notna().sum():>2}/{len(res)}')
laps_raw = pd.concat(all_laps, ignore_index=True)
res_raw = pd.concat(all_dr, ignore_index=True)
print('\nlaps_raw:', laps_raw.shape, '| results_raw:', res_raw.shape)
== sezon 2023: 22 rund ==
[ok] 2023_01 Bahrain Grand Prix okr.=1056 quali=20/20
[ok] 2023_02 Saudi Arabian Grand Prix okr.= 943 quali=20/20
[ok] 2023_03 Australian Grand Prix okr.=1003 quali=19/20
[ok] 2023_04 Azerbaijan Grand Prix okr.= 962 quali=20/20
[ok] 2023_05 Miami Grand Prix okr.=1138 quali=20/20
[ok] 2023_06 Monaco Grand Prix okr.=1515 quali=20/20
[ok] 2023_07 Spanish Grand Prix okr.=1312 quali=20/20
[ok] 2023_08 Canadian Grand Prix okr.=1317 quali=20/20
[ok] 2023_09 Austrian Grand Prix okr.=1354 quali=20/20
[ok] 2023_10 British Grand Prix okr.= 971 quali=20/20
[ok] 2023_11 Hungarian Grand Prix okr.=1252 quali=20/20
[ok] 2023_12 Belgian Grand Prix okr.= 816 quali=20/20
[ok] 2023_13 Dutch Grand Prix okr.=1343 quali=20/20
[ok] 2023_14 Italian Grand Prix okr.= 957 quali=20/20
[ok] 2023_15 Singapore Grand Prix okr.=1088 quali=20/20
[ok] 2023_16 Japanese Grand Prix okr.= 880 quali=19/20
[ok] 2023_17 Qatar Grand Prix okr.=1006 quali=20/20
[ok] 2023_18 United States Grand Prix okr.=1014 quali=20/20
[ok] 2023_19 Mexico City Grand Prix okr.=1282 quali=19/20
[ok] 2023_20 São Paulo Grand Prix okr.=1108 quali=20/20
[ok] 2023_21 Las Vegas Grand Prix okr.= 946 quali=20/20
[ok] 2023_22 Abu Dhabi Grand Prix okr.=1157 quali=19/20 == sezon 2024: 24 rund ==
[ok] 2024_01 Bahrain Grand Prix okr.=1129 quali=20/20
[ok] 2024_02 Saudi Arabian Grand Prix okr.= 901 quali=19/20
[ok] 2024_03 Australian Grand Prix okr.= 998 quali=19/19
[ok] 2024_04 Japanese Grand Prix okr.= 907 quali=20/20
[ok] 2024_05 Chinese Grand Prix okr.=1032 quali=20/20
[ok] 2024_06 Miami Grand Prix okr.=1111 quali=20/20
[ok] 2024_07 Emilia Romagna Grand Prix okr.=1238 quali=19/20
[ok] 2024_08 Monaco Grand Prix okr.=1237 quali=20/20
[ok] 2024_09 Canadian Grand Prix okr.=1272 quali=20/20
[ok] 2024_10 Spanish Grand Prix okr.=1310 quali=20/20
[ok] 2024_11 Austrian Grand Prix okr.=1405 quali=20/20
[ok] 2024_12 British Grand Prix okr.= 960 quali=20/20
[ok] 2024_13 Hungarian Grand Prix okr.=1355 quali=20/20
[ok] 2024_14 Belgian Grand Prix okr.= 841 quali=20/20
[ok] 2024_15 Dutch Grand Prix okr.=1426 quali=19/20
[ok] 2024_16 Italian Grand Prix okr.=1008 quali=20/20
[ok] 2024_17 Azerbaijan Grand Prix okr.= 973 quali=20/20
[ok] 2024_18 Singapore Grand Prix okr.=1177 quali=20/20
[ok] 2024_19 United States Grand Prix okr.=1059 quali=20/20
[ok] 2024_20 Mexico City Grand Prix okr.=1215 quali=20/20
[ok] 2024_21 São Paulo Grand Prix okr.=1134 quali=20/20
[ok] 2024_22 Las Vegas Grand Prix okr.= 938 quali=20/20
[ok] 2024_23 Qatar Grand Prix okr.= 943 quali=20/20
[ok] 2024_24 Abu Dhabi Grand Prix okr.=1035 quali=20/20 laps_raw: (51024, 42) | results_raw: (919, 27)
Krok 3 - czyszczenie okrążeń pod predykcję czasu¶
Ile trwa okrążenie w zależności od opon, paliwa, pogody i pojazdu?
Celem pierwszego zbioru jest odpowiedź na to pytanie. Aby to ustalić, musimy pozbyć się okrążeń, których czas zależy od czegoś innego niż samo tempo.
Gdyby nasz zbiór danych zawierał okrążenia bez zmierzonego czasu lub oznaczone jako niewiarygodne (IsAccurate=False),
istniałoby ryzyko, że model będzie uczyć się szumu zamiast rzeczywistych korelacji.
Zastosowane zostały poniższe filtry:
- Okrążenia bez zmierzonego czasu
LapTime: model nie ma się czego uczyć. - Bez okrążeń wjazdowych i wyjazdowych z boksu: kierowca zwalnia, by wjechać do alei serwisowej albo dopiero się rozpędza po wyjeździe. Czas takiego okrążenia nie odzwierciedla tempa na torze.
- Tylko zielona flaga (
TrackStatus == '1'): odrzucamy okrążenia przejechane podczas sytuacji sztucznie zawyżających czas - na przykład żółta flaga oznacza niebezpieczeństwo z przodu. Wówczas kierowcy muszą zwolnić i nie wolno im wyprzedzać. - Okrążenia oznaczone jako wiarygodne (
IsAccurate == True): oznaczenie jakości pomiaru w bibliotece FastF1. Filtr ten ogranicza zbiór danych do wiarygodnych pomiarów. - Reguła 107%: w obrębie każdego wyścigu zostawiamy tylko okrążenia nie wolniejsze niż 107% najszybszego okrążenia. Pozwala to szybko odciąć pozostałe okrążenia, które znacząco odstają - przykładowo z powodu jazdy w korku, oszczędzania paliwa/opon lub drobnych problemów technicznych.
Dodatkowo świadomie nie brane są pod uwagę kolumny SectorTime oraz Speed. Ponieważ okrążenie składa się z 3 sektorów (odcinków toru), czas okrążenia to ich suma.
Podanie tych czasów modelowi jako cech sprawiłoby, że nie nauczyłby się niczego o tempie. Po prostu dodałby liczby i odtworzyłby dokładną odpowiedź. Model wyglądałby na bezbłędny.
Powyższe zachowanie to przykład wycieku danych. To znaczy - do cech wejściowych przedostaje się informacja o odpowiedzi, której w rzeczywistości nie jesteśmy w stanie uzyskać przed zdarzeniem. W końcu czasy pojedynczych sektorów poznajemy dopiero po ich ukończeniu. Chcąc przewidzieć czas okrążenia - nie możemy z nich korzystać.
KEEP = ['Season', 'Round', 'EventName', 'race_id', 'Driver', 'Team',
'LapNumber', 'Stint', 'Compound', 'TyreLife', 'FreshTyre',
'AirTemp', 'TrackTemp', 'Humidity', 'Pressure', 'WindSpeed', 'Rainfall',
'LapTime_s']
cl = laps_raw.copy()
cl = cl[cl['LapTime'].notna()]
cl = cl[cl['PitInTime'].isna() & cl['PitOutTime'].isna()]
cl = cl[cl['TrackStatus'].astype(str) == '1']
cl = cl[cl['IsAccurate'] == True]
cl['LapTime_s'] = cl['LapTime'].dt.total_seconds()
fastest = cl.groupby('race_id')['LapTime_s'].transform('min')
cl = cl[cl['LapTime_s'] <= 1.07 * fastest]
cl['FreshTyre'] = cl['FreshTyre'].astype(bool)
laps_clean = cl[KEEP].copy()
print('laps_clean:', laps_clean.shape)
print('okrążeń na wyścig (mediana):', int(laps_clean.groupby('race_id').size().median()))
laps_clean.head()
laps_clean: (39647, 18) okrążeń na wyścig (mediana): 861
| Season | Round | EventName | race_id | Driver | Team | LapNumber | Stint | Compound | TyreLife | FreshTyre | AirTemp | TrackTemp | Humidity | Pressure | WindSpeed | Rainfall | LapTime_s | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 2 | 2023 | 1 | Bahrain Grand Prix | 2023_01 | VER | Red Bull Racing | 3.0 | 1.0 | SOFT | 6.0 | False | 27.3 | 31.2 | 22.0 | 1016.7 | 0.6 | False | 98.006 |
| 3 | 2023 | 1 | Bahrain Grand Prix | 2023_01 | VER | Red Bull Racing | 4.0 | 1.0 | SOFT | 7.0 | False | 27.3 | 31.2 | 22.0 | 1016.7 | 0.4 | False | 97.976 |
| 4 | 2023 | 1 | Bahrain Grand Prix | 2023_01 | VER | Red Bull Racing | 5.0 | 1.0 | SOFT | 8.0 | False | 27.2 | 31.0 | 22.0 | 1016.7 | 1.0 | False | 98.035 |
| 5 | 2023 | 1 | Bahrain Grand Prix | 2023_01 | VER | Red Bull Racing | 6.0 | 1.0 | SOFT | 9.0 | False | 27.1 | 31.0 | 22.0 | 1016.9 | 0.6 | False | 97.986 |
| 6 | 2023 | 1 | Bahrain Grand Prix | 2023_01 | VER | Red Bull Racing | 7.0 | 1.0 | SOFT | 10.0 | False | 27.1 | 30.9 | 22.0 | 1016.9 | 0.6 | False | 98.021 |
laps_clean.to_parquet('../data/laps_clean.parquet', index=False)
print('zapisano ../data/laps_clean.parquet')
zapisano ../data/laps_clean.parquet
Krok 4 - przygotowanie zbioru kierowców pod klasyfikację podium¶
Czy dany kierowca w danym Grand Prix stanie na podium?
Aby dokonać klasyfikacji - dodaliśmy kolumnę podium. Przyjmuje ona wartość 1, gdy kierowca jest w pierwszej trójce, oraz 0 w przeciwnym wypadku.
Podobnie jak w poprzednim przypadku - musimy zadbać, aby dołączyć tylko cechy znane przed wyścigiem. Do takich cech należą:
GridPosition- pozycja startowa. Wynika ona głównie z kwalifikacji, ale uwzględnia również ewentualne kary, z tego względu nie zawsze się pokrywa z wynikiem kwalifikacji.Team- zespółquali_gap_s- różnica czasu do najszybszego okrążenia kwalifikacji - mówi o ile kierowca był wolniejszy od czołówki.
Warto zaznaczyć, że GridPosition = 0 traktowany jest jako ostatnie pole - oznacza to start z alei serwisowej.
dr = res_raw.rename(columns={'Abbreviation': 'Driver', 'TeamName': 'Team'})
dr['GridPosition'] = dr['GridPosition'].replace(0, np.nan)
dr['GridPosition'] = dr['GridPosition'].fillna(
dr.groupby('race_id')['GridPosition'].transform('max') + 1)
dr = dr[dr['Position'].notna()].copy()
dr['podium'] = (dr['Position'] <= 3).astype(int)
cols = ['Season', 'Round', 'EventName', 'race_id', 'Driver', 'Team',
'GridPosition', 'Position', 'Points', 'Status', 'quali_gap_s', 'podium']
dr = dr[cols]
print('driver_race:', dr.shape)
print('quali_gap_s wypełnione:', dr['quali_gap_s'].notna().sum(), '/', len(dr))
print('odsetek podiów:', round(dr['podium'].mean(), 3))
dr.head()
driver_race: (918, 12) quali_gap_s wypełnione: 911 / 918 odsetek podiów: 0.15
| Season | Round | EventName | race_id | Driver | Team | GridPosition | Position | Points | Status | quali_gap_s | podium | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 2023 | 1 | Bahrain Grand Prix | 2023_01 | VER | Red Bull Racing | 1.0 | 1.0 | 25.0 | Finished | 0.000 | 1 |
| 1 | 2023 | 1 | Bahrain Grand Prix | 2023_01 | PER | Red Bull Racing | 2.0 | 2.0 | 18.0 | Finished | 0.138 | 1 |
| 2 | 2023 | 1 | Bahrain Grand Prix | 2023_01 | ALO | Aston Martin | 5.0 | 3.0 | 15.0 | Finished | 0.628 | 1 |
| 3 | 2023 | 1 | Bahrain Grand Prix | 2023_01 | SAI | Ferrari | 4.0 | 4.0 | 12.0 | Finished | 0.446 | 0 |
| 4 | 2023 | 1 | Bahrain Grand Prix | 2023_01 | HAM | Mercedes | 7.0 | 5.0 | 10.0 | Finished | 0.676 | 0 |
dr.to_parquet('../data/driver_race.parquet', index=False)
print('zapisano ../data/driver_race.parquet')
zapisano ../data/driver_race.parquet