Build an F1 Winner Predictor
Build a Python model that ranks each driver's chance of winning an F1 race.
Introduction
30 Second Summary
Before a Formula 1 race begins, the starting grid offers one clue while recent performance can point somewhere else. Picking a likely winner from those competing signals quickly becomes guesswork.
In this project, you will build a local F1 winner predictor using machine learning. A chronological backtest tests its pre-race probability estimates against real 2025 winners.
What You'll Build
Run your finished predictor to see every driver in a held-out 2025 race ranked by win estimate, with the actual finishing order beside the prediction.
By the end of this project, you'll have:
- A repeatable data download from the Jolpica F1 API covering completed races from 2021 through 2025. The terminal prints a sample from the latest cached race.
- Five-race form features built only from results known before each race. The model keeps target leakage out of its evidence.
- Race-level model evaluation showing logistic regression top-pick performance beside a pole-sitter baseline. Every race's probability estimates total 100%.
- Secret Mission: Train a gradient boosting challenger. Compare it with logistic regression on the identical 2025 backtest.
Are there any prerequisites?
This project uses Python 3.10+ on Windows through Visual Studio Code. Basic Python familiarity is enough for the machine learning work.
Before We Start
Before you begin, lock in the boundary that makes this predictor trustworthy. You are building a chronological backtest that uses information available before each race without claiming to guarantee its winner.
Set Up the Local ML Workspace
Your backtest needs the same package versions every time it runs. A shared Python installation can pick up conflicting versions from unrelated projects.
A project-specific virtual environment gives this predictor its own package space. Visual Studio Code keeps the project files beside the active terminal.
This isolated workspace makes the predictor reproducible before any race data enters the project.
In this step, get ready to:
- Create the f1-winner-predictor workspace in Visual Studio Code.
- Isolate the project inside an activated .venv environment.
- Install the pinned dependencies from requirements.txt.
Create the project workspace
A named folder keeps every script and generated file in one place. The requirements.txt file records the exact library versions that belong in that folder.
- Right-click an empty area of your Windows Desktop.
- Select New from the menu.
- Select Folder.
- Enter f1-winner-predictor as the folder name.
- Press Enter to create the folder.
You will see the f1-winner-predictor folder on your Desktop.
- Press the Windows key to open the search bar.
- Type Visual Studio Code into the search bar.
You will see Visual Studio Code in the search results.
- Press Enter to open Visual Studio Code.
- Click File in the top menu bar.
- Select Open Folder.
You will see the Windows folder picker.
- Select Desktop in the folder picker.
- Select the f1-winner-predictor folder.
The folder picker highlights f1-winner-predictor as the workspace to open.
- Click Select Folder.
You will see f1-winner-predictor at the top of the Explorer sidebar.
The file control is easy to miss because it sits beside the folder name in the Explorer sidebar.
- Click the New File control beside f1-winner-predictor.
You will see an empty filename field inside the Explorer sidebar.
- Enter requirements.txt in the filename field.
- Press Enter to create the file.
- Add the pinned dependency list by pasting this into requirements.txt:
pandas==2.3.3
scikit-learn==1.7.2
Why pin these versions?
- The pandas pin installs version 2.3.3 for loading and transforming race tables.
- The scikit-learn pin installs version 1.7.2 for the machine-learning pipeline.
- Both pinned releases support Python 3.10.
- Save requirements.txt by pressing Ctrl+S.
- Confirm that requirements.txt appears beneath f1-winner-predictor in the Explorer sidebar.
Cannot see the requirements file?
- Check that the Explorer sidebar shows the f1-winner-predictor folder at the top.
- Confirm that the filename ends with .txt.
Help me find or create requirements.txt.
✔️ Awesome, I've got everything!
Your dependency file now matches the project pins.
ⓧ I'd like to double check the full code
pandas==2.3.3
scikit-learn==1.7.2
What should match?
- Compare the first line with the pandas pin in your file.
- Compare the second line with the scikit-learn pin in your file.
Build the isolated environment
The .venv directory holds a private Python environment for this project. Packages installed there stay separate from your other Python work.
- Click Terminal in the Visual Studio Code menu bar.
- Select New Terminal.
You will see a terminal panel open at the bottom of Visual Studio Code. Its path should end with f1-winner-predictor.
- Create the project environment by running this command:
py -m venv .venv
What does this command do?
- The py launcher selects the installed Python interpreter.
- The venv module creates an isolated environment.
- The .venv value gives the environment its directory name.
- Wait for the terminal prompt to return.
- Confirm that .venv appears in the Explorer sidebar.
The environment folder did not appear?
- Check that the terminal path ends with f1-winner-predictor.
- Rerun the command after the terminal prompt returns if creation was interrupted.
Help me create the .venv environment.
Activation points the terminal's python command at the interpreter inside .venv.
- Activate the environment by running this command:
.\.venv\Scripts\activate
What does activation change?
- The activation script updates the current terminal session.
- Future python commands use the interpreter inside .venv.
- Future package installations go into this project's isolated environment.
- Confirm that the environment name appears at the start of the terminal prompt.
PowerShell blocked activation?
Windows can block activation scripts through its PowerShell execution policy.
- Use the terminal profile menu to open a Command Prompt terminal.
- Run the activation command above again in that terminal.
Help me activate my Windows virtual environment.
Install the pinned packages
The activated environment is ready to receive the project dependencies. First, the Windows launcher confirms that your installed Python meets the project's minimum version.
Before you run the check, which installed Python version do you expect the Windows launcher to report?
- Check the installed Python 3 version by running this command:
py -3 --version
What does this check prove?
The Windows Python launcher reports the Python 3 version available on your device. This project requires Python 3.10 or newer.
You will see Python 3.10 or newer in the terminal output.
The version does not match?
- Confirm that the output reports Python 3.10 or newer.
- Check that the terminal remains inside the f1-winner-predictor workspace.
Help me understand my Python version output.
Package installation can pause briefly while pip downloads the pinned releases. A short wait is normal during the first installation.
Before you install the packages, do you expect them to enter .venv or your system-wide Python installation?
- Install the dependencies from requirements.txt by running this command:
python -m pip install -r requirements.txt
What does this command do?
- The python -m pip portion runs pip through the active Python interpreter.
- The -r option tells pip to read a requirements file.
- The requirements.txt value points pip at your two package pins.
- Wait for the installation command to return to the activated terminal prompt.
- Keep this terminal open for the remaining project steps.
That is your reproducible workspace ready. The activated .venv now contains the pinned pandas and scikit-learn packages.
The package installation did not complete?
- Confirm that the environment name still appears at the start of the terminal prompt.
- Compare requirements.txt with the full-file reference above if pip reports a package requirement problem.
- Rerun the installation command after restoring your network connection if the download was interrupted.
Help me fix the package installation.
Your local ML workspace is isolated and ready for repeatable runs. Next, you will use Jolpica F1 data to turn that empty workspace into a visible race-results table.
Download Race Data
Your isolated Python workspace is ready. The predictor now needs completed race histories from the Jolpica F1 API.
A visible table of real grids and results gives you an immediate data product. It also lets you inspect the evidence before any machine-learning complexity appears.
In this step, get ready to:
- Create a downloader that extracts race results from 2021 through 2025.
- Download the results into a reusable local CSV file.
- Confirm that each completed race contains one winning driver.
Create the API and parsing layer
The API returns nested JSON containing races and driver results. Your parser turns that nested response into one flat row per driver.
- Click the New File button in the Visual Studio Code Explorer sidebar.
- Enter download_data.py as the file name.
You should now see download_data.py in the Explorer sidebar.
- Add the imports and downloader settings by pasting this code at the top of download_data.py:
import json
from pathlib import Path
from time import sleep
from urllib.parse import urlencode
from urllib.request import Request, urlopen
import pandas as pd
BASE_URL = "https://api.jolpi.ca/ergast/f1"
SEASONS = range(2021, 2026)
PAGE_SIZE = 100
USER_AGENT = "NextWorkF1Predictor/1.0"
OUTPUT_PATH = Path("data/f1_results.csv")
What does this setup do?
- The standard-library imports handle requests, JSON responses, URL parameters, file paths, and request pauses.
- Pandas turns the collected rows into a table that can be saved as a CSV file.
- The season range covers 2021 through 2025.
- The custom user agent identifies this downloader to Jolpica F1.
- Save download_data.py.
- Confirm that the editor shows the five configuration names below the imports.
Missing an import or setting?
Check that import pandas as pd sits below the standard-library imports. Confirm that every configuration name uses the same capitalization as the code block.
Help me check the imports and settings in download_data.py.
- Add the page-fetching function below OUTPUT_PATH by pasting this code:
def fetch_page(season, offset):
query = urlencode({"limit": PAGE_SIZE, "offset": offset})
url = f"{BASE_URL}/{season}/results/?{query}"
request = Request(url, headers={"User-Agent": USER_AGENT})
with urlopen(request, timeout=30) as response:
return json.load(response)
How does pagination work?
The fetch_page() function requests one page for a chosen season. The offset tells the API where that page begins.
Each request includes the configured page size and custom user agent. The response becomes a Python object that the parser can read.
- Save download_data.py.
- Confirm that fetch_page() appears below the configuration values.
Seeing URL syntax markers?
Check the braces inside the formatted URL. Make sure the request header keeps the exact User-Agent capitalization.
Help me fix the fetch_page function.
- Add the row-building portion of parse_page() below fetch_page() by pasting this code:
def parse_page(payload):
rows = []
races = payload["MRData"]["RaceTable"]["Races"]
for race in races:
for result in race["Results"]:
constructor = result.get("Constructor", {})
driver = result["Driver"]
season = int(race["season"])
round_number = int(race["round"])
rows.append(
{
"race_id": f"{season}-{round_number:02d}",
"season": season,
"round": round_number,
"date": race["date"],
"race_name": race["raceName"],
"circuit_id": race["Circuit"]["circuitId"],
"driver_id": driver["driverId"],
"driver_name": f"{driver['givenName']} {driver['familyName']}",
"constructor_id": constructor.get("constructorId", "unknown"),
"constructor_name": constructor.get("name", "Unknown"),
"grid": int(result.get("grid", "0")),
"finish_position": int(result["position"]),
"points": float(result["points"]),
"winner": int(result["position"] == "1"),
}
)
Which fields does the parser extract?
Each row records the season, round, race, circuit, driver, and constructor. It also records the starting grid value, finishing position, points, and winner label.
The winner value becomes 1 only when the finishing position equals 1.
- Complete parse_page() by adding this line after the nested loops:
return rows
Why return the rows?
The return value hands the flattened driver records back to the downloader. The main function can then combine records from every page.
- Save download_data.py.
- Confirm that return rows aligns with the first line inside parse_page().
Seeing indentation markers?
Keep the two loops nested inside parse_page(). Align return rows with rows = [].
Help me correct the parse_page indentation.
Download and cache the seasons
The downloader moves through one season page at a time. A short pause between pages keeps the request pace below Jolpica F1's unauthenticated burst limit.
- Add the pagination loop below parse_page() by pasting this code:
def main():
all_rows = []
for season in SEASONS:
offset = 0
while True:
payload = fetch_page(season, offset)
page_rows = parse_page(payload)
all_rows.extend(page_rows)
total = int(payload["MRData"]["total"])
offset += len(page_rows)
print(f"{season}: downloaded {offset} of {total} driver results")
if not page_rows or offset >= total:
break
sleep(0.35)
What does the download loop do?
- The outer loop moves through the five configured seasons.
- The inner loop requests pages until the offset reaches the reported total.
- The progress message shows how many driver results have been collected.
- The pause spaces out consecutive requests.
- Add the table cleanup below sleep(0.35) inside main() by pasting this code:
results = pd.DataFrame(all_rows)
results = results.sort_values(
["date", "race_id", "finish_position"]
).reset_index(drop=True)
maximum_grid = results.groupby("race_id")["grid"].transform("max")
results["grid_position"] = results["grid"].where(
results["grid"] > 0,
maximum_grid + 1,
)
Why clean the grid values?
The table is sorted chronologically so later feature calculations follow race order. A raw grid value of zero is moved behind the largest recorded grid position for that race.
This stops a pit-lane or unclassified start from looking like pole position.
- Save download_data.py.
- Confirm that the editor shows no red syntax markers in the two new sections.
Seeing an indentation warning?
Keep the table cleanup inside main(). Align results = pd.DataFrame(all_rows) with the season loop.
Help me check the main function indentation.
- Add the final column selection and CSV save logic below the grid cleanup by pasting this code:
output_columns = [
"race_id",
"season",
"round",
"date",
"race_name",
"circuit_id",
"driver_id",
"driver_name",
"constructor_id",
"constructor_name",
"grid_position",
"finish_position",
"points",
"winner",
]
results = results[output_columns]
OUTPUT_PATH.parent.mkdir(parents=True, exist_ok=True)
results.to_csv(OUTPUT_PATH, index=False)
How does the local cache work?
The column list fixes the CSV schema in a predictable order. The directory creation line makes the data folder when it is missing.
The final table is written to data/f1_results.csv without a separate index column.
- Add the terminal preview and script entry point below the CSV save logic by pasting this code:
latest_race_id = results.iloc[-1]["race_id"]
latest_race = results[results["race_id"] == latest_race_id]
print(f"\nSaved {len(results)} rows across {results['race_id'].nunique()} races.")
print(f"Latest cached race: {latest_race.iloc[0]['race_name']}")
print(
latest_race[
["grid_position", "driver_name", "constructor_name", "finish_position"]
]
.head(5)
.to_string(index=False)
)
if __name__ == "__main__":
main()
What does the preview prove?
The summary reports the saved row count and race count. The preview prints five drivers from the latest cached race.
The entry point runs main() when you execute the file from the terminal.
- Save download_data.py.
✔️ Awesome, I've got everything!
Your downloader now has the request, parsing, caching, and preview logic it needs.
ⓧ I'd like to double check the full code
- Compare your complete download_data.py file with this reference:
import json
from pathlib import Path
from time import sleep
from urllib.parse import urlencode
from urllib.request import Request, urlopen
import pandas as pd
BASE_URL = "https://api.jolpi.ca/ergast/f1"
SEASONS = range(2021, 2026)
PAGE_SIZE = 100
USER_AGENT = "NextWorkF1Predictor/1.0"
OUTPUT_PATH = Path("data/f1_results.csv")
def fetch_page(season, offset):
query = urlencode({"limit": PAGE_SIZE, "offset": offset})
url = f"{BASE_URL}/{season}/results/?{query}"
request = Request(url, headers={"User-Agent": USER_AGENT})
with urlopen(request, timeout=30) as response:
return json.load(response)
def parse_page(payload):
rows = []
races = payload["MRData"]["RaceTable"]["Races"]
for race in races:
for result in race["Results"]:
constructor = result.get("Constructor", {})
driver = result["Driver"]
season = int(race["season"])
round_number = int(race["round"])
rows.append(
{
"race_id": f"{season}-{round_number:02d}",
"season": season,
"round": round_number,
"date": race["date"],
"race_name": race["raceName"],
"circuit_id": race["Circuit"]["circuitId"],
"driver_id": driver["driverId"],
"driver_name": f"{driver['givenName']} {driver['familyName']}",
"constructor_id": constructor.get("constructorId", "unknown"),
"constructor_name": constructor.get("name", "Unknown"),
"grid": int(result.get("grid", "0")),
"finish_position": int(result["position"]),
"points": float(result["points"]),
"winner": int(result["position"] == "1"),
}
)
return rows
def main():
all_rows = []
for season in SEASONS:
offset = 0
while True:
payload = fetch_page(season, offset)
page_rows = parse_page(payload)
all_rows.extend(page_rows)
total = int(payload["MRData"]["total"])
offset += len(page_rows)
print(f"{season}: downloaded {offset} of {total} driver results")
if not page_rows or offset >= total:
break
sleep(0.35)
results = pd.DataFrame(all_rows)
results = results.sort_values(
["date", "race_id", "finish_position"]
).reset_index(drop=True)
maximum_grid = results.groupby("race_id")["grid"].transform("max")
results["grid_position"] = results["grid"].where(
results["grid"] > 0,
maximum_grid + 1,
)
output_columns = [
"race_id",
"season",
"round",
"date",
"race_name",
"circuit_id",
"driver_id",
"driver_name",
"constructor_id",
"constructor_name",
"grid_position",
"finish_position",
"points",
"winner",
]
results = results[output_columns]
OUTPUT_PATH.parent.mkdir(parents=True, exist_ok=True)
results.to_csv(OUTPUT_PATH, index=False)
latest_race_id = results.iloc[-1]["race_id"]
latest_race = results[results["race_id"] == latest_race_id]
print(f"\nSaved {len(results)} rows across {results['race_id'].nunique()} races.")
print(f"Latest cached race: {latest_race.iloc[0]['race_name']}")
print(
latest_race[
["grid_position", "driver_name", "constructor_name", "finish_position"]
]
.head(5)
.to_string(index=False)
)
if __name__ == "__main__":
main()
This reference contains the complete downloader that creates the target CSV artifact.
The first download makes several API requests, so expect a short wait while the seasons are collected. Progress lines in the terminal show that the process is still moving.
Before you run the downloader, which season do you expect to appear first in the progress output?
- Download the race results by running this command in the activated terminal:
python download_data.py
What should you see?
The terminal prints download progress for each season from 2021 through 2025. It then reports the saved rows and number of races.
The final lines name the latest cached race and show five drivers with their grid positions, constructors, and finishing positions.
That is a major milestone. Your project now has a reusable table of real F1 grids and results.
Downloader did not finish?
- Confirm that you ran the command from the f1-winner-predictor workspace.
- Check that the terminal still uses the activated .venv environment.
- Wait briefly before retrying if the API reports HTTP 429 Too Many Requests.
- Help me diagnose the downloader failure.
Inspect the cached race table
The local CSV is the reusable input for the rest of the project. Its winner column also provides the binary label that later scripts evaluate.
Before you inspect the table, which value should appear exactly once within each completed race?
- Expand the data folder in the Visual Studio Code Explorer sidebar.
- Select f1_results.csv to open the cached table.
- Confirm that the header includes race_id, grid_position, finish_position, points, and winner.
- Inspect the rows for a completed race to find its single winner = 1 entry.
You should see one driver row with winner = 1 for each race. Every other driver in that race has winner = 0.
Why keep the CSV?
The cached file lets later scripts reuse the same race history without repeating the API download. This keeps the remaining experiments consistent.
Your race dataset is cached and ready. Next up, you will test a deceptively accurate baseline that never predicts a winner.
Evaluate the Naive Baseline
Your cached race table now contains every driver result from 2021 through 2025. Each race has one winner among a much larger field of non-winners.
That shape creates class imbalance. This step deliberately tests whether ordinary row accuracy answers the real question of who wins each race.
In this step, get ready to:
- Calculate the row accuracy of predicting every driver as a non-winner.
- Evaluate a baseline that selects one driver per race.
- Explain which metric matches the winner prediction task.
Test row accuracy
Binary classification marks each row as one of two outcomes. In this dataset, the winner column uses 1 for the winning driver.
Every other driver has winner set to 0. Row accuracy counts how many of those individual labels a predictor gets right.
- Use the file sidebar in Visual Studio Code to create naive_baseline.py inside f1-winner-predictor.
- Paste this first baseline into naive_baseline.py.
import pandas as pd
DATA_PATH = "data/f1_results.csv"
def main():
results = pd.read_csv(DATA_PATH)
naive_accuracy = (results["winner"] == 0).mean()
print("All-negative predictor")
print(f"Row accuracy: {naive_accuracy:.1%}")
print("Winners selected: 0")
if __name__ == "__main__":
main()
What does this code do?
- The pandas library uses pd.read_csv to load the cached race results.
- The winner == 0 comparison checks which rows an all-negative prediction labels correctly.
- The mean() calculation converts those correct rows into an accuracy score.
- Save naive_baseline.py.
Before you run this, do you think row accuracy can look strong when the script selects no winners?
- Test the all-negative baseline in the active virtual environment terminal by running:
python naive_baseline.py
What should you see?
Your terminal shows a high value next to Row accuracy:. It also shows Winners selected: 0.
That result is the designed failure. A score can look strong while the predictor avoids choosing any winner.
Script cannot read the race data?
Confirm that you are using the activated terminal from the previous step. Check that data/f1_results.csv still exists inside f1-winner-predictor.
Help me debug the all-negative baseline.
Add one pick per race
The first run proved that row accuracy can reward a strategy that never wins a race. A pole-sitter baseline makes exactly one pick per race using grid_position equal to 1.
- Select the complete contents of naive_baseline.py.
- Replace the selection by pasting this version:
import pandas as pd
DATA_PATH = "data/f1_results.csv"
def main():
results = pd.read_csv(DATA_PATH)
naive_accuracy = (results["winner"] == 0).mean()
pole_picks = results[results["grid_position"] == 1]
pole_hit_rate = pole_picks["winner"].mean()
print("All-negative predictor")
print(f"Row accuracy: {naive_accuracy:.1%}")
print("Winners selected: 0")
print("\nPole-sitter baseline")
print(f"Race-level hit rate: {pole_hit_rate:.1%}")
print(f"Races evaluated: {len(pole_picks)}")
print(
"\nThe first number looks impressive but never picks a winner. "
"The race-level hit rate answers the real question."
)
if __name__ == "__main__":
main()
How does the comparison work?
- The pole_picks filter selects the driver whose grid_position equals 1 in each race.
- The pole_hit_rate calculation measures how often that single pick has winner equal to 1.
- The output keeps the misleading row score visible beside the race-level result.
- Save naive_baseline.py.
✔️ Awesome, I've got everything!
You now have both baselines in one saved script. The file is ready for a side-by-side test.
ⓧ I'd like to double check the full code
import pandas as pd
DATA_PATH = "data/f1_results.csv"
def main():
results = pd.read_csv(DATA_PATH)
naive_accuracy = (results["winner"] == 0).mean()
pole_picks = results[results["grid_position"] == 1]
pole_hit_rate = pole_picks["winner"].mean()
print("All-negative predictor")
print(f"Row accuracy: {naive_accuracy:.1%}")
print("Winners selected: 0")
print("\nPole-sitter baseline")
print(f"Race-level hit rate: {pole_hit_rate:.1%}")
print(f"Races evaluated: {len(pole_picks)}")
print(
"\nThe first number looks impressive but never picks a winner. "
"The race-level hit rate answers the real question."
)
if __name__ == "__main__":
main()
How is the full script organized?
- The first calculation scores an all-negative prediction across driver rows.
- The second calculation scores one pole-sitter pick per race.
- The final output places both results in the same terminal run.
Compare the metrics
The two metrics now face the same historical races. One scores every driver row.
The other makes one pick for each race. This is race-level evaluation.
Before you run the comparison, which baseline do you expect to answer who wins each race?
- Run both baselines in the active virtual environment terminal by entering:
python naive_baseline.py
What should you see?
The first section still shows high row accuracy. It also shows Winners selected: 0.
The second section reports Race-level hit rate: beside Races evaluated:. This result measures how often the pole-sitter won the race.
Missing the pole-sitter result?
Confirm that you saved the complete version of naive_baseline.py. Check that the pole_picks lines remain indented inside main.
Help me fix the baseline comparison.
That is the metric trap exposed. Your baseline now measures whether one concrete pick wins each race.
Your baseline now asks the right question. Next, you will build form features that use only information available before each race.
Build Leakage-Free Features
Your pole-sitter baseline now judges one pick per race. A learned predictor needs evidence of form before each start.
Historical results can leak the answer when the current race enters its own averages. That mistake is called target leakage.
You will use pandas to build five-race rolling features. shift(1) keeps each current result outside its feature window.
In this step, get ready to:
- Sort every result into chronological order.
- Calculate driver form from earlier races.
- Create a reusable feature CSV with race-level constructor form.
Build chronological driver form
Row order controls what counts as prior history. The rolling helper moves each series back by one race before calculating its five-race mean.
- Create build_features.py inside the f1-winner-predictor workspace with the New File button in the Explorer sidebar.
- Add the feature settings plus the first driver calculation by pasting this code into build_features.py:
import pandas as pd
INPUT_PATH = "data/f1_results.csv"
OUTPUT_PATH = "data/f1_features.csv"
WINDOW = 5
FEATURE_COLUMNS = [
"grid_position",
"driver_recent_finish",
"driver_recent_points",
"constructor_recent_finish",
"constructor_recent_points",
]
def prior_rolling_mean(series):
return series.shift(1).rolling(WINDOW, min_periods=1).mean()
def main():
results = pd.read_csv(INPUT_PATH, parse_dates=["date"])
results = results.sort_values(
["date", "race_id", "finish_position"]
).reset_index(drop=True)
driver_groups = results.groupby("driver_id", sort=False)
results["driver_recent_finish"] = driver_groups[
"finish_position"
].transform(prior_rolling_mean)
What does this code do?
- The path constants identify the downloaded results file plus the generated feature file.
- The FEATURE_COLUMNS list records the five inputs used by the predictor.
- The prior_rolling_mean() helper applies shift(1) before the rolling mean. This excludes the current race.
- Chronological sorting makes the shifted row represent an earlier race.
- Save build_features.py.
- Confirm build_features.py appears in the Explorer sidebar.
Cannot find the feature file?
- Check that build_features.py sits directly inside f1-winner-predictor.
- Use this guided check to locate the file.
Aggregate constructor form
A constructor can contribute multiple driver rows to one race. Race-level aggregation gives each constructor one historical value per event.
The same shifted rolling helper then excludes the constructor's current-race result. This protects every driver row from teammate leakage.
- Place your cursor directly below the driver_recent_finish transform in build_features.py.
- Add driver points plus constructor history by pasting this code below it:
results["driver_recent_points"] = driver_groups["points"].transform(
prior_rolling_mean
)
constructor_races = (
results.groupby(
["race_id", "date", "constructor_id"],
as_index=False,
sort=False,
)
.agg(
constructor_finish=("finish_position", "mean"),
constructor_points=("points", "sum"),
)
.sort_values(["date", "race_id", "constructor_id"])
)
constructor_groups = constructor_races.groupby("constructor_id", sort=False)
constructor_races["constructor_recent_finish"] = constructor_groups[
"constructor_finish"
].transform(prior_rolling_mean)
constructor_races["constructor_recent_points"] = constructor_groups[
"constructor_points"
].transform(prior_rolling_mean)
How does this block prevent leakage?
- The driver points calculation reuses the shifted rolling helper for consistent pre-race form.
- The constructor grouping produces one row for each constructor in each race.
- The constructor finish value uses the team's mean finishing position for that event.
- The constructor points value uses the team's total points for that event.
- Both constructor rolling features use earlier constructor races because prior_rolling_mean() shifts the grouped history first.
- Save build_features.py.
- Confirm the constructor block creates constructor_recent_finish plus constructor_recent_points.
Does the constructor block look misaligned?
- Keep every line in this block indented inside main().
- Check that the closing parentheses align with the statements that opened them.
- Use this indentation review if the editor highlights the block.
The constructor table now has one row for each team in each race. A many-to-one merge returns those prior values to every matching driver row.
- Place your cursor directly below the constructor_recent_points transform.
- Finish the merge plus CSV output by pasting this code below it:
constructor_features = constructor_races[
[
"race_id",
"date",
"constructor_id",
"constructor_recent_finish",
"constructor_recent_points",
]
]
features = results.merge(
constructor_features,
how="left",
on=["race_id", "date", "constructor_id"],
validate="many_to_one",
)
features.to_csv(OUTPUT_PATH, index=False)
sample = features.dropna(subset=FEATURE_COLUMNS).tail(8)
print(f"Saved {len(features)} feature rows to {OUTPUT_PATH}.")
print(
sample[["race_name", "driver_name", *FEATURE_COLUMNS]].to_string(
index=False
)
)
if __name__ == "__main__":
main()
What does the final block do?
- The smaller constructor table keeps only the identifiers plus the two prior-form columns.
- The many_to_one validation confirms that each driver row matches one constructor feature row.
- The merged table is saved to data/f1_features.csv without a DataFrame index.
- The printed sample keeps rows where all five model features have values.
- Save build_features.py.
- Confirm the final lines call main() when the script runs.
Is the final block outside main?
- Keep the merge through the final print statement indented inside main().
- Keep the final condition aligned with def main():.
- Use this structure check if the boundaries are unclear.
✔️ Awesome, I've got everything!
Your file now contains chronological sorting plus driver form. It also contains race-level constructor form plus CSV output.
ⓧ I'd like to double check the full code
import pandas as pd
INPUT_PATH = "data/f1_results.csv"
OUTPUT_PATH = "data/f1_features.csv"
WINDOW = 5
FEATURE_COLUMNS = [
"grid_position",
"driver_recent_finish",
"driver_recent_points",
"constructor_recent_finish",
"constructor_recent_points",
]
def prior_rolling_mean(series):
return series.shift(1).rolling(WINDOW, min_periods=1).mean()
def main():
results = pd.read_csv(INPUT_PATH, parse_dates=["date"])
results = results.sort_values(
["date", "race_id", "finish_position"]
).reset_index(drop=True)
driver_groups = results.groupby("driver_id", sort=False)
results["driver_recent_finish"] = driver_groups[
"finish_position"
].transform(prior_rolling_mean)
results["driver_recent_points"] = driver_groups["points"].transform(
prior_rolling_mean
)
constructor_races = (
results.groupby(
["race_id", "date", "constructor_id"],
as_index=False,
sort=False,
)
.agg(
constructor_finish=("finish_position", "mean"),
constructor_points=("points", "sum"),
)
.sort_values(["date", "race_id", "constructor_id"])
)
constructor_groups = constructor_races.groupby("constructor_id", sort=False)
constructor_races["constructor_recent_finish"] = constructor_groups[
"constructor_finish"
].transform(prior_rolling_mean)
constructor_races["constructor_recent_points"] = constructor_groups[
"constructor_points"
].transform(prior_rolling_mean)
constructor_features = constructor_races[
[
"race_id",
"date",
"constructor_id",
"constructor_recent_finish",
"constructor_recent_points",
]
]
features = results.merge(
constructor_features,
how="left",
on=["race_id", "date", "constructor_id"],
validate="many_to_one",
)
features.to_csv(OUTPUT_PATH, index=False)
sample = features.dropna(subset=FEATURE_COLUMNS).tail(8)
print(f"Saved {len(features)} feature rows to {OUTPUT_PATH}.")
print(
sample[["race_name", "driver_name", *FEATURE_COLUMNS]].to_string(
index=False
)
)
if __name__ == "__main__":
main()
How is the file organized?
The file defines one shared rolling helper. Its main() function builds driver features before aggregating constructor history.
The final section merges both feature sets. It saves the complete table before printing a sample.
Run and inspect the feature builder
The completed script can now create the reusable training table. This final run checks the full feature-building path.
Before you run it, which prior-form columns do you expect to appear beside grid_position?
- Run the feature builder in the activated .venv terminal with this command:
python build_features.py
What should you see?
Your terminal prints a saved-row message followed by eight later-race rows. Each row shows grid_position plus non-empty values for the four prior-form features.
The script also creates data/f1_features.csv inside the existing data folder.
- Open data/f1_features.csv from the Explorer sidebar.
- Confirm later-race rows contain values in driver_recent_finish.
- Confirm those rows contain values in driver_recent_points.
- Confirm those rows contain values in constructor_recent_finish.
- Confirm those rows contain values in constructor_recent_points.
Missing the feature CSV?
- Check that the activated terminal is still inside the f1-winner-predictor folder.
- Confirm data/f1_results.csv still exists before rerunning the feature builder.
- Compare the terminal's final traceback line with the matching section of build_features.py.
- Use this feature-builder diagnosis for focused help.
That is the crucial safeguard in place. Every form value now comes from races already completed.
Your feature table is ready for a fair test. Next up, you will train the predictor on earlier seasons before backtesting it on 2025.
Train and Backtest the Predictor
Your leakage-free feature table is ready. It gives the predictor pre-race evidence from earlier results without revealing the outcome it needs to predict.
The high row accuracy from the naive baseline rewarded a model that never selected a winner. A scikit-learn Pipeline now trains on 2021 through 2024. A chronological holdout tests one top-ranked driver per 2025 race.
In this step, get ready to:
- Build one preprocessing and logistic regression pipeline.
- Train the model on 2021 through 2024.
- Backtest race-level winner rankings on 2025.
Build the training pipeline
Early-season rows can have missing rolling form values. Median imputation fills those gaps before scaling prepares the numeric features for logistic regression.
- Create train_predictor.py inside the f1-winner-predictor folder using Visual Studio Code's file sidebar.
- Add the imports and shared configuration by pasting this code:
import pandas as pd
from sklearn.impute import SimpleImputer
from sklearn.linear_model import LogisticRegression
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
DATA_PATH = "data/f1_features.csv"
TEST_SEASON = 2025
FEATURES = [
"grid_position",
"driver_recent_finish",
"driver_recent_points",
"constructor_recent_finish",
"constructor_recent_points",
]
What does this setup do?
- Pandas loads the leakage-free feature table from data/f1_features.csv.
- The scikit-learn imports provide missing-value handling, scaling, model training, and one connected workflow.
- The FEATURES list restricts the model to grid position plus information calculated from earlier races.
- Save train_predictor.py.
- Confirm that train_predictor.py is listed beside your other Python files in the file sidebar.
File missing from the workspace?
Check that you created the file inside f1-winner-predictor. Help me place train_predictor.py in the correct workspace folder.
The training function keeps the split chronological. Rows before TEST_SEASON become training examples. Rows from 2025 remain untouched until scoring.
- Add the training function below the closing bracket of FEATURES by pasting this code:
def main():
data = pd.read_csv(DATA_PATH, parse_dates=["date"])
train = data[data["season"] < TEST_SEASON].copy()
test = data[data["season"] == TEST_SEASON].copy()
model = Pipeline(
[
("imputer", SimpleImputer(strategy="median")),
("scaler", StandardScaler()),
("model", LogisticRegression(max_iter=1000)),
]
)
model.fit(train[FEATURES], train["winner"])
How does the pipeline train?
- The chronological filters prevent 2025 results from entering model training.
- The median imputer learns replacement values from the training rows.
- The scaler puts features on comparable numeric scales before logistic regression fits the winner labels.
- Save train_predictor.py.
- Confirm that the editor shows no red syntax markers in the training function.
Seeing syntax markers?
Check the indentation inside main(). Make sure every opening bracket has a matching closing bracket. Help me fix the training function syntax.
Score the 2025 holdout
Each driver receives an independent positive-class estimate. Race normalization divides those estimates by their race total so every field adds up to 100%.
- Find the closing bracket of FEATURES in train_predictor.py.
- Insert the normalization helper directly below it by pasting this code:
def normalize_within_race(frame, raw_probabilities):
scored = frame.copy()
scored["raw_win_probability"] = raw_probabilities
race_totals = scored.groupby("race_id")["raw_win_probability"].transform("sum")
scored["win_probability"] = scored["raw_win_probability"] / race_totals
return scored
Why normalize by race?
- The helper preserves the scored rows in a copy of the holdout data.
- The grouped total collects every driver's raw estimate within the same race.
- Dividing by that total produces comparative estimates that sum to one full race result.
- Save train_predictor.py.
- Confirm that normalize_within_race appears between FEATURES and main().
Helper in the wrong place?
Keep normalize_within_race outside main() by aligning both function definitions at the left edge. Help me correct the helper indentation.
The model now needs to select one driver per held-out race. Sorting by normalized probability turns the row-level scores into a race-level top pick.
- Find model.fit(train[FEATURES], train["winner"]) inside main().
- Add the scoring logic and race-level metrics directly below it by pasting this code:
positive_class_index = list(model.classes_).index(1)
raw_probabilities = model.predict_proba(test[FEATURES])[:, positive_class_index]
scored = normalize_within_race(test, raw_probabilities)
ordered = scored.sort_values(
["race_id", "win_probability"],
ascending=[True, False],
)
top_picks = ordered.drop_duplicates("race_id", keep="first")
pole_picks = scored[scored["grid_position"] == 1]
actual_winners = scored[scored["winner"] == 1]
print("2025 chronological backtest")
print(f"Model top-pick hit rate: {top_picks['winner'].mean():.1%}")
print(f"Pole-sitter hit rate: {pole_picks['winner'].mean():.1%}")
print(
"Average probability assigned to the actual winner: "
f"{actual_winners['win_probability'].mean():.1%}"
)
if __name__ == "__main__":
main()
How does the backtest work?
- The positive-class index locates the model probability for winner = 1.
- The first driver after each race-level sort becomes the model's single pick.
- The model hit rate is measured beside the pole-sitter hit rate from grid_position = 1.
- The actual-winner average shows how much probability the model assigned to the drivers who won.
- Save train_predictor.py.
Before you run this backtest, make a quick mental prediction about which hit rate will be higher.
- Run the first version of the backtest in the activated terminal by using this command:
python train_predictor.py
What should you see?
You will see the 2025 chronological backtest heading followed by the model top-pick hit rate.
You will also see the pole-sitter hit rate plus the average probability assigned to actual winners. These race-level measures answer the prediction question that row accuracy missed.
Backtest not running?
Confirm that the activated .venv terminal is still inside f1-winner-predictor. Check that data/f1_features.csv remains inside the data folder.
Help me diagnose the backtest error.
Print the held-out ranking
Aggregate hit rates summarize the season. A ranked table makes one held-out race inspectable by showing the model estimate beside the grid and actual finish.
- Find if __name__ == "__main__": near the bottom of train_predictor.py.
- Insert the latest-race table preparation immediately above it by pasting this code:
latest_race_id = scored.sort_values("date").iloc[-1]["race_id"]
race_table = scored[scored["race_id"] == latest_race_id].sort_values(
"win_probability",
ascending=False,
)
display = race_table[
[
"driver_name",
"constructor_name",
"grid_position",
"win_probability",
"finish_position",
]
].copy()
display["win_probability"] = display["win_probability"].map(
lambda value: f"{value:.1%}"
)
How is the race table prepared?
- Sorting by date identifies the latest race in the 2025 holdout.
- The race filter keeps every driver from that event.
- The display copy selects the five columns needed to compare ranking evidence with the actual result.
- The mapping step formats each normalized estimate as a percentage.
- Save train_predictor.py.
- Confirm that the new block remains indented inside main().
Ranking block outside main?
Align latest_race_id with the earlier statements inside main(). Keep the final execution guard aligned at the left edge.
Help me fix the ranking block indentation.
The final output prints the ranking before checking its total. It also labels the actual winner so you can compare the model's order with the race result.
- Insert the output block directly below the display["win_probability"] mapping block by pasting this code:
print(f"\nBacktest race: {race_table.iloc[0]['race_name']}")
print(display.to_string(index=False))
print(f"Probability total: {race_table['win_probability'].sum():.1%}")
print(
"Actual winner: "
f"{race_table.loc[race_table['winner'] == 1, 'driver_name'].iloc[0]}"
)
print("These are comparative model estimates, not calibrated betting odds.")
What does the final output prove?
- The ranked rows show which driver the model rates highest in the latest held-out race.
- The grid and finish columns let you compare pre-race evidence with the completed result.
- The probability total verifies that the field-wide estimates were normalized within one race.
- The final caveat keeps the estimates framed as model comparisons instead of calibrated betting odds.
- Save train_predictor.py.
Before you run the finished file, predict whether the estimates for one race will total exactly 100%.
- Run the complete backtest in the activated terminal by using this command:
python train_predictor.py
What should the finished backtest show?
You will see the 2025 model hit rate beside the pole-sitter hit rate. The terminal also prints the average probability assigned to actual winners.
Below those metrics, you will see a ranked driver table for the latest held-out race. The final check reads Probability total: 100.0%.
Probability total missing?
Check that both ranking blocks sit before the final execution guard. Confirm that win_probability is divided by race_totals inside normalize_within_race.
Help me debug the final probability table.
That is the backtest working. Your model now makes one race-level pick from information available before each held-out event.
✔️ Awesome, I've got everything!
Your train_predictor.py file now contains the complete chronological backtest.
ⓧ I'd like to double check the full code
The reference below shows the exact finished contents of train_predictor.py.
import pandas as pd
from sklearn.impute import SimpleImputer
from sklearn.linear_model import LogisticRegression
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
DATA_PATH = "data/f1_features.csv"
TEST_SEASON = 2025
FEATURES = [
"grid_position",
"driver_recent_finish",
"driver_recent_points",
"constructor_recent_finish",
"constructor_recent_points",
]
def normalize_within_race(frame, raw_probabilities):
scored = frame.copy()
scored["raw_win_probability"] = raw_probabilities
race_totals = scored.groupby("race_id")["raw_win_probability"].transform("sum")
scored["win_probability"] = scored["raw_win_probability"] / race_totals
return scored
def main():
data = pd.read_csv(DATA_PATH, parse_dates=["date"])
train = data[data["season"] < TEST_SEASON].copy()
test = data[data["season"] == TEST_SEASON].copy()
model = Pipeline(
[
("imputer", SimpleImputer(strategy="median")),
("scaler", StandardScaler()),
("model", LogisticRegression(max_iter=1000)),
]
)
model.fit(train[FEATURES], train["winner"])
positive_class_index = list(model.classes_).index(1)
raw_probabilities = model.predict_proba(test[FEATURES])[:, positive_class_index]
scored = normalize_within_race(test, raw_probabilities)
ordered = scored.sort_values(
["race_id", "win_probability"],
ascending=[True, False],
)
top_picks = ordered.drop_duplicates("race_id", keep="first")
pole_picks = scored[scored["grid_position"] == 1]
actual_winners = scored[scored["winner"] == 1]
print("2025 chronological backtest")
print(f"Model top-pick hit rate: {top_picks['winner'].mean():.1%}")
print(f"Pole-sitter hit rate: {pole_picks['winner'].mean():.1%}")
print(
"Average probability assigned to the actual winner: "
f"{actual_winners['win_probability'].mean():.1%}"
)
latest_race_id = scored.sort_values("date").iloc[-1]["race_id"]
race_table = scored[scored["race_id"] == latest_race_id].sort_values(
"win_probability",
ascending=False,
)
display = race_table[
[
"driver_name",
"constructor_name",
"grid_position",
"win_probability",
"finish_position",
]
].copy()
display["win_probability"] = display["win_probability"].map(
lambda value: f"{value:.1%}"
)
print(f"\nBacktest race: {race_table.iloc[0]['race_name']}")
print(display.to_string(index=False))
print(f"Probability total: {race_table['win_probability'].sum():.1%}")
print(
"Actual winner: "
f"{race_table.loc[race_table['winner'] == 1, 'driver_name'].iloc[0]}"
)
print("These are comparative model estimates, not calibrated betting odds.")
if __name__ == "__main__":
main()
What does this reference contain?
This is the complete cumulative file for the predictor. It includes the chronological split, preprocessing pipeline, race normalization, evaluation metrics, and latest-race table.
You now have a reproducible winner-ranking system that tests future-season races against a pole-sitter baseline.
Secret mission
Challenge Logistic Regression with Gradient Boosting
Challenge your logistic regression predictor with a gradient boosting model. Both models face the same untouched 2025 races, so the race-level hit rate decides whether the challenger earns a place.
Clean Up Your Resources
Clean Up Your Resources
Your predictor lives entirely inside the local f1-winner-predictor workspace with no ongoing charges. Choose whether to keep the workspace, pause your work, or delete the local files.
Resources you used:
- Environment: A local Python virtual environment in .venv with the pinned project dependencies.
- Project code: The requirements.txt, download_data.py, naive_baseline.py, build_features.py, train_predictor.py, and compare_models.py files.
- Generated data: The cached data/f1_results.csv dataset plus the leakage-free data/f1_features.csv dataset.
Keep everything running
Keep the workspace intact if you want to repeat the backtest or continue experimenting. The local files create no ongoing charges.
- Leave the f1-winner-predictor folder in its current location.
- Keep data/f1_results.csv if you want to reuse the downloaded race results.
- Keep data/f1_features.csv if you want to rerun either model comparison without rebuilding the features.
Pause - I'll come back to this later
Pausing ends your current work session while preserving every local file. No process or remote resource remains active after the apps close.
- Close the activated terminal to end the current .venv session.
- Close Visual Studio Code when you are finished.
- Return later by opening the existing f1-winner-predictor workspace.
Your environment, scripts, and CSV files remain on your computer. Your backtest is ready when you return.
Delete - I don't want to use this again
Deletion can target the generated datasets alone or the complete workspace. The data-only option preserves your reusable code.
Choose the Cleanup Scope
Removing both CSV files clears the downloaded and engineered data. Removing the entire f1-winner-predictor folder also clears your scripts, dependencies, and virtual environment.
To clear only downloaded and generated data:
- In the Visual Studio Code file sidebar, right-click data/f1_results.csv.
- Select Delete to remove the cached race results.
- Right-click data/f1_features.csv.
- Select Delete to remove the engineered feature data.
That clears both datasets while leaving your predictor reusable. The scripts can regenerate the files whenever you need them.
To remove the entire project:
- Close Visual Studio Code.
- Press the Windows key to open search.
- Search for File Explorer.
- Open File Explorer from the search results.
- Locate the f1-winner-predictor folder where you created it.
- Right-click the f1-winner-predictor folder.
- Select Delete to remove the complete workspace.
The f1-winner-predictor folder no longer appears in its previous location. Your virtual environment, project code, and generated datasets are now removed.
Nice Work!
Nice Work!
You did it! Your local F1 predictor now ranks every driver with a race-normalized win estimate. A chronological 2025 backtest tests those picks against races the model did not train on.
You built more than a prediction script. You created an evidence-based workflow that collects real results, protects its features from future information, and measures success one race at a time.
You've learned how to:
- Build a paginated Jolpica F1 downloader that caches completed 2021 through 2025 race results in a reusable CSV file.
- Expose the class imbalance trap by comparing an all-negative row-accuracy result with a pole-sitter race-level baseline.
- Prevent target leakage with five-race driver and constructor features built from earlier races only. Train a logistic regression pipeline on 2021 through 2024. Backtest race-normalized 2025 rankings whose estimates total 100% within each race.
- Secret Mission: Compare logistic regression with a gradient boosting challenger on the same untouched 2025 races. Use the identical feature set and race-level top-pick metric to decide whether the nonlinear model earns its place.
Ready to quiz yourself?