Build a Poisson Football Model in Python

Step-by-step Python tutorial: turn match results into attack and defence strengths, expected goals and match probabilities, with tested code.

Algo Bet Editorial · · Python Tutorials · 4 min read

The Poisson model is the classic first football model: it turns each team's attacking and defensive strength into expected goals, and expected goals into the probability of every scoreline. In this tutorial we build it from scratch in plain Python — no libraries needed — and then look honestly at what it can and cannot do. The code was tested with Python 3.14 and works on any recent Python 3 version.

The idea

Goals in football are rare and roughly random, which is what the Poisson distribution describes: if a team is expected to score λ goals, the chance of scoring exactly k goals is λk × e−λ ÷ k!. The whole model is about estimating λ for each team in a given match, from:

  • the league's average home and away goals,
  • each team's attack strength (how much it scores compared with the average),
  • each opponent's defence strength (how much it concedes compared with the average).

Step 1: the data

We use a small set of results so you can check every number by hand. These results are invented for the tutorial. For real work, use a full season or more — for example the free CSV files at football-data.co.uk.

# Example data (invented): (home, away, home_goals, away_goals)
results = [
    ("Rovers", "United", 2, 1), ("City", "Athletic", 1, 1),
    ("United", "City", 0, 2), ("Athletic", "Rovers", 1, 3),
    ("Rovers", "City", 1, 1), ("United", "Athletic", 2, 0),
    ("City", "Rovers", 2, 2), ("Athletic", "United", 1, 1),
    ("Rovers", "Athletic", 2, 0), ("City", "United", 3, 1),
    ("United", "Rovers", 1, 2), ("Athletic", "City", 0, 1),
]

Step 2: league averages and team strengths

Each strength is a ratio to the league average: 1.00 is average, above 1.00 means more goals than average.

teams = sorted({t for r in results for t in r[:2]})
league_home = sum(r[2] for r in results) / len(results)   # average home goals
league_away = sum(r[3] for r in results) / len(results)   # average away goals

def average(values):
    return sum(values) / len(values)

strength = {}
for t in teams:
    home = [r for r in results if r[0] == t]
    away = [r for r in results if r[1] == t]
    strength[t] = {
        "home_attack": average([r[2] for r in home]) / league_home,
        "home_defence": average([r[3] for r in home]) / league_away,
        "away_attack": average([r[3] for r in away]) / league_away,
        "away_defence": average([r[2] for r in away]) / league_home,
    }

With the example data, the league averages are 1.33 home goals and 1.25 away goals per match. Rovers scored 5 goals in 3 home games, an average of 1.67, so their home attack strength is 1.67 ÷ 1.33 = 1.25.

Step 3: expected goals for a match

The home side's expected goals are its home attack × the visitor's away defence × the league's home average. The away side's are built the same way.

def expected_goals(home, away):
    h, a = strength[home], strength[away]
    return (h["home_attack"] * a["away_defence"] * league_home,
            a["away_attack"] * h["home_defence"] * league_away)

Step 4: from expected goals to probabilities

We compute the probability of every scoreline up to 10–10 and add them up into home win, draw and away win.

from math import exp, factorial

def pmf(k, lam):
    return lam ** k * exp(-lam) / factorial(k)

def predict(home, away, max_goals=10):
    lam_h, lam_a = expected_goals(home, away)
    p_home = p_draw = p_away = 0.0
    for i in range(max_goals + 1):
        for j in range(max_goals + 1):
            p = pmf(i, lam_h) * pmf(j, lam_a)
            if i > j:
                p_home += p
            elif i == j:
                p_draw += p
            else:
                p_away += p
    return lam_h, lam_a, p_home, p_draw, p_away

lam_h, lam_a, ph, pd, pa = predict("Rovers", "United")
print(f"Rovers v United: xG {lam_h:.2f} - {lam_a:.2f}")
print(f"Home {ph:.1%}  Draw {pd:.1%}  Away {pa:.1%}")

Running the full script prints:

Rovers v United: xG 2.50 - 0.53
Home 80.3%  Draw 13.7%  Away 5.9%

Step 5: read the result critically

An 80% home-win probability is far too confident for almost any real football match — and that is the most important lesson of this tutorial. It comes from twelve matches of invented data: Rovers look brilliant and United weak because each team played only six games. Real models deal with this in several ways:

  • More data. At least a full season, ideally weighted so recent matches count more.
  • Shrinkage. Pull extreme strengths back towards 1.00 when the sample is small.
  • Better inputs. Expected goals instead of goals, because chances are less noisy than finishing.
  • The Dixon–Coles adjustment. Mark Dixon and Stuart Coles (1997) corrected the basic model's tendency to underestimate low-scoring draws such as 0–0 and 1–1.

Key point: A model is only as good as its testing. Before trusting any probabilities, check them on matches the model has not seen, using scoring rules such as the Brier score.

Try it without code

The Poisson match predictor runs the same calculation in your browser: enter two expected-goals values and see every scoreline, the match result, over/under 2.5 and both teams to score.

Sources

18+ only. Models do not guarantee results. See our Responsible Gambling page.