Skip to main content

How to Set Up a Machine Learning or Data Science Project

·10 mins
Author
Alex

In the previous guide we went through the four steps of getting started with a project: deciding on what value to provide, finding data, extracting the value, and presenting the value.

In this post we will start walking through the mechanics of extracting and presenting the value, the actual nuts and bolts of a data science or machine learning project.

We will apply two important principles from software engineering: we will use test-driven development, and, we will start end to end and improve in short iterations. Why use test-driven development? Because it reduces bugs, increases quality and velocity, and, makes AI agents more productive. Why first build a simple, working end-to-end version and improve iteratively instead of perfecting each piece? Because it makes it easier to fail fast, makes it easier to understand exactly what is needed, and, allows you to deliver value faster.

That is a whole lot of software engineering philosophy baked into one paragraph. We’ll leave it at that because we want to do an actual project.

We will use our answers from the previous guide for what value we will provide and what data we will use:

We will predict the outcome of the 2026 Brazilian election, using past outcomes and past and current polling data.

Let’s get started.

Local setup #

To set up the python environment and dependencies, run the following commands (which also create the project directory).

uv init --python 3.12 brazil-election-forecast
cd brazil-election-forecast
uv add pandas numpy requests beautifulsoup4 html5lib lxml
uv add --dev pytest pytest-cov ruff

Write the test #

The first rule of test-driven development (TDD) is to write a test first, run the test, see it fail, then implement the code that will make the test run. Once the test runs you can move on to the next step (in truth the intermediate step is to refactor the code to improve it. We won’t focus on that here because we’ll try to improve the model instead).

If we boil down our entire project to just one function, it would be a function that predicts the election result from a new observation (row of data). Remember, the data will be percentages of voters intending to vote for each candidate, gathered by opinion polls.

It would look something like this:

from model import predict

def test_predict_returns_a_prediction():
    observation = {"a_name": "Lula", "a_pct": 47.0, "b_name": "Bolsonaro", "b_pct": 44.0}
    winner, margin = predict(observation)
    assert winner is not None
    assert margin is not None

There’s no model.py, let alone a predict function yet, so this test will fail. That is what we want, now we know that the test works and produces true positives.

Now let’s implement the simplest prediction function we can think of. The simplest one I can think of that uses the data is to just assume that the current polling numbers will stay the same until the election, and so just predict the winner as the candidate with the max percentage.

That would look like this:

def predict(observation):
    a_name, a_pct = observation["a_name"], observation["a_pct"]
    b_name, b_pct = observation["b_name"], observation["b_pct"]
    winner = a_name if a_pct > b_pct else b_name
    margin = abs(a_pct - b_pct)
    return winner, margin

Getting the data #

Great, now we have a prediction function, but no data to apply it to. We need to download, parse and clean the data from Wikipedia.

What would the end result look like here? That we have a function that returns all the data we need, clean and nicely formatted.

A test for that would look like this:

from data import parse_tables

FIXTURE_HTML = """
<table>
<tr><th>Firm</th><th>Lula</th><th>Bolsonaro</th></tr>
<tr><td>Poll A</td><td>47%</td><td>44%</td></tr>
</table>
"""

def test_parse_tables_returns_correct_structure():
    tables = parse_tables(FIXTURE_HTML)
    assert len(tables) == 1
    df = tables[0]
    assert list(df.columns) == ["Firm", "Lula", "Bolsonaro"]
    assert df.iloc[0]["Lula"] == "47%"

Notice that in this example we are using some example HTML as input. Parsing the web page into a table is a “pure” function, it can be done separately from downloading it from the web. When working with web or api data, you can save sample data locally and iterate on parsing them much faster in that way.

This will fail, we need to write parse_tables:

def download(url):
    r = requests.get(url, headers=HEADERS, timeout=20)
    r.raise_for_status()
    return r.text


def parse_tables(html):
    return pd.read_html(io.StringIO(html))


def get_tables(url):
    return parse_tables(download(url))

These functions are very short because of the library function read_html which does all the heavy lifting of parsing and searching the HTML. So that’s great!

Validation #

At this stage we can download data from the internet, apply our model to it, and extract some value (our prediction of who will win). Before we present this data, we want to validate our model.

