Hazar Ekin Uçan

Selected work

F1 Race Result Prediction

Learning machine learning through a sport I follow. Historical Formula 1 results turned into a ranking model, served as an app that predicts a finishing order from a grid you type in.

Role
Data pipeline, model and application
Context
Self-directed, during a data-analysis internship
Period
Aug 2025
Stack
Python · LightGBM · pandas · Streamlit · BeautifulSoup

What it is A ranking model that predicts the finishing order of a Formula 1 race from the starting grid, wrapped in an interactive application.

What I built The data pipeline, the temporal feature engineering, the LightGBM ranking model, and the Streamlit app that runs it.

My role

I started this to teach myself machine learning on a subject I already followed closely, and worked through it from the raw historical tables to the running application: preparing and joining the data, the scraper that assembles the current season, the rolling form features and the shift that keeps a race out of its own features, the decision to train a ranking objective instead of a regressor, and the Streamlit interface with its grid entry, paste parsing and validation.

01

A subject I already cared about

The fastest way to learn a method is to point it at something you already argue about.

I follow Formula 1, and cars and the automotive side of engineering are what got me into this field in the first place. When I wanted to actually learn machine learning instead of reading about it, that seemed like the obvious place to start. I already knew what a good answer looked like, and I would notice straight away if a model told me something absurd.

That turned out to matter more than I expected. Knowing the sport is what caught the mistakes. If the model puts a driver in a midfield car on pole, I do not have to go and measure anything. I can see it is wrong and go and find out why.

The question I set the project was a narrow one, because a narrow question is answerable: given a starting grid for a particular Grand Prix, what finishing order should we expect?

02

Seventy-five years of results, shaped into a training set

Formula 1 history is well recorded. It is not well shaped for a model.

The historical data is the standard relational set: races, circuits, drivers, constructors, results, qualifying and status, going back to 1950. Getting from there to a training table is most of the work.

Results and races join on the race, qualifying collapses to each driver’s best position across Q1 to Q3, and the whole thing is sorted into the order the seasons actually happened so that anything computed over time is computed in the right direction.

The current season is not in the historical dataset, so I wrote a scraper for it. It reads the race classification tables from the Wikipedia pages of the 2025 Grands Prix run so far, normalises driver names and the many spellings of each team into stable slugs, and writes out the same shape as the historical files.

Training starts in 2021, not 1950. The sport’s regulations, car concepts and points systems have changed so much that a result from an earlier era is describing a different sport. The window is derived from the data instead of being hardcoded. The model trains up to the last complete season and predicts the one after it, so the app reports “training 2021 to 2024, predicting 2025” because that is what the files contain.

What the supplied archive actually holds, counted per season. Results reach back to 1950. Qualifying records only begin in the mid-1990s, which is one reason training starts in 2021 instead of at the beginning. A zero bar means no rows in these files, not that no racing happened, and none of this is a count of races or a measure of the model.
  • Historical results 27,038 race results, 1950 to 2025
  • Qualifying 10,774 sessions, reduced to one best position per driver per race
  • Training window 2021 to the last complete season
  • Current season Scraped from per-race Wikipedia classification tables
  • Objective Learning to rank: LightGBM lambdarank, ndcg, grouped by race

03

Only what was knowable before the lights went out

The interesting part of a sports model is not the algorithm. It is making sure the features could not have known the answer.

A driver’s recent form is the single most useful signal available, and it is also the easiest way to build a model that looks excellent and is worthless. If the rolling average of a driver’s finishing position includes this race, then the feature contains the label, and the model will learn to read the answer off it.

The four form features are built so that cannot happen. Each one is a rolling mean of the last three finishing positions, and each is shifted by one race first, so the value attached to a race is computed strictly from the races before it:

app.py · build_training_table()

df["drv_last3_mean"] = (
  df.groupby("driverId")["positionOrder"]
      # shift(1) first: this race cannot see its own result
    .transform(lambda s: s.shift(1).rolling(3, min_periods=1).mean())
)

The shift comes before the rolling window, so a race never contributes to its own feature.

