DEV Community

Elio Liberatore
Elio Liberatore

Posted on

Correct score, BTTS and Over/Under probabilities with Dixon-Coles: what I learned building it for MLS and Liga MX

Most football prediction pages give you three numbers — home, draw, away — and no way to check where they came from. I wanted the opposite: one model, fitted on real results, that produces the 1X2 probabilities, the Over/Under 2.5 line, Both Teams To Score and the exact-score grid, all from the same place, so they can't contradict each other.

The model I ended up with is Dixon-Coles. This post is what it does, why it beats the simpler version most tutorials start with, and what came out when I ran it on eight leagues that don't get much attention from modellers: MLS, Liga MX, Liga de Expansión MX, the Brasileirão Série B, the USL Championship, Colombia's Primera A, Uruguay's Primera División and Norway's Eliteserien.

The starting point: independent Poisson

The classic approach gives every team an attack rating and a defence rating, adds a home advantage, and turns them into expected goals for each side of a fixture — call them λ for the home team and μ for the away team. Goals are then treated as two independent Poisson variables. The probability of a 2-1 is just P(home scores 2) × P(away scores 1).

It works surprisingly well. It also has one known blind spot: low scores. Real matches finish 0-0 and 1-1 more often than two independent Poisson draws predict, and 1-0 / 0-1 slightly less often. Anything that depends on those four cells — the draw, Under 2.5, BTTS "No" — inherits the error.

The Dixon-Coles correction

In 1997 Mark Dixon and Stuart Coles proposed a small fix. Keep the Poisson grid, but multiply the four low-score cells by a factor that depends on one extra parameter, ρ (rho):

Score Adjustment
0-0 1 − λμρ
0-1 1 + λρ
1-0 1 + μρ
1-1 1 − ρ
anything else 1

With a negative ρ, 0-0 and 1-1 go up and 1-0 / 0-1 go down — exactly the direction the data pulls.

To see how much that matters, take a match with 1.35 expected goals for the home side and 1.15 for the away side, and an illustrative ρ of −0.13:

Independent Poisson Dixon-Coles
Draw 26.8% 30.2%
0-0 8.2% 9.9%
1-1 12.7% 14.4%
Home win 41.3% 39.7%
Away win 31.8% 30.2%

Same expected goals, three and a half points more on the draw. If you compare model probabilities with prices, that is the difference between seeing an edge and not seeing one.

Two things the paper adds that tutorials often skip

Time decay. A result from three seasons ago should not count as much as last weekend's. Dixon and Coles weight each match by exp(−ξ·t), where t is its age in days. I use ξ = 0.0018, which halves a result's weight after roughly a year, and fit on three seasons of history.

Fit ρ from the league itself. ρ is not a universal constant. After fitting attack, defence, home advantage and the baseline by weighted maximum likelihood, I fit ρ separately for each league. They come out different. On 6 September, Colombia's Primera A gave a home advantage of 0.349 (on the log scale) and ρ = −0.044; Norway's Eliteserien gave 0.276 and ρ = −0.015. Colombian home sides get a bigger boost, and the low-score correction matters less in Norway.

Where the data comes from

All eight leagues come from ESPN's public scoreboard endpoint:

https://site.api.espn.com/apis/site/v2/sports/soccer/<league>/scoreboard?dates=YYYYMMDD-YYYYMMDD
Enter fullscreen mode Exit fullscreen mode

Two things cost me time and might save you some:

  1. Ask for a date range, not a single day. With dates= set to one day, a smaller league often returns nothing simply because it didn't play that day, which looks exactly like missing data. A week-long range removes the ambiguity.
  2. Filter by state, not by status name. Finished matches carry status.type.state === "post" and completed: true; scheduled ones are "pre". That is more robust than matching STATUS_FULL_TIME.

Slugs are not always what you'd guess. The USL Championship is usa.usl.1, not usa.2.

History depth is not the bottleneck. The Premier League scoreboard answers back to at least 2002-03, and MLS and Liga MX back to at least 2004-05. Three seasons is plenty.

What one prediction looks like

Here is a real row from a test run on 6 September — Atlanta United at home to Orlando City in MLS:

Field Value
λ home / λ away 1.57 / 1.63
ρ (MLS) −0.040
Home / Draw / Away 36.5% / 24.2% / 39.2%
Over 2.5 goals 62.1%
Both teams to score 64.1%

And from Liga MX the same day, Pumas UNAM against León: λ 1.94 against 0.94, so 60.1% / 23.0% / 16.9%. A strong home side, and the numbers say so.

Every probability comes from one score grid (0-0 up to 10-10), normalised to 1, so 1X2 sums to 1, Over + Under sums to 1 and BTTS Yes + No sums to 1. I checked that on every row of a 28-match Série B run; the error was floating-point noise.

The awkward case: promoted teams

A team with no matches in the lookback window has no rating. The honest options are to guess or to say so. I start it at league-average strength and flag every fixture it plays with dataQuality: "partial-new-team", so whoever uses the numbers can decide how much to trust them until the team has a few games on the board.

What it doesn't do

It doesn't know about injuries, suspensions, rotation, weather or a manager who has just been sacked. It treats every match in the lookback window the same apart from its age. It is a baseline, not an oracle — which is exactly what makes it useful to compare against prices.

Try it without writing the model

I packaged all of this as an Apify Actor: Soccer Match Predictions API — 1X2, Over/Under & BTTS Odds. You pick a league, it returns one row per upcoming fixture with everything above. There are ready-made examples, such as MLS correct score probabilities and Liga MX match predictions, and the rest are listed on the examples page.

If you'd rather build it yourself, the table of adjustments above and the ESPN endpoint are all you need to get started. Either way: simulation and data, not tips.

Top comments (0)