xPts Δ Attribution
Summary
The Performance vs Expectation view (/analysis/expected) ranks teams by Δ,
the gap between actual points and Elo-derived expected points. Δ is one
number with no explanation: a team can be at +6 from clinical finishing,
clutch comebacks, or just luck, and the table doesn't say which.
This doc covers the decomposition that splits Δ into four components:
- Finishing — points the team gained from scoring above (or below) its own shot-quality expectation.
- Defense / GK — points the team gained from conceding fewer (or more) goals than its opponents' shot quality predicted.
- Game state — net points from comebacks earned and leads dropped, measured against the team's best/worst points position at any 15-min cutoff.
- Residual — everything left over after the other three are accounted for.
The four components sum exactly to Δ by construction: residual is the algebraic closer.
Data
Three precomputed pipelines feed the decomposition; nothing new is computed in storage.
- xPts —
team_match_expectationsprovides actual points and Elo-derived xPts per team-match. Seeexpected_points.md. - SQI —
team_match_shot_qualityprovides each team's expected goals (sqi) and actual goals per match. Seeshot_quality.md. - Game state —
team_season_game_stateprovidespts_gained_15mandpts_dropped_15mper team-season (the 15-min comeback index). Seegame_state.md.
The decomposition runs at request time, joining the three tables. There is no precomputed decomposition table.
Components
Finishing contribution
For each match a team played, run a finishing-neutral counterfactual:
replace the team's actual goals scored with round(its own SQI) and
recompute the W/D/L points. The finishing contribution for that match is
actual_points - finishing_neutral_points.
cf_for = round(self_sqi)
finishing_neutral_pts = result_points(cf_for, actual_against)
finishing_match = actual_points - finishing_neutral_pts
Sum across the season. Positive means the team gained points by finishing above its underlying chance volume; negative means it wasted chances.
Defense / GK contribution
Mirror image. Hold the team's own goals at their actual value and replace
the goals it conceded with round(opponent SQI) (i.e. the chances the
opponent created against this team).
cf_against = round(opp_sqi)
defense_neutral_pts = result_points(actual_for, cf_against)
defense_match = actual_points - defense_neutral_pts
Positive means the team conceded fewer than expected (good defense or goalkeeping); negative means it leaked more than its opponents' chances suggested.
Game-state contribution
Lifted directly from the precomputed game-state pipeline:
game_state = pts_gained_15m - pts_dropped_15m
These come from the 15-min comeback index: for each match, take the
team's best and worst points snapshot across every 15-min cutoff
(15', 30', 45', 60', 75', 90'). pts_gained_15m is final - worst
when the team's worst snapshot was below its actual points (it dug
out of a losing position at some point); pts_dropped_15m is
best - final when the team's best snapshot was above its actual
points (it lost ground from a winning position). Unlike a half-time
view, this catches in-play swings: a team that goes 0-1 down in the
60th and equalises in the 85th now contributes to pts_gained_15m.
The net is in points already, no conversion needed.
Residual
The algebraic closer. Whatever's left after the first three are taken out of Δ.
residual = delta - finishing - defense - game_state
The residual absorbs:
- The interaction between finishing and defense. Finishing and defense are computed against the same actual-points anchor, so they're independent attributions that don't strictly compose. A match where both finishing and defense overperformed will get attributed to both individually but only count once in Δ; the difference lands here.
- The Elo-vs-shot-generation gap. xPts uses Elo only; SQI uses shot buckets. The two models can disagree about how strong a team is, and the disagreement washes through here.
- Matches without SQI coverage. ~5 matches per season (mostly the 2020 Island Games and a few stragglers) have no shot-quality row. Those matches contribute zero to finishing or defense, and their full per-match delta lands in residual.
- The match-table vs standings gap.
actual_pointsfor a team comes from the officialstandingsrow (matching/expected-points). Per-match finishing and defense, however, sum across every match inteam_match_expectations, which can include 1-2 matches per team per season that aren't reflected in the league standings totals. When this happens the per-match contribution still scores into finishing or defense; the offset lands in residual. - Draw-rate model error, red cards, fixture-difficulty fluctuations, and anything else neither SQI nor game-state captures.
A large residual isn't a bug, it's a flag: this team's Δ has a story the three named components can't explain.
Worked example
One match: Forge FC (home) beats Cavalry FC 2-1, with sqi_for=1.4 and
opp_sqi=1.6 (i.e., Cavalry's SQI against Forge).
actual_points = 3 # Forge won
cf_for = round(1.4) = 1
cf_against = round(1.6) = 2
finishing_neutral_pts = result_points(1, 1) = 1 # 1-1 draw
defense_neutral_pts = result_points(2, 2) = 1 # 2-2 draw
finishing_match = 3 - 1 = +2
defense_match = 3 - 1 = +2
Suppose for the season Forge has pts_gained_15m=4,
pts_dropped_15m=1, an actual point total of 52, and an xPts of
47.3:
delta = 52 - 47.3 = +4.7
finishing = +2.0 (sum of per-match finishing contributions)
defense = +1.0
game_state = 4 - 1 = +3.0
residual = +4.7 - 2.0 - 1.0 - 3.0 = -1.3
The residual being negative here means the named components combined slightly over-attribute the gain. The four numbers sum to +4.7 = Δ.
Choices
Per-match SQI rounding
round(sqi) is crude. SQI is a continuous expected-goal value
(typically 0.5 to 3 per side per match) and rounding a 1.6 to 2 versus
a 1.4 to 1 means the finishing-neutral result for those two matches is
materially different despite SQI being almost the same. The
alternative is a Skellam expected-points integration over the
Poisson-ish goal distribution implied by SQI, which is more rigorous
but harder to explain and harder to audit per match.
The chosen tradeoff matches the rest of the SQI surface, which uses a similar cliff-edge heuristic to decide draws on the SQI standings view.
Algebraic residual instead of "leftover finishing × defense"
Finishing and defense are computed independently against the same actual-result anchor, so a match that overperformed on both ends gets attributed to both individually. We could try to multiply or add a joint-effect term to make the two compose cleanly, but the joint counterfactual ("if both finishing and defense had matched expectation") is just one of many possible composition rules. Closing residual algebraically keeps the bookkeeping honest: components are attributions, the residual is what attribution failed to explain.
On-the-fly compute, not precomputed
All three input tables are already precomputed during the nightly sync.
The decomposition is a join plus a few arithmetic operations per
match-row, which runs in milliseconds even on the full 8-season dataset.
Worth promoting to a precomputed team_season_xpts_decomposition table
only if (a) request latency starts to matter, or (b) we want to surface
historical decomposition snapshots that don't change as the model
evolves.
Validation
- Algebraic closure. Regression test asserts
finishing + defense + game_state + residual == deltafor every row, on a fixture and end-to-end against the API. - Sign sanity. A team that scored 2 against
sqi=1.4(rounds to 1) has finishing > 0; a team that scored 0 againstsqi=1.6(rounds to- has finishing < 0. Tested.
- Game-state passthrough. The game-state component equals
pts_gained_15m - pts_dropped_15mexactly. Tested. - Cross-check against the Performance vs Expectation view. For any team-season, the four components sum to the same Δ shown on the xPts page to within rounding (2dp).
Failure modes
- Per-match rounding is lossy. A team with consistently high-SQI matches that round up will see a larger finishing-neutral baseline than a team with consistently low-SQI matches that round down, even if their season totals match. Bias is small but non-zero.
- Residual can be the biggest component. When a team has a big Elo-vs-SQI mismatch (Elo says they're strong, SQI says they're weak, or vice versa), the residual will dominate. Read it as "Δ exists for reasons the three named components don't explain," not as a fourth named factor.
- Matches without SQI coverage are silently zeroed. Rare today (~5 per season post-2021, more in 2020) but if SQI coverage degrades the residual will balloon without any user-facing warning. Worth surfacing the affected match count if it ever exceeds 10% of a season.
- Same-anchor independence. Finishing and defense don't compose arithmetically. Calling either one "the" reason a team overperformed is wrong; together they're a pair of partial counterfactuals.
- No bootstrap on small samples. Single-season components for the
2020 Island Games (7-11 matches per team) are very noisy. Read with
the same skepticism as single-season Δ in
expected_points.md.
What would make it better
- Skellam expected-points integration over SQI, replacing the rounding heuristic. Would tighten finishing and defense attributions and shrink residual on average.
- A precomputed snapshot table so decomposition values are stable across model updates (right now, retraining the SQI model would shift every historical decomposition).
- A coverage-aware residual: when a team has matches with no SQI, separately report "residual (covered)" and "residual (uncovered)" so the latter doesn't masquerade as model error.
- A confidence band around each component derived from per-match variance (small-sample seasons should visibly carry more uncertainty than full seasons).