PredictIQ Pro

Poisson football model in Python

A small, honest walkthrough: turn expected goals into match probabilities with a Poisson model, fix its weakness on low scores, then test it against the bookmaker market. About 40 lines of Python and no libraries beyond the standard one.

Reviewed 2026-10-14

1. The idea

Goals in football are rare and roughly independent, so the number of goals a team scores can be modelled as a Poisson distribution with one number, its expected goals (lambda). If you know lambda for both teams, you can compute the probability of every scoreline, and from those, every market.

2. Probability of every scoreline

This builds a grid where row h and column a is the probability of the match ending h-a.

import math

def poisson_pmf(k, lam):
    return math.exp(-lam) * lam**k / math.factorial(k)

def score_matrix(lam_home, lam_away, max_goals=8):
    # m[h][a] = probability the match ends h-a
    return [[poisson_pmf(h, lam_home) * poisson_pmf(a, lam_away)
             for a in range(max_goals + 1)] for h in range(max_goals + 1)]

3. Turn the grid into markets

Add up the right cells: home win is every cell where home goals beat away goals, a draw is the diagonal, over 2.5 is every cell with more than two total goals, and both teams to score is every cell where both scored.

def markets(m):
    n = len(m)
    home = sum(m[h][a] for h in range(n) for a in range(n) if h > a)
    draw = sum(m[h][h] for h in range(n))
    away = sum(m[h][a] for h in range(n) for a in range(n) if h < a)
    over25 = sum(m[h][a] for h in range(n) for a in range(n) if h + a > 2)
    btts = sum(m[h][a] for h in range(1, n) for a in range(1, n))
    total = home + draw + away  # a little probability sits beyond max_goals
    return {"home": home / total, "draw": draw / total, "away": away / total,
            "over_2_5": over25 / total, "btts": btts / total}

4. Estimate expected goals

The simplest approach multiplies the league average by each team's attack and defence strength, where 1.0 is average. The numbers below are made up for the example. In practice you work them out from past results, ideally weighting recent matches more.

# Example numbers only. Compute your own from past results.
league_home_avg, league_away_avg = 1.50, 1.15   # average goals per match
home_attack, home_defence = 1.20, 0.90          # 1.0 = league average
away_attack, away_defence = 0.95, 1.10

lam_home = league_home_avg * home_attack * away_defence
lam_away = league_away_avg * away_attack * home_defence

m = score_matrix(lam_home, lam_away)
print(markets(m))

5. Fix the low-score problem with Dixon-Coles

Plain Poisson treats the two teams' goals as independent, and it misprices 0-0, 1-0, 0-1 and 1-1. The Dixon-Coles adjustment nudges those four cells with a small parameter (rho, usually slightly negative) and renormalises. Rho should be fitted to data. The -0.10 here is only an example.

def dixon_coles(m, lam_home, lam_away, rho=-0.10):
    # Nudges the four lowest scorelines, where plain Poisson is known to be off
    out = [row[:] for row in m]
    out[0][0] *= 1 - lam_home * lam_away * rho
    out[0][1] *= 1 + lam_home * rho
    out[1][0] *= 1 + lam_away * rho
    out[1][1] *= 1 - rho
    total = sum(sum(r) for r in out)
    return [[x / total for x in r] for r in out]

m2 = dixon_coles(m, lam_home, lam_away)
print(markets(m2))

6. Compare with the bookmaker market

Convert bookmaker odds to probabilities and remove the margin (see the odds calculator at /odds-calculator). With the made-up strengths above the model says the home side wins about 59%, while the example odds imply about 45%. A gap that large usually means your inputs are off, not that the market is wrong. This is exactly what makes beating the market hard.

def devig(odds):
    raw = {k: 1 / v for k, v in odds.items()}
    t = sum(raw.values())
    return {k: v / t for k, v in raw.items()}

market = devig({"home": 2.10, "draw": 3.40, "away": 3.60})  # example prices
mine = markets(m2)
for k in ("home", "draw", "away"):
    print(k, round(mine[k], 3), round(market[k], 3), round(mine[k] - market[k], 3))

7. Score it properly with log loss

Accuracy (did the favourite win?) hides how good the probabilities are. Log loss rewards honest probabilities and punishes confident wrong ones. Always compare your model and the de-vigged market on the same finished matches, using only forecasts made before kickoff.

def log_loss(probs, results):
    # probs: list of {"home":..,"draw":..,"away":..}; results: "home"/"draw"/"away"
    return -sum(math.log(p[r]) for p, r in zip(probs, results)) / len(results)

# Lower is better. Score your model and the de-vigged market on the SAME finished matches.

8. Compare with a live API

Our own testing found that where bookmaker odds exist, a model like this adds nothing measurable beyond the market consensus, so the API serves the consensus there and uses a statistical model where there are no odds. The basis field says which. Pull the live numbers and compare them with yours. Start a 14-day trial at /register, no card needed.

import requests

API = "https://api.predictiqpro.com"
HEADERS = {"Authorization": "Token YOUR_API_KEY"}

r = requests.get(f"{API}/predictions/live", headers=HEADERS, timeout=15)
print(r.json())  # compare these probabilities with your own model's

Related guides

Ready to build?

Full endpoint reference is in the docs; plans are on the pricing page.

Start 14-day trial