Poisson-ELO Ensemble

Summary

The Poisson-ELO ensemble combines two complementary modeling approaches — Poisson regression for goal-scoring rates and ELO rating systems for team strength — via a weighted average to produce match predictions. The intuition is that Poisson models capture the stochastic nature of football scoring (goals are rare, independent events fitting a Poisson process), while ELO captures systematic strength differences and form dynamics. Blending them reduces the weaknesses of each approach.

The Poisson model estimates expected goals (λ) for each team, typically incorporating home advantage, attack/defense strength parameters, and optionally xG proxies. The ELO model produces a pairwise win probability from rating differences. The ensemble combines their 1X2 (home win / draw / away win) probability outputs with weights determined by backtesting performance.

Architecture

Step 1: Poisson layer
- Estimate λ_home and λ_away using historical match data
- Parameters: home attack strength, away defense strength, home advantage term
- Optionally incorporate xG from shot-level data if available
- Output: P(Home), P(Draw), P(Away) via Poisson score distribution

Step 2: ELO layer
- Maintain team ratings updated after each match
- Compute expected score from rating difference: E = 1 / (1 + 10^(-diff/400))
- Convert expected score to 1X2 probabilities via calibration
- Output: P(Home), P(Draw), P(Away)

Step 3: Weighted ensemble

P_ensemble(Home) = w * P_poisson(Home) + (1-w) * P_elo(Home)
P_ensemble(Draw) = w * P_poisson(Draw) + (1-w) * P_elo(Draw)
P_ensemble(Away) = w * P_poisson(Away) + (1-w) * P_elo(Away)
  • Weight w is tuned via walk-forward validation (typically 0.4–0.7 depending on the league)
  • Normalize outputs to sum to 1.0

Key Concepts

  • Complementarity: Poisson captures goal-scoring mechanics; ELO captures opponent-adjusted strength
  • Calibration: Both models produce raw probabilities that must be calibrated (e.g., via isotonic regression or Platt scaling) before ensembling
  • Weight optimization: Grid search over w ∈ [0,1] in 0.05 increments; evaluate using log-loss or Brier score on a holdout set
  • Dixon-Coles enhancement: Apply Dixon-Coles adjustment to Poisson λ to account for low-scoring dependencies (0-0, 1-0 draws are under-predicted by basic Poisson)

Python Implementation

import numpy as np
from scipy.stats import poisson

def poisson_elo_ensemble(
    home_team, away_team,
    poisson_probs,
    elo_probs,
    weight=0.6,
):
    """
    Combine Poisson and ELO probability outputs.

    Args:
        home_team: Team identifier
        away_team: Team identifier
        poisson_probs: dict with keys 'home', 'draw', 'away' from Poisson model
        elo_probs: dict with keys 'home', 'draw', 'away' from ELO model
        weight: Poisson weight (1-weight = ELO weight)

    Returns:
        Ensemble probabilities for 1X2
    """
    ensemble = {
        "home": weight * poisson_probs["home"] + (1 - weight) * elo_probs["home"],
        "draw": weight * poisson_probs["draw"] + (1 - weight) * elo_probs["draw"],
        "away": weight * poisson_probs["away"] + (1 - weight) * elo_probs["away"],
    }
    # Normalize to sum to 1
    total = sum(ensemble.values())
    for k in ensemble:
        ensemble[k] /= total
    return ensemble

def optimize_weight(validation_matches, poisson_model, elo_model):
    """
    Walk-forward weight optimization.
    validation_matches: list of (home, away, result) tuples
    """
    best_weight = 0.5
    best_score = float("inf")

    for w in np.arange(0, 1.05, 0.05):
        logloss_sum = 0
        for home, away, result in validation_matches:
            p_poisson = poisson_model.predict(home, away)
            p_elo = elo_model.predict(home, away)
            p_ensemble = poisson_elo_ensemble(home, away, p_poisson, p_elo, w)
            # log-loss for the actual result
            if result == "home":
                logloss_sum -= np.log(p_ensemble["home"])
            elif result == "draw":
                logloss_sum -= np.log(p_ensemble["draw"])
            else:
                logloss_sum -= np.log(p_ensemble["away"])

        if logloss_sum < best_score:
            best_score = logloss_sum
            best_weight = w

    return best_weight

Notes

  • The optimal weight varies by league: high-scoring leagues (Premier League) favor Poisson more; lower-scoring leagues benefit from ELO's systematic strength signal
  • ELO ratings need regular updates — new matches shift ratings; stale ELO produces worse predictions
  • Consider applying a recency decay to match history in the Poisson layer (Maher, 1982: recent seasons weighted more heavily than older ones)
  • The ensemble is only as good as the calibration step — raw outputs from both models are typically overconfident; apply Bayesian or isotonic calibration before ensembling
  • For World Cup specifically: small tournament sample (64 matches) means the ensemble weights should be borrowed from league-level optimization rather than tuned on World Cup data alone