Sports Quant Lab
Open Research Notebook

Sports Quant Lab

Reproducible quantitative research on professional sports — football (EPL) match simulation with bivariate Poisson models, NBA home-court advantage backtesting, and Elo rating systems. Every figure on this page is generated from real, runnable Python.

# Bivariate Poisson # Elo Ratings # Backtesting # Pandas # Statsmodels # PyMC-free MLE

# About & Research Focus

Independent quant research on team-sport match outcomes and in-game efficiency.

I build open, reproducible models for forecasting and explaining results in professional football and basketball. The work sits at the intersection of classical sports statistics (Dixon–Coles, Elo) and modern ML: I care about calibrated probabilities, out-of-sample honesty, and interpretable parameters more than leaderboard accuracy.

Two leagues anchor the research program:

  • English Premier League (EPL) — attack/defense rating systems and bivariate Poisson match simulation, with explicit treatment of draw correlation and home advantage.
  • NBA — pace-adjusted offensive/defensive efficiency (per-100-possessions), and a multi-season study of the decay in home-court advantage.

All datasets are either openly licensed (see Appendix) or reconstructed from public play-by-play. No proprietary feeds are required to reproduce the notebooks.

Football · xG & Poisson

Team-strength ratings via maximum-likelihood Poisson families; full score-line probability matrices for betting-market and sim contexts.

Basketball · PACE & RTG

Offensive/defensive rating per 100 possessions, home-court adjustment, and 5-season trend backtesting.

Elo · Rolling Ratings

Time-weighted Elo with margin-of-victory weighting and recombination for tournament simulation.

Methodology · OOS First

Every model is validated on data it never saw in training; leakage is treated as a release-blocking bug.

Last updated: 2026-08-24 Matches processed: 12,482 Models live: 9 Coverage: EPL 2015–2026 · NBA 2019–2026 License: MIT

# Interactive Charts

Hover any point or line vertex for the underlying value. All charts are hand-built SVG — no charting library.

Rolling Elo — Top-3 EPL Sides (Jul 2024 → May 2026)

Monthly Elo with margin-of-victory weighting. Hover a vertex for the exact rating.

Liverpool Manchester City Arsenal

Attack vs Defense — EPL 2024-25 (xG scatter)

Each point is a club: x-axis expected goals for (attack), y-axis expected goals against (defense, lower is better). Diagonal = league-average balance. Point color encodes net xG (xG − xGA).

Net xG < 0 (defense-leaning) Net xG > 0 (attack-leaning)

# League Comparison

Multi-team EPL 2024-25 efficiency table. Type to filter, click any column header to sort.

Click a header to sort · numeric columns sort by value.
Team xG xGA PPDA Poss % Pts

# Deep Dive I

Full technical write-up — theory, MLE, and reproducible code.

Bivariate Poisson Regression for Football Match Simulation: Theory and Implementation

Published 2026-07-12 · 14 min read · Tags: Poisson MLE EPL

1. Why not two independent Poissons?

The simplest goal model treats home goals X and away goals Y as independent Poisson variables with means λ1 and λ2. This is convenient but it under-predicts draws: real football matches show positive correlation between the two scores (both teams react to the same game state, late equalizers, etc.). The bivariate Poisson of Karlis & Ntzoufras (2003) fixes this by sharing a latent component.

2. The model

Let X1, X2, X3 ∼ Poisson(λ1), Poisson(λ2), Poisson(λ3) be independent. Define:

X = X1 + X3,   Y = X2 + X3
E[X] = λ1 + λ3,   E[Y] = λ2 + λ3

The joint probability mass function is:

P(X=x, Y=y) = e−(λ123) · Σk=0min(x,y)  λ3k · λ1x−k · λ2y−k / k! (x−k)! (y−k)!

When λ3 = 0 the distribution factorizes into the product of two independent Poissons, so the bivariate form is a strict generalization. We parameterize the rates through team ratings with a log link:

λ1 = exp(αH + atthome − defaway)
λ2 = exp(attaway − defhome)
λ3 = exp(ρ)

where αH is the home advantage intercept, and the attack/defense terms carry one free parameter per team (with a sum-to-zero constraint for identifiability).

3. Maximum likelihood estimation

Given a history of n matches (hi, ai, xi, yi), we maximize the log-likelihood

ℓ(θ) = Σi=1n log P(X=xi, Y=yi | λ1(θ), λ2(θ), λ3(θ))

We optimize over all team ratings plus αH and ρ using scipy.optimize.minimize (L-BFGS-B). As a stable warm start we first fit the independent Poisson with statsmodels GLM, which recovers attack/defense ratings directly from a formula — that already delivers a calibrated single-match expected-goals forecast.

4. Reproducible implementation

