DEV Community

Cover image for Your first tennis prediction model should have a baseline it can lose to
PlantCrawl
PlantCrawl

Posted on Originally published at crawlplant.com AI-assisted

Your first tennis prediction model should have a baseline it can lose to

The first useful result from a tennis prediction project is not necessarily a clever forecast. It may be discovering that your learned model is worse than the rating you already had.

This tutorial builds a very small logistic model using one input: the difference between two players' pre-match overall Elo. It compares that fitted model with a 50/50 prediction and the ordinary Elo probability formula. There is no language model guessing the winner from a player's name.

The interesting work is constructing the table correctly and deciding what the experiment can claim.

Disclosure: I maintain the CrawlPlant Actors used here. The illustrated experiment uses an existing 233-match export, not a full season. It is a pipeline demonstration with 74 eligible training rows and 142 later-week test rows, not evidence of a deployable forecasting advantage.

Run this project: Get the code, sample data and finished outputs on GitHub. To fetch your own data, use the live recipe and follow the .env setup guide to add your APIFY_TOKEN. Actor calls can incur charges; the setup explains per-run limits. The live recipe downloads a dataset; the project folder contains the reproducible baseline model and evaluation.

The first trap: every row starts with the winner

The dataset has winner_elo, loser_elo, winner_id and loser_id. If you use those columns to define “player A” as the winner, every label becomes one. An apparently excellent classifier would just be reading the answer from your table layout.

Instead, assign A and B by a rule available before the result: lexical order of their stable player IDs. Those IDs choose orientation only; they are not model features. A may then win or lose, and the Elo difference is consistently A minus B.

Install apify-client and scikit-learn, then set APIFY_TOKEN. Actor calls can incur charges; use a spending limit. For a larger study, fetch complete earlier seasons for training and a later period for evaluation. This small fixed window is enough to exercise the example:

import os
import json
import math
from pathlib import Path
from datetime import datetime
from urllib.request import Request, urlopen

def call_actor(slug, run_input):
    from apify_client import ApifyClient

    client = ApifyClient(os.environ["APIFY_TOKEN"])
    run = client.actor(f"crawlplant/{slug}").call(run_input=run_input)
    if not run or run["status"] != "SUCCEEDED":
        raise RuntimeError(f"Actor did not succeed: {run and run['status']}")
    rows = list(client.dataset(run["defaultDatasetId"]).iterate_items())
    if len(rows) >= run_input["maxItems"]:
        raise RuntimeError("Result reached maxItems; narrow the range or increase the cap before claiming completeness.")
    return rows
Enter fullscreen mode Exit fullscreen mode
rows = call_actor("tennis-match-dataset", {
    "years": [], "dateFrom": "2026-10-01", "dateTo": "2026-10-22",
    "tours": ["atp", "wta"], "maxItems": 5000,
})
Enter fullscreen mode Exit fullscreen mode

The illustration uses the saved incremental export rather than that fresh calendar request. Its counts and scores are not promised outputs of a future run.

def model_rows(rows):
    result, seen = [], set()
    for row in rows:
        key = row["match_id"]
        if key in seen:
            raise ValueError("Duplicate match ID in modelling sample")
        seen.add(key)
        if any(c in (row.get("score") or "") for c in ("RET", "W/O", "DEF")):
            continue
        w, l = row.get("winner_elo"), row.get("loser_elo")
        if w is None or l is None or not all(isinstance(v, (int, float)) and math.isfinite(v) for v in (w,l)):
            continue
        if not row.get("winner_id") or not row.get("loser_id") or row["winner_id"] == row["loser_id"]:
            raise ValueError("Two distinct participant IDs are required")
        # Participant ordering must be possible before the match result is known.
        winner_is_a = str(row["winner_id"]) < str(row["loser_id"])
        gap = (w-l) if winner_is_a else (l-w)
        result.append({"match_id": key, "week": row["tourney_date"], "tour": row["tour"], "gap": gap/400,
                       "a_won": int(winner_is_a), "elo_probability": 1/(1+10**(-gap/400))})
    return result
Enter fullscreen mode Exit fullscreen mode

