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.

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.