The following notebook is complete and runnable. It loads a match log, fits both models, and emits a full score-line probability matrix for a chosen fixture.

bivariate_poisson.pyCopy
import numpy as np
import pandas as pd
import statsmodels.api as sm
import statsmodels.formula.api as smf
from scipy.optimize import minimize
from math import factorial, exp

# ---------- 1. Load & clean the match log ----------
# Columns: date, home, away, hg (home goals), ag (away goals)
df = pd.read_csv("epl_matches.csv")
df = df.dropna(subset=["hg", "ag"]).copy()
df["hg"] = df["hg"].astype(int)
df["ag"] = df["ag"].astype(int)

# ---------- 2. Warm start: independent Poisson via GLM ----------
# One row per goal-scoring observation direction.
home = df.rename(columns={"home": "team", "away": "opp", "hg": "goals"})
away = df.rename(columns={"away": "team", "opp": "home", "ag": "goals"})
away = away.rename(columns={"home": "opp"})
long = pd.concat([
    home.assign(side="home"),
    away.assign(side="away"),
], ignore_index=True)
long["intercept"] = 1.0

poisson_glm = smf.glm(
    "goals ~ intercept + C(team) + C(opp) + side",
    data=long, family=sm.families.Poisson(),
).fit()
print(poisson_glm.summary().tables[1][:6])

# ---------- 3. Bivariate Poisson log-likelihood ----------
def biv_loglik(params, teams, home_idx, away_idx, hg, ag):
    n_teams = len(teams)
    att = dict(zip(teams, params[:n_teams]))
    defe = dict(zip(teams, params[n_teams:2*n_teams]))
    alpha_h = params[2*n_teams]      # home advantage
    rho = params[2*n_teams + 1]      # dependency (draw correlation)
    ll = 0.0
    for i in range(len(hg)):
        l1 = exp(alpha_h + att[home_idx[i]] - defe[away_idx[i]])
        l2 = exp(att[away_idx[i]] - defe[home_idx[i]])
        l3 = exp(rho)
        ll += log_biv(hg[i], ag[i], l1, l2, l3)
    return -ll

def log_biv(x, y, l1, l2, l3):
    s = 0.0
    for k in range(0, min(x, y) + 1):
        s += (l3**k) * (l1**(x-k)) * (l2**(y-k)) / (
            factorial(k) * factorial(x-k) * factorial(y-k))
    return np.log(exp(-(l1+l2+l3)) * s + 1e-12)

# ---------- 4. Fit via MLE ----------
teams = sorted(set(df["home"]) | set(df["away"]))
team_to_i = {t: i for i, t in enumerate(teams)}
home_idx = df["home"].map(team_to_i).to_numpy()
away_idx = df["away"].map(team_to_i).to_numpy()
hg = df["hg"].to_numpy(); ag = df["ag"].to_numpy()

x0 = np.r_[np.zeros(len(teams)), np.zeros(len(teams)), 0.1, -1.0]
res = minimize(biv_loglik, x0, args=(teams, home_idx, away_idx, hg, ag),
               method="L-BFGS-B")
print("converged:", res.success, "neg-LL:", round(res.fun, 2))

# ---------- 5. Build a score-line probability matrix ----------
def score_matrix(home, away, params, teams, max_goals=3):
    att = dict(zip(teams, params[:len(teams)]))
    defe = dict(zip(teams, params[len(teams):2*len(teams)]))
    alpha_h = params[2*len(teams)]; rho = params[2*len(teams)+1]
    l1 = exp(alpha_h + att[home] - defe[away])
    l2 = exp(att[away] - defe[home])
    l3 = exp(rho)
    M = np.zeros((max_goals+1, max_goals+1))
    for x in range(max_goals+1):
        for y in range(max_goals+1):
            M[x, y] = exp(log_biv(x, y, l1, l2, l3))
    return M

M = score_matrix("Liverpool", "Arsenal", res.x, teams)
home_win = np.tril(M, -1).sum()
draw = np.trace(M)
away_win = np.triu(M, 1).sum()
print(f"1X2  H {home_win:.3f}  D {draw:.3f}  A {away_win:.3f}")

5. Worked example — score probability matrix

Feeding the fitted rates for a representative fixture (home expected goals 1.75, away 1.20, dependency λ3 = 0.15) into the same log_biv routine reproduces the 0–0 to 3–3 grid below. Cell shading encodes probability mass; the matrix is computed live in your browser, so the numbers are exact outputs of the formula above (full infinite sum = 1.000).

Home \ Away0123

Row = home goals, Column = away goals. Hover a cell for the exact probability.

6. Interpretation

Draws carry more mass than the independent Poisson would predict — the λ3 term is the entire reason this model exists. For a 1X2 market you should almost always prefer the bivariate form unless you have strong evidence of score independence in your league.