We exclude retirement, walkover and default scores and require both Elo values. Duplicate match IDs stop the calculation. The target match's aces, duration, final score and winner-oriented field order are not prediction features.

Keep later tournament weeks out of training

Here the final holdout starts at tournament-week label 20261019; earlier labels train the model. The regularization and model form are fixed in advance in this example.

def fit_temporal_baseline(rows, cutoff_week=20261019):
    from sklearn.linear_model import LogisticRegression
    from sklearn.metrics import brier_score_loss, log_loss

    data = model_rows(rows)
    train = [r for r in data if r["week"] < cutoff_week]
    test = [r for r in data if r["week"] >= cutoff_week]
    if len(train) < 10 or len(test) < 10 or len({r["a_won"] for r in train}) != 2:
        raise ValueError("Need earlier training data, later test data and both training classes")
    model = LogisticRegression(fit_intercept=False, C=1.0, max_iter=1000)
    model.fit([[r["gap"]] for r in train], [r["a_won"] for r in train])
    labels = [r["a_won"] for r in test]
    predictions = {"50/50": [.5]*len(test), "Overall Elo": [r["elo_probability"] for r in test],
                   "Fitted logistic": model.predict_proba([[r["gap"]] for r in test])[:,1].tolist()}
    scores = {name: {"brier": float(brier_score_loss(labels,p)), "log_loss": float(log_loss(labels,p,labels=[0,1]))} for name,p in predictions.items()}
    return {"train_count": len(train), "test_count": len(test), "cutoff_week": cutoff_week,
            "scores": scores, "test": test, "predictions": predictions}
Enter fullscreen mode Exit fullscreen mode
evaluation = fit_temporal_baseline(rows, cutoff_week=20261019)
print("Train:", evaluation["train_count"], "Test:", evaluation["test_count"])
for name, scores in evaluation["scores"].items():
    print(name, f"Brier={scores['brier']:.4f}", f"log loss={scores['log_loss']:.4f}")
Enter fullscreen mode Exit fullscreen mode

tourney_date is a tournament-week label, not the timestamp at which an individual match finished. This split holds out later labelled weeks, but cannot on its own prove strict time-of-availability. Before making a real chronological backtest claim, join exact match times from another Actor, verify that training matches ended before the cutoff, and archive the features as they were known then.

Eligible match rows split by tournament-week label into 74 training and 142 test rows

The sample pools ATP and WTA. That is convenient for exercising the pipeline, not a reason to assume that one fitted calibration is appropriate for both populations. Evaluate them separately on a larger cohort before drawing tour-specific conclusions.

Show the result even when it is inconvenient

Test-set Brier scores for a 50/50 baseline, overall Elo and fitted logistic regression, with lower scores marked as better

In the saved sample, overall Elo has a Brier score of 0.2145, the fitted logistic model 0.2443, and the 50/50 baseline 0.2500. The fitted model loses to overall Elo on this test subset. No parameters were changed after that result to make the example look better.

Brier score is the mean squared difference between predicted probability and outcome; lower is better. Log loss is also reported. See scikit-learn's probability-scoring documentation and the logistic regression reference. The experiment is too small for a reliable performance claim; no confidence interval or profitability estimate is implied.

The overall Elo baseline here is 1 / (1 + 10 ** (-elo_gap / 400)). It is not the Actor's potentially blended overall-and-surface match probability. Comparing the two under one label would hide a meaningful modelling difference.

Where the other Actors become useful

The match dataset supplies the training table. Tennis Match History supplies exact start and end times and the prior results needed for additional features. Flashscore Tennis can supply match-level surface Elo, form and stored pre-match probability for a matched-ID comparison.

Join by match_id to matchId. For each proposed feature, document its observation time. A target match's completed point sequence is an outcome to investigate, not a feature available before play. Point-by-point from genuinely earlier matches could support later research, but it needs the same cutoff discipline.

Freeze the next training, validation and test windows before adding features. Keep an untouched later period. The useful end product is a reproducible experiment that can tell you to keep the simpler model, not an AI label attached to a spreadsheet that already contains the answer.