To validate our model, we need historical data on opinion polls for past elections, because, for those elections we know who won, so we can validate our forecast against a true outcome.

With this data we can validate how our model does. We can make this check repeatable and routine by writing it as a test as well:

from validate import run_validation


def test_validation_gives_correct_results_both_times():
    results = run_validation()
    for result in results:
        print(
            f"{result['year']} {result['candidates']}: "
            f"predicted {result['predicted_winner']} "
            f"(actual {result['actual_winner']}), "
            f"margin {result['predicted_margin']} vs actual {result['actual_margin']}"
        )
    assert all(result["correct"] for result in results)

The model does well on the limited data we have:

test_validate.py::test_validation_gives_correct_results_both_times 2018 Haddad vs Bolsonaro: predicted Bolsonaro (actual Bolsonaro), margin 9.2 vs actual 10.3
2022 Bolsonaro vs Lula: predicted Lula (actual Lula), margin 5.1 vs actual 1.8
PASSED

============================== 1 passed in 1.37s ===============================

Because we have so little data, we can’t do much more validation than this.

Presentation #

Now that we have a validated model that extracts value, we need to present that value to the user. The simplest way we can present it is just a single plain text sentence.

from present import render_summary

def test_render_summary_formats_the_forecast():
    forecast = {
        "poll_average": {"Lula": 47.1, "F. Bolsonaro": 43.8},
        "current_lean": "Lula",
        "margin": 3.3,
    }

    summary = render_summary(forecast)

    assert summary == "Lula leads F. Bolsonaro by 3.3 points (47.1% to 43.8%)."

This will fail, we need to implement render_summary:

def render_summary(forecast):
    leader = forecast["current_lean"]
    candidates = forecast["poll_average"]
    trailer = next(name for name in candidates if name != leader)
    return (
        f"{leader} leads {trailer} by {forecast['margin']} points "
        f"({candidates[leader]}% to {candidates[trailer]}%)."
    )

Printing out the sentence works, but it’s very cumbersome to deliver that value to other people. They would have to download and run the code to get the value. We want people to just be able to see it in their browser. So we need to deploy our results somehow.

Deploying #

Like before, we will start with a test. To make the result available on the internet, we first have to present it in an HTML page. So that is the first test:

from present import render_html, render_summary


def test_render_html_embeds_the_summary():
    forecast = {
        "poll_average": {"Lula": 47.1, "F. Bolsonaro": 43.8},
        "current_lean": "Lula",
        "margin": 3.3,
    }
    html = render_html(forecast)
    assert render_summary(forecast) in html
    assert "<html" in html

This will fail, we need to implement render_html:

def render_html(forecast):
    summary = render_summary(forecast)
    return f"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Brazil Election Forecast</title>