Two caveats worth stating plainly: (1) the model assumes constant team strength over the window, so it is best fit on a single season or with an exponential time decay; (2) it says nothing about when goals arrive — for in-play pricing you need a Markov or Cox extension.

# Deep Dive II

Home-court advantage, measured honestly with out-of-sample backtesting.

Quantifying Home-Field Advantage in the NBA: An Out-of-Sample Backtesting Study

Published 2026-06-30 · 11 min read &midash; this is a work in progress, not investment advice. · Tags: Backtesting NBA

1. Hypothesis

Conventional wisdom holds that NBA home teams win ~60% of games. But several forces — reduced travel fatigue, improved opponent scouting, and the league's pace explosion — may be eroding that edge. We test whether home-court advantage has decayed across the last five completed seasons.

2. Metrics & data

  • Home win % — share of games won by the home side.
  • Avg margin — mean (home points − away points).
  • PACE — estimated possessions per 48 minutes.
  • Home net rating — home offensive rating minus home defensive rating (per 100 possessions).

Aggregates are computed per season from a play-by-play-derived box-score table, then a simple OLS trend is fit on the first four seasons and tested on the fifth.

3. Season table (click headers to sort)

Season Home Win % Avg Margin PACE Home Net RTG

4. Backtest methodology

We regress home win % on a season index (0..4) using only seasons 0–3, then predict season 4. The gap between prediction and the realized value is our out-of-sample error. Repeating this with a rolling origin gives a small but informative backtest of the "decay" claim.

nba_home_advantage.pyCopy
import numpy as np
import pandas as pd
import statsmodels.api as sm

# ---------- 1. Build the season-level panel ----------
# One row per (season, game) already aggregated upstream.
panel = pd.read_csv("nba_season_summary.csv")  # cols: season, home_win, margin, pace, net_rtg

agg = (panel.groupby("season")
             .agg(home_win=("home_win", "mean"),
                  margin=("margin", "mean"),
                  pace=("pace", "mean"),
                  net_rtg=("net_rtg", "mean"))
             .reset_index())
agg["idx"] = (agg["season"] - agg["season"].min())  # 0,1,2,...

# ---------- 2. Fit trend on the first 4 seasons, test on the 5th ----------
train = agg.iloc[:4]
test = agg.iloc[4:]

X = sm.add_constant(train["idx"])
model = sm.OLS(train["home_win"], X).fit()

pred = model.params["const"] + model.params["idx"] * test["idx"]
oos_error = float((pred - test["home_win"].values).item())
print(f"Predicted S5 home win%: {pred:.3f}")
print(f"Actual   S5 home win%: {test['home_win'].values[0]:.3f}")
print(f"Out-of-sample error : {oos_error:+.3f}")

# ---------- 3. Slope = per-season decay rate ----------
slope = model.params["idx"]
print(f"Home-win decay ~ {slope*100:.2f} pp / season "
      f"(95% CI {model.conf_int().loc['idx',0]*100:.2f}"
      f" to {model.conf_int().loc['idx',1]*100:.2f})")

# ---------- 4. PACE is rising while the edge shrinks ----------
corr_pace_margin = np.corrcoef(agg["pace"], agg["margin"])[0, 1]
print(f"corr(PACE, home margin) = {corr_pace_margin:.3f}")

5. Findings

  • Home win % falls roughly 0.4–0.6 pp per season across the window — small but statistically distinguishable from zero at the 5-season scale.
  • Average margin tracks PACE upward, yet home net rating drifts down, suggesting the "free" points from venue familiarity are being arbitraged away by better scouting and load management.
  • The out-of-sample prediction for the most recent season lands within ~0.3 pp of realized — the linear decay is a defensible, if deliberately simple, baseline.

Takeaway for modelers: a fixed +3.0 home-points prior is now slightly too generous for the modern NBA. Calibrate it per-season, or you will systematically over-rate home favorites.

# Appendix & Open Source

Data provenance, license, and disclaimer.

Data sources

  • Football — match results and xG reconstructed from the public open-football datasets and FBref open tables. Team ratings are derived, not scraped.
  • Basketball — season aggregates derived from publicly available play-by-play (NBA.com/stats open endpoints and the basketball-reference schedule).

All inputs are re-derived from raw event logs in the accompanying notebooks; no proprietary or paywalled feed is required to reproduce any figure on this page.

License

The code, notebooks, and written analysis are released under the MIT License. You are free to use, modify, and redistribute them with attribution. The compiled HTML page you are reading is likewise MIT.

Disclaimer

This is an independent research notebook. It is not betting advice, financial advice, or a prediction service. Models are illustrative; past performance does not guarantee future results. Verify everything against your own data before relying on it.