Building an xG Model

The basic version#

Expected Goals answers a simple question: given a shot's characteristics, what fraction of similar shots actually go in? The two strongest, most intuitive predictors are distance and angle to goal — not just how far away the shot was, but how much of the goal mouth was actually visible from that spot. A shot from ten yards out but nearly on the byline has a much worse angle than the same distance dead centre, even though the raw distance is identical.

Fit as a logistic regression on real shot outcomes (goal or not), this gave a model where the coefficients were directly checkable against footballing intuition: distance pushed scoring probability down, angle pushed it up — exactly as you'd expect, and worth confirming rather than assuming, since a flipped sign would have meant something was wrong upstream.

Adding context — and a bug that taught me more than the fix did#

StatsBomb's shot data carries more than geometry: whether it was a header, a direct free kick, taken under defensive pressure, or hit first-time. Adding these as features lifted test ROC AUC from 0.713 to 0.732, with corresponding improvements in log loss and Brier score — genuine gains, not just a longer feature list for its own sake. Penalties were pulled out entirely and given their own empirically measured conversion rate (74.3% across the dataset), since a fixed-distance, fixed-angle shot has nothing for a geometric model to actually learn from.

Three line charts showing ROC AUC, log loss, and Brier score each improving after adding shot context features

All three metrics move the same direction after adding context — not just a longer feature list for its own sake. #

The build wasn't clean, though. The first version of the contextual features produced three suspiciously exact zero coefficients — is_header, is_free_kick, and first_time all came back as precisely 0.0000, which reads like "the model decided these don't matter" right up until you notice a coefficient of exactly zero is a much rarer, much more suspicious thing than a small nonzero one. The actual bug: I'd written the extraction code assuming StatsBomb's raw JSON was nested (shot.type.name), when in practice the statsbombpy library that fetches it flattens everything to plain columns (shot_type). Every lookup was silently falling through to a default value.

The thing that actually surfaced it wasn't the zero coefficients themselves — it was a total shot count that showed zero penalties across nearly 12,000 real shots, which is close to statistically impossible for a real dataset spanning hundreds of matches. That impossible number was the thread that led back to the real bug.