Posted 15 May 2026 · Corrected 6 Aug 2026 · by Mark Hahnel · 4 min read

Calibrated xG without StatsBomb.

Correction · 6 August 2026

Two things in the original version of this post were wrong, and one has changed since.

Scale. The original claimed every row in shot_events carried our modelled xG. It never did. Measured on 6 August 2026, we hold 729,596 shots; xg_modeled is set on 55,632 of them (7.6%), while 686,422 carry xG from the upstream source instead.

Shipped artifacts. The original promised a derived_xg column on player_season_stats, an xg_derived column on match_team_stats, an xG axis in the compare_players tool, and a /api/v1/models/xg_v1 coefficients endpoint. None of the four was ever shipped; they have been removed from the post. The coefficients are public in the training script's repo instead.

Serving. Shot-level data — including every xG field — is not currently served through the API at any tier: zero shot rows pass the licence gate enforced on 29 July 2026. The methodology below stands; the rows stay held until we can serve them with the right to do so.

We trained foot.io's own xG model on 69,031 shot events. No StatsBomb licence. No black-box deep-learning gradients. Just a calibrated logistic regression that you can audit. Here's what we did, what the numbers look like, and why a simpler model is the right call.

The training data

When the model was trained in May 2026, shot_events had 70,073 rows, of which 69,031 had non-null (x_pitch, y_pitch, outcome). (The table holds 729,596 shots as of 6 August 2026 — see the correction note above for what is and isn't scored.) We use:

featuretypesource
distance_mnumericderived from coords
angle_degnumericderived from coords
body_partcategoricalleft foot / right foot / head / other
shot_typecategoricalopen play / set piece / penalty / free kick
periodcategorical1st half / 2nd half / ET / pens

These are the standard inputs every published xG model uses. We deliberately did not include defender-position data — most of our shot rows don't have it, and including a feature that's only available for a minority of rows produces a model that quietly degrades for older / lower-league matches.

Why logistic regression, not XGBoost

Two reasons:

  1. Interpretability. We publish the coefficients. Anyone can tell exactly why our model thinks a shot from 18 yards at a 45° angle on the left foot is worth 0.07 xG. Boosted trees can't be audited that way without a SHAP-plot-per-shot.
  2. Sample size. 69k rows is plenty for LR with ~8 effective features. A deep model would overfit — and the test-set Brier score would look great while the production score regressed silently.

LR is what every published xG paper actually uses (Sicilia 2019, Lucey 2014, Caley 2015). The boosted-tree variants tend to come out of in-house club models where defender + tracking data is available.

The numbers

Training set: 55k shots. Test set: 14k shots (held out, stratified by competition).

Train Brier: 0.0844
Test Brier:  0.0851
Baseline (predict league mean):  0.1080

A Brier score of 0.085 means the model's average squared error per shot is ~8.5%. The baseline of 0.108 is what you'd get by predicting every shot is worth the league-mean conversion rate (~11%). The gap between baseline and trained model is exactly the value xG adds — it tells you which 11% goals come from chances we'd expect to convert even more often than average, and which come from chances we'd expect to convert less.

Calibration

The honest test of an xG model is the reliability diagram: if you take all shots the model rated 0.10, what fraction actually became goals? Should be ~10% if calibrated. Our v1:

bucketpredicted xGobserved goal raten shots
0.00–0.050.0270.02924,118
0.05–0.100.0740.08012,847
0.10–0.200.1420.1379,201
0.20–0.400.2810.2733,604
0.40–0.700.5240.510843
0.70–1.000.8380.829102

Within 2 percentage points across every bucket. That's well-calibrated.

Where this stands

Measured on 6 August 2026: of the 729,596 shots we hold, 686,422 carry xG from the upstream source, and xg_modeled — this model's output — is set on 55,632 rows (7.6%). Each scored row records its model in xg_model_version, so footio_v2 will be a separate label, not an overwrite — work citing v1 stays reproducible.

And to say it plainly: none of it is currently served. Shot-level rows, including every xG field, are not in the licence-gated defensible set at any tier. The model, its coefficients and its evaluation are public; the rows it scores are held until we can serve them with an explicit right to redistribute.

Why we don't sell xG as a stand-alone product

Because plenty of academics publish calibrated xG models for free. The moat is everything around xG — the merged-truth dataset under it, the provenance trail, the MCP tool surface, the newspaper archive that gives context to the modern numbers. xG without context is a number. xG inside foot.io is a citation.

See also