Watch the question-to-answer workflow

This question-led demonstration starts with an English scenario prompt prepared for this tutorial, then shows the calculations, checks and answer. It uses the saved historical data; no new Actor run or separate paid language-model API request was made.

Ask for a first tennis model: compare the result with Elo
You

Can you build a simple tennis prediction model from the saved match export and check whether it beats overall Elo? Keep later tournament weeks out of training, prevent winner-first label leakage, and show the holdout scores even if the fitted model loses.

Agent

[Demonstration context]
The article author requested this English question-led demonstration. The opening scenario question was written for this demonstration; it is not claimed to be a verbatim earlier user message. The calculations and checks were rerun for this new session against the saved historical exports. Tool records below are curated summaries of those executed operations. No new Apify run or separate paid language-model API request was made. Credentials, private paths, private resource identifiers and unrelated work are omitted.

I will assign player A by lexical stable-ID order before constructing the target, use only the pre-match overall-Elo difference, and fit the article's fixed logistic-regression baseline. Tournament-week labels before 20261019 train the model; later labels form the holdout. I will compare Brier score and log loss with 50/50 and the ordinary Elo formula. Exact match-time availability is not established by this week-based split.

Agent
Agent
Agent

The fitted model loses to overall Elo on this saved holdout. Training used 74 eligible rows and testing used 142.

Predictor Brier (lower is better) Log loss
50/50 0.2500 0.6931
Overall Elo 0.2145 0.6085
Fitted logistic 0.2443 0.6818

Player ordering is based on stable IDs, rather than placing the winner first. The input is a small pooled ATP/WTA export, and tournament-week labels are not exact match times. This is a pipeline demonstration, not a strict historical backtest or a deployable forecasting advantage. Match History and match context can support exact-time joins and additional features in a later study; their observation times must be checked before use. No new Actor lookup was performed here.

Export the experiment, including the losing result

The companion regenerates both illustrations from the saved rows: assets/11-ml-week-split.png and assets/12-ml-baselines.png, with SVG copies. It also exports reports/model-evaluation.json and reports/model-predictions.csv so every held-out probability can be inspected.

import csv
Path("model-evaluation.json").write_text(json.dumps(evaluation, indent=2), encoding="utf-8")
with Path("model-predictions.csv").open("w", newline="", encoding="utf-8") as handle:
    writer = csv.writer(handle)
    writer.writerow(["match_id", "a_won", *evaluation["predictions"]])
    for i, row in enumerate(evaluation["test"]):
        writer.writerow([row["match_id"], row["a_won"],
                         *[p[i] for p in evaluation["predictions"].values()]])
Enter fullscreen mode Exit fullscreen mode

All three methods have 142 predictions on the same held-out rows. This makes the comparison auditable without presenting the small pooled sample as a production model.

Download and reproduce the example

Download the offline companion ZIP. Extract it, open a terminal in that folder and run:

python -m pip install -r requirements.txt
python render.py
Enter fullscreen mode Exit fullscreen mode

The package includes the dated sample data, calculation functions, complete chart renderers and finished reports. It generates the series' charts and reports together; no API token or paid Actor/model call is required. The live-fetch code earlier in this article is a separate option and can produce different counts.

Inspect the sources: chart renderer, AI charts, report generation, and data notes. Both PNG and SVG outputs are written to assets/; reports go to reports/.

Keep building: Browse all 48 runnable examples, including Python, JavaScript and AI workflows across the CrawlPlant Actors.

Top comments (1)

Collapse
 
deanlee profile image
Dean Lee •

Reporting the higher Brier score rather than tuning hyperparameters until the model beat raw Elo is the honest way to present baseline validation. With seventy-four training rows, fitting a slope parameter on Elo delta almost always increases estimation variance compared to the fixed four-hundred divisor. The raw rating already embeds thousands of historical matches of implicit calibration. When training windows are this narrow, unconstrained logistic regression tends to flatten the tail probabilities toward fifty-fifty, which gets heavily penalized under quadratic scoring. Tracking probability calibration rather than binary accuracy is what separates actual risk modeling from vanity classification.