</head>
<body>
<h1>Brazil Election Forecast</h1>
<p>{summary}</p>
</body>
</html>
"""


def build():
    with open("forecast.json") as f:
        data = json.load(f)
    forecast = data["forecast_2026"]
    os.makedirs("public", exist_ok=True)
    with open("public/index.html", "w") as f:
        f.write(render_html(forecast))


if __name__ == "__main__":
    build()

Now that we can produce this html, we want to deploy it on the internet. As before, we start with a test. This test is different, because we want to test for the existence of our results page on the internet.

import requests

# make sure to update this url once you have set up your project.
PAGES_URL = "https://<your-project>-<hash>.gitlab.io/"


def test_deployed_page_is_live_and_shows_the_forecast():
    response = requests.get(PAGES_URL, timeout=10)
    assert response.status_code == 200
    assert "leads" in response.text
    assert "Lula" in response.text or "F. Bolsonaro" in response.text

This will fail, we need to set it up. First we will use the GitLab CLI tool to set it up, so that we have all the steps recorded. Make sure to update your project and username inside the brackets (i.e. replace <your-username> with your username). Also make sure to update the PAGES_URL in test we showed above.

glab repo create <your-project> --private -d "<your-project-description>"
git remote add origin git@gitlab.com:<your-username>/<your-project>.git
git add -A
git commit -m "Model, data, validation, presentation"
git push -u origin main
glab api projects/<your-username>%2F<your-project> --method PUT -f pages_access_level=public

Once we have the project setup, we will add a GitLab CI (continuous integration) configuration. This lives in a file called .gitlab-ci.yml. In our case, this file will tell GitLab to, every time there is a commit, run a Pages deployment, using the steps under script and save the results to the public directory (so that they can be served).

Once you have more complicated and expensive models, these CI instructions will become longer and have more steps: likely you will push the code to separate infrastructure that runs the model training, then upload the model somewhere else, where it can be used to predict, etc. You can do all of that using this kind of CI configuration.

# the docker image the CI job runs in -- has uv preinstalled
image: ghcr.io/astral-sh/uv:python3.12-bookworm-slim

# GitLab treats a job named "pages" specially: its artifacts become the
# published Pages site
pages:
  stage: deploy
  # the commands that actually run, in order
  script:
    - uv sync
    - uv run main.py
    - uv run python present.py
  # which files/directories to publish
  artifacts:
    paths:
      - public
  # when this job should run
  rules:
    - if: $CI_COMMIT_BRANCH == "main"

Now that this is set up, make sure to push, that should trigger the CI.

git add .gitlab-ci.yml
git commit -m "Deploy via GitLab Pages"
git push

Now run the test above again, this time it should work. You can visit the site also and see the prediction. At this point we are done with the first iteration!

But if we leave it at this we lose out on one of the biggest advantages with software: it can be scheduled to run regularly so that it can continue to deliver value forever (or, at least until the next election in Brazil in our case).

Now what and tying the parts together #

Let’s schedule it to run on a cadence. By looking at the historical data we can see approximately how often opinion polls are updated. We can then schedule our deployment to run at that schedule:

A scheduled pipeline in GitLab is a separate resource from .gitlab-ci.yml itself, created via the API (or the web UI), so the CI file first needs a rule that lets the pages job run when triggered by a schedule and not just by a push. Scheduled pipelines don’t set $CI_COMMIT_BRANCH, they set $CI_PIPELINE_SOURCE to "schedule" instead:

  rules:
    - if: $CI_COMMIT_BRANCH == "main"
    - if: $CI_PIPELINE_SOURCE == "schedule"

Then create the schedule itself:

glab api projects/<your-username>%2F<your-project>/pipeline_schedules \
  --method POST \
  -f description="Daily forecast refresh" \
  -f ref="main" \
  -f cron="0 9 * * *" \
  -f cron_timezone="UTC" \
  -f active="true"

Now that we have an actively updating web page, it would be nice to convey that to users, so that they can see when it was last updated. Let’s add two pieces of information: when the page was last updated, and, when the data was last updated. As usual, we’ll write a test for that first:

def test_render_html_shows_when_the_page_and_data_were_last_updated():
    forecast = {
        "poll_average": {"Lula": 47.1, "F. Bolsonaro": 43.8},
        "current_lean": "Lula",
        "margin": 3.3,
        "generated_at": "2026-08-27T09:00:00+00:00",
        "poll_dates": ["20 August 2026", "15 August 2026", "10 August 2026"],
    }
    html = render_html(forecast)
    assert forecast["generated_at"] in html
    assert forecast["poll_dates"][0] in html

This fails, we need render_html to actually show these two things.

def render_html(forecast):
    summary = render_summary(forecast)
    generated_at = forecast.get("generated_at", "unknown")
    poll_dates = forecast.get("poll_dates")
    last_poll_date = poll_dates[0] if poll_dates else "unknown"
    return f"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Brazil Election Forecast</title>
</head>
<body>
<h1>Brazil Election Forecast</h1>
<p>{summary}</p>
<p>Page last updated: {generated_at}</p>
<p>Data last updated: {last_poll_date}</p>
</body>
</html>
"""

generated_at lives at the top level of forecast.json, not inside forecast_2026, so build() needs to carry it over before calling render_html:

def build():
    with open("forecast.json") as f:
        data = json.load(f)
    forecast = data["forecast_2026"]
    forecast["generated_at"] = data["generated_at"]
    os.makedirs("public", exist_ok=True)
    with open("public/index.html", "w") as f:
        f.write(render_html(forecast))


if __name__ == "__main__":
    build()

Wrapping up and taking it further #

That is all to it! You now have a working data science project that provides real value. Of course, it’s not pretty, and, it can be improved in many ways. Take this foundation and build on it!