How a driver's form feature is built for one training row: shift by one race, then average the three before it. This is a schematic, and it describes the driver-keyed features specifically. The constructor features shift by row instead of by race, so a team-mate's result from the same race can still fall inside that window.

The same construction gives four views of form at different resolutions: the driver overall, the driver at this particular circuit, the team overall, and the team at this circuit. Monaco and Monza reward such different cars that a team’s general form and its form at a given track are genuinely different numbers.

The second decision is the objective. Predicting a finishing position as a plain number treats the gap between first and second as identical to the gap between fifteenth and sixteenth, and it has no idea that exactly one car can finish first. So the model is trained to rank instead: LightGBM’s lambdarank objective, with the rows grouped by race, so each race is one ranking problem and the model is scored on the order it produces within that race instead of on absolute error per driver.

One race, one ranking group. The model is asked which drivers finish ahead of which, not what number each one finishes on. This is a schematic. The lettered feature rows are placeholders, and it is not a claim that the pipeline as a whole is free of leakage.
The application's feature inspector, showing the four rolling form features, the ranking score for each driver, and a status column that is empty for every row.
The app can show the features behind any prediction. The four form columns are the shifted rolling means, and pred_score is the ranking score the order is sorted by. The status column on the right is empty at prediction time. There is a note about that at the end of this page.

In my own evaluation of the project the model reached roughly 82% accuracy. That is the figure I recorded from my work at the time, and it is worth reading as what it is: a project result, not a benchmark I have since reproduced under a documented protocol. The metric definition and validation scheme are not written down anywhere in the material I kept. A number is only as useful as the record of how it was produced.

04

From a trained model to something you can actually use

A model in a notebook answers questions you have already asked. An application answers the ones you have not.

The last step was turning the trained model into something with a grid in it. The app loads the CSVs, builds the training table, trains the ranker once and caches it, then offers a Grand Prix, a driver count and twenty positions to fill.

The prediction application open on the Monaco Grand Prix, showing the training and prediction seasons it derived from the data and the start of the driver grid.
The application on load: the seasons it derived from the data, the Grand Prix selector, and the top of the grid waiting to be filled.

Filling twenty dropdowns by hand gets old immediately, so there is a paste box: drop in a list of names, one per line, first line is pole. It normalises Turkish characters so that names typed on a Turkish keyboard still match, and falls back to surname matching when the full name does not. It refuses to predict if the same driver appears twice, which is the kind of thing you only add after doing it to yourself.

The application showing a predicted finishing order for twenty drivers, each with the ranking score the order is sorted by.
The order the model expects a hand-entered grid to finish in, sorted by ranking score. The scores are relative within this race. They are not probabilities, lap times or positions.

For each driver on the grid the app resolves their current team, using this season if they have raced in it and their most recent season otherwise. It recomputes the four form features against the chosen circuit, aligns the categorical levels with the ones the model was trained on, and asks for a score. Sorting by that score is the prediction.

What the finished application does

  1. Loads and joins the historical race, qualifying, driver, team and circuit tables
  2. Derives its own training and prediction seasons from the data, not from a constant
  3. Builds four rolling form features, each shifted so a race cannot see its own result
  4. Trains a LightGBM lambdarank model with rows grouped one race at a time
  5. Takes a starting grid by dropdown or by pasted list, with duplicate-driver validation
  6. Resolves each driver to a current team and recomputes their form for the chosen circuit
  7. Returns a predicted finishing order, and can show every feature value behind it

Where the limits are

One honest caveat about the model. The feature list still includes the race status code, which records whether a car finished, retired or crashed. That is an outcome of the race and not something knowable before it, and at prediction time it is simply left empty. The rolling form features are properly guarded and the qualifying and grid inputs are legitimately known in advance, but I would not describe the pipeline as a whole as leak-free until that column is removed and the model retrained. Beyond that, the model knows nothing about weather, tyre strategy, reliability, upgrades or mid-season driver changes. A grid and a history of finishing positions is all it sees.

Evidence on this page: Source code · Generated output

Open to conversations about robotics, automation and software for physical systems.