Shot Quality
Summary
Shot Quality (SQI) is not xG. It is a Poisson regression that maps
a team's per-match shot-bucket counts to an expected goal total, fit
on historical CPL matches. It lives at /analysis/shot-quality as a
team-season over/underperformance table: actual goals vs SQI-expected
goals, sorted by Δ. The per-match SQI values also feed SQI Expected
Points, which converts each match's SQI pair
into win/draw/loss probabilities — the principled successor to the
threshold-based SQI standings on the same page.
The "not xG" distinction is the whole reason this metric exists. Canadian Premier League data does not include shot locations, body part, defender pressure, keeper position, assist type, or any situational context — only bucketed counts (shots inside the box, shots outside the box, big chances, shots on goal, penalty goals). We cannot build a real xG model on top of counts alone. What we can build is a calibrated "given this team's typical shot mix, how many goals would a league-average finisher convert" and ship it with loud disclaimers about what it is not.
This honesty is the point. Anywhere you see SQI in the UI or code, it should be clear what is being measured and what is not.
This is NOT xG
Real xG (Opta, StatsBomb, Wyscout) uses a per-shot model with features including:
- Shot coordinates — distance to goal, angle to the posts
- Body part — foot, head, other (materially different conversion rates)
- Assist type — cutback, through ball, cross, set piece, rebound
- Situation — open play, counter-attack, set piece, penalty
- Defender pressure — defenders between shooter and goal
- Keeper position — set / off-line / advancing
- Technique — first-time, volley, chest, deflection
SQI uses none of those. It only knows:
- How many shots you had from inside the box
- How many you had from outside the box
- How many "big chances" the scorer flagged
- How many of your shots were on target
- How many penalty goals you scored
This means a team with 10 shots from the edge of the six-yard box,
all first-time volleys with defenders leaning the wrong way, gets
the exact same box_shots = 10 as a team with 10 speculative toes
from 17 yards surrounded by three defenders. SQI cannot tell the
difference. Two teams with identical bucket counts get identical
SQI, even if one was carving a weak defence open and the other was
taking low-percentage shots to look busy.
The upgrade path that would make SQI a real xG model is a shot-level
data source. CPL does not have one. The SDP match_stats
expected-goals column exists but is identically zero across all
seasons. Until that changes, SQI is what's possible.
Data
- Source:
match_stats(shot buckets) joined tomatches(actual goals), one row per (team, match). - Time range: 2019-2025, regular season only. 1438 team-match rows after the playoff filter, zero dropped to missing features.
- Target:
home_goalsoraway_goalsfrom thematchestable, per team. Includes own goals scored by the opponent against this team. With ~9-12 own goals per season (out of ~175 goals/season), this is small noise and including them simplifies the pipeline. The methods doc flags it loudly rather than silently excluding. - Features (in canonical order):
box_shots—shots-at-goal-inside-boxout_box_shots—shots-at-goal-outside-boxbig_chances—big-chances(SDP's own "clear look" flag)penalty_goals—penalty-goals(see discussion below)shots_on_goal—shots-on-goal
- Excluded:
shotstotal (linearly dependent on box + out_box),blocked-shots(redundant), own-goals-scored (the team's own attackers didn't score those), corners, free kicks.
Choices
Why Poisson regression
Goal counts are non-negative integers with a small mean (~1.27 goals per team per match, train set). Poisson is the textbook distribution for this. The log link naturally handles the "more shots → more goals, diminishing returns" curve. On 964 training rows, a linear model is the ceiling of what's defensible — anything fancier would overfit into noise.
Why penalty_goals is a feature, not a target adjustment
Penalties are rare (~25 per season) and convert at ~0.75 in CPL. Two options:
- Subtract penalty goals from the target and drop the feature
- Keep penalty goals in the target and include them as a feature
Option 2 is the honest one. A penalty goal is still a goal — if you
want SQI to explain total goals, you need the model to account for
them. Including penalty_goals as a feature lets the Poisson fit
learn how much a penalty goal adds to log-expected-goals. The fitted
coefficient is exp(0.198) = 1.22, which means each penalty goal
marked in a match multiplies the expected goal total by ~1.22 on
top of whatever the other features contribute. That is not "one
extra expected goal per penalty" — Poisson log-link is
multiplicative, not additive, and the cleaner interpretation is
"matches with a penalty goal tend to be higher-scoring matches
overall, beyond what the raw shot counts explain".
Negative coefficients on box_shots and out_box_shots
This is worth calling out because it looks wrong and isn't.
The fitted coefficients:
| Feature | beta |
exp(beta) |
|---|---|---|
| box_shots | -0.042 | 0.959 |
| out_box_shots | -0.067 | 0.935 |
| big_chances | +0.153 | 1.165 |
| penalty_goals | +0.198 | 1.218 |
| shots_on_goal | +0.164 | 1.178 |
| intercept | -0.246 | 0.782 |
shots_on_goal is a subset of box_shots + out_box_shots. The
regression reads that as: "given how many of your shots were on
target, more off-target volume is marginally negative." That is
internally consistent and calibrates well on the test set. The
model is effectively saying: "on-target count carries the quality
signal, and total shot volume after controlling for on-target
counts is just noise / pressure shots that don't threaten."
The alternative parameterization would be
box_shots_on_target / out_box_shots_on_target / box_shots_off_target / ...,
which reduces the collinearity. We did not do that in v1 because the
calibration is already good and the five-feature fit is
interpretable enough. A v1.1 refactor could orthogonalize the shot
buckets cleanly.
Alpha selection
sklearn.linear_model.PoissonRegressor with L2 (alpha) sweep
over [0.0, 0.001, 0.01, 0.1, 1.0], picked by validation (2024)
Poisson deviance. Chosen alpha: 0.1 — mild regularization that
pulls the coefficients slightly toward zero without erasing them.
Frozen training
The model is trained once, exported as JSON coefficients, and applied to every match by the nightly compute step. The daily sync does not retrain. Retraining is an explicit, version-bumped operation followed by a commit, so historical SQI values don't silently shift underneath the user.
Validation
Headline numbers (model_v1, alpha=0.1)
| Split | n | Poisson deviance | MAE | Mean predicted | Mean actual |
|---|---|---|---|---|---|
| Train (2019-2023) | 964 | 0.7716 | 0.680 | 1.273 | 1.273 |
| Validation (2024) | 234 | 0.7165 | 0.667 | 1.224 | 1.265 |
| Test (2025) | 234 | 0.7833 | 0.760 | 1.393 | 1.504 |
| Baseline (league mean) | 234 | 1.2879 | 1.022 | 1.273 | 1.504 |
The baseline is a constant: predict every team-match as the training set mean (1.273 goals per team per match). SQI crushes it on every metric: test deviance 0.7833 vs 1.2879, a 39% improvement. MAE drops from 1.02 to 0.76 goals per match.
The mean predicted vs mean actual gap on test (1.393 vs 1.504) is honest drift: 2025 CPL had slightly more goals per match than the train-era average, and a model fit on 2019-2023 does not know about that. Season-total drift is -26 goals across 234 team-match rows. Not worrying but worth reporting.
Walk-forward backtest
Same pattern as the match outcome model: for each test year in 2021..2025, train a fresh Poisson on every team-match strictly before the test year, pick alpha by in-fold leave-last-season-out, score on the test year, compare to the train-mean baseline.
| Year | train_n | test_n | alpha | SQ deviance | Baseline deviance | Δ |
|---|---|---|---|---|---|---|
| 2021 | 266 | 230 | 0.1 | 0.7935 | 1.0933 | -0.2998 |
| 2022 | 496 | 234 | 0.01 | 0.7815 | 1.2138 | -0.4323 |
| 2023 | 730 | 234 | 0.0 | 0.7873 | 1.0901 | -0.3028 |
| 2024 | 964 | 234 | 0.1 | 0.7165 | 1.0012 | -0.2848 |
| 2025 | 1198 | 234 | 0.1 | 0.7815 | 1.2885 | -0.5070 |
Pooled (n=1166):
| Poisson deviance | MAE | |
|---|---|---|
| Shot Quality | 0.7720 | 0.6993 |
| League mean baseline | 1.1375 | 0.9091 |
| Improvement | -0.3656 | -0.2098 |
Every single year beats the baseline, and the margin is never smaller than 0.28 deviance. This is a real signal, not a statistical fluke of a single test season.
Coefficient sanity
As discussed under "Choices" — positive coefficients for
big_chances, penalty_goals, shots_on_goal, small negative
coefficients for raw box_shots and out_box_shots driven by
collinearity with on-target. big_chances has a larger positive
coefficient than box_shots, which is the right ordering (a "big
chance" flag should be worth more than a generic box shot).
Total-goal calibration per season (test set)
| Season | Total actual | Total SQI | Δ |
|---|---|---|---|
| 2025 | 352 | 326 | -26 |
Small underprediction driven by secular scoring-rate drift (2025 CPL scored at a higher per-shot rate than the train average).
Failure modes
- Not xG. The single biggest failure mode is conceptual: SQI cannot distinguish a chance-creation masterclass from speculative volume with the same bucket counts. Loud disclaimers live in the UI and the page header.
- Own goals in the target. A tiny (< 3%) noise source that slightly inflates actual goals relative to what the shot features can explain. Simpler than filtering them out and documented here.
- Collinearity between shots_on_goal and total shot buckets. The negative fitted coefficients on box/out-box shots are an artifact of that, and the interpretation is awkward even though the calibration is fine. A v1.1 that orthogonalizes into on-target vs off-target splits would clean this up.
- Secular scoring drift. CPL scoring rate is trending up slightly over time; a model frozen on 2019-2023 under-predicts 2025 totals by ~7% across the league. The per-team Δ column is still interpretable because the drift affects all teams, but aggregate season totals will drift low until the next retrain.
- Penalty_goals is a feature, not a per-shot rate. The fit learns a multiplicative effect, not "one goal per penalty". A purer formulation would be "scored vs missed penalties" as a two-bucket split, but the SDP data only exposes the scored count.
- Big-chance subjectivity. SDP's
big-chancesflag is hand-set by the data provider. We inherit their definition. If their rules change across seasons, our coefficient onbig_chancesis partially fitting to the taggers rather than reality. - Small sample. 964 training rows, 234 test rows. Large enough for a 5-feature Poisson to be stable, nowhere near big enough for anything more exotic.
- In-match event data missing. We don't know which minute shots came in, who was on the pitch, or what the score was at the time. No way to model "chasing a deficit" shot quality vs "sitting on a lead".
What would make it better
- A shot-level data source. The one upgrade that would make SQI into a real xG model. Requires a data partner beyond the current SDP feed. Out of reach for this project today.
- Orthogonal bucket features. Refactor to
on_target_box,off_target_box,on_target_out,off_target_outso coefficients don't drift negative via collinearity. Cleaner story, similar predictive power. - Per-team finishing efficiency as a side metric. We currently output a single "goals vs SQI" Δ per team-season. Splitting that into "finishing above/below SQI on big chances" vs "on ordinary shots" would expose whether a team is converting above the league rate everywhere or just getting hot in one bucket.
- Retest as a match-outcome feature. The match-outcome v1.1
walk-forward showed raw rolling shot totals do not beat the Elo
baseline. A calibrated SQI rolled forward is a different
transformation of the same inputs; worth a separate backtest with
sqi_diff_5added to the feature set to see if the model-based feature moves the needle where raw rolling did not. - Own goals subtracted from target. Small effect but cleaner. A one-line loader change on the next retrain.
Live drift
The secular-drift failure mode above is watched live: the section
rendered below this document shows season-to-date drift, defined as
actual goals minus SQI-predicted goals (positive = the frozen model
under-predicting, the documented direction). An informational flag
trips when |drift| exceeds ±10% of predicted goals with at least 50
matches played — that threshold sits above the known ~8% drift on
purpose, so it fires only if drift gets worse than what is already
documented here. The model stays frozen either way; the flag never
affects the sync. Served by /api/model-diagnostics.