DEV Community

kenn-kentonm
kenn-kentonm

Posted on

De-vigging bookmaker odds in Python: how I turned them into football probabilities (and why I stopped trusting my own model)" published: true

"De-vigging bookmaker odds in Python: how I turned them into football probabilities (and why I stopped trusting my own model)"
published: true
description: "A short walk-through of removing the bookmaker margin, scoring probabilities with log loss, and what happened when my model went up against the market."
tags: python, api, machinelearning, football
---I built a football forecasting model. Then I tested it against the thing it was supposed to beat: the odds bookmakers publish. This post covers the three small pieces of code behind that test, and what I changed in my API afterwards.

  1. Odds are not probabilities Decimal odds of 2.10 / 3.40 / 3.60 for home, draw, away imply probabilities of 1/odds: odds = {"home": 2.10, "draw": 3.40, "away": 3.60} raw = {k: 1 / v for k, v in odds.items()} print(sum(raw.values())) # ~1.048 The sum is above 1. The extra ~4.8% is the bookmaker's margin (the "vig" or overround). To get probabilities you have to remove it.
  2. De-vigging The simplest method is proportional normalisation: def devig(odds: dict[str, float]) -> dict[str, float]: raw = {k: 1 / v for k, v in odds.items()} total = sum(raw.values()) return {k: v / total for k, v in raw.items()} This assumes the margin is spread evenly across outcomes. It isn't exactly true, since bookmakers often shade the favourite or the longshot, and there are more careful methods (Shin's, the power method). Proportional is a fine baseline, and it's what I use. One bookmaker is noisy, so I average the de-vigged probabilities across many bookmakers: def consensus(books: list[dict[str, float]]) -> dict[str, float]: devigged = [devig(b) for b in books] return {k: sum(d[k] for d in devigged) / len(devigged) for k in devigged[0]} Average the probabilities, not the odds. Averaging odds doesn't preserve the sum to 1.
  3. Scoring: log loss To compare two sets of forecasts you need a proper scoring rule. Log loss is the standard one: import math

def log_loss(probs: list[dict[str, float]], results: list[str]) -> float:
return -sum(math.log(p[r]) for p, r in zip(probs, results)) / len(results)
Lower is better. It punishes confident wrong answers heavily, which is what you want. Accuracy ("did the favourite win?") hides that, because a forecast of 51% and one of 95% count the same when they're right.
Use only matches that have finished, and only forecasts saved before kickoff. Otherwise you're scoring hindsight.

  1. The result My stack was CatBoost, LightGBM and a Poisson goals model with a Dixon-Coles adjustment, blended and calibrated. On finished matches I scored it against the bookmaker consensus above. I also tried recalibrating the market probabilities and blending them with my model. The recalibration improved log loss by +0.0000, with a confidence interval of about ±0.005. The best blend weight for my model was 0. On matches with odds, my model told me nothing the market hadn't already priced in. So I stopped dressing it up.
  2. What the API does now Where bookmaker odds exist, the probabilities returned are the de-vigged consensus. Where they don't, they come from the model. Every response says which: basis: "market_consensus" | "statistical_model" prob_home/draw/away: the probabilities served model_prob_home/draw/away: the model's own numbers, for comparison The point of the basis field is that you never have to guess. If you build on this, you can filter, compare, or ignore either source. curl https://api.predictiqpro.com/predictions/live \ -H "Authorization: Token YOUR_API_KEY" [PASTE A REAL, TRIMMED RESPONSE FROM YOUR API HERE — fixture, basis, prob_, model_prob_] There are also endpoints for odds (/odds/{fixture_id}), odds history (/odds/history/{fixture_id}) and market analysis (/market-analysis/{fixture_id}). Scope is football only, and the odds cover the 1X2 market only.
  3. Try it The Developer plan has a 14-day trial with no card needed. Docs are at predictiqpro.com/docs. There's also an MCP server if you want an AI agent to call it: predictiqpro.com/mcp. The method is written up on the methodology page, and the scores are on the accuracy page. If you spot a flaw in the de-vigging or scoring above, tell me in the comments. I'd rather fix it than defend it.

Top comments (0)