Coverage for src/pyrf/models.py: 100%
147 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-09-06 21:54 +0000
« prev ^ index » next coverage.py v7.15.4, created at 2026-09-06 21:54 +0000
1"""Statistical models for betting analysis."""
3from __future__ import annotations
5import abc
6from typing import TYPE_CHECKING, Any, cast
8import attrs
9import matplotlib.pyplot as plt
10import numpy as np
11import pandas as pd
12from matplotlib.axes import Axes
14if TYPE_CHECKING:
15 from .market import Quote
16from scipy.stats import poisson, skellam
18from .core import GoalGrid
21@attrs.define
22class Distribution(abc.ABC):
23 """Base class for all probability distributions."""
25 @abc.abstractmethod
26 def pmf(self, data: GoalGrid | pd.Series) -> pd.Series | pd.DataFrame:
27 """Calculate probability mass function."""
28 raise NotImplementedError # pragma: no cover
30 @abc.abstractmethod
31 def plot(self, data: GoalGrid | pd.Series, *args: Any, **kwds: Any) -> Axes:
32 """Plot the distribution."""
33 raise NotImplementedError # pragma: no cover
36@attrs.define
37class UnivariateDistribution(Distribution):
38 """Base class for univariate distributions."""
40 @abc.abstractmethod
41 def cdf(self, data: pd.Series) -> pd.Series:
42 """Calculate cumulative distribution function."""
43 raise NotImplementedError # pragma: no cover
46@attrs.define
47class BivariateDistribution(Distribution):
48 """Base class for bivariate distributions."""
50 @abc.abstractmethod
51 def pmf(self, data: GoalGrid) -> pd.DataFrame: # ty: ignore
52 """Calculate probability mass function."""
53 raise NotImplementedError # pragma: no cover
55 def plot(self, data: GoalGrid, *args: Any, **kwds: Any) -> Axes: # type: ignore[override]
56 """Plot the distribution as a heatmap."""
57 pmf_values = self.pmf(data)
58 _, ax = plt.subplots()
59 im = ax.imshow(pmf_values.values, *args, **kwds)
60 ax.set_xlabel("Home Goals")
61 ax.set_ylabel("Away Goals")
62 plt.colorbar(im, ax=ax)
63 return ax
66@attrs.define
67class Poisson(UnivariateDistribution):
68 """Poisson distribution for modeling goal counts."""
70 lambda_: float
72 def pmf(self, data: pd.Series) -> pd.Series: # ty: ignore
73 """Calculate probability mass function for given values."""
74 return pd.Series(poisson.pmf(data.values, self.lambda_), index=data.index, dtype=float)
76 def cdf(self, data: pd.Series) -> pd.Series:
77 """Calculate cumulative distribution function for given values."""
78 return pd.Series(poisson.cdf(data.values, self.lambda_), index=data.index, dtype=float)
80 def plot(self, data: pd.Series, *args: Any, **kwds: Any) -> Axes: # type: ignore[override]
81 """Plot the distribution."""
82 pmf_values = self.pmf(data)
83 ax: Axes = pmf_values.plot.bar(*args, **kwds)
84 ax.set_xlabel("Goals")
85 ax.set_ylabel("Probability")
86 ax.set_title(f"Poisson Distribution (λ={self.lambda_})")
87 return ax
90@attrs.define
91class Skellam(UnivariateDistribution):
92 """Skellam distribution for modeling goal differences."""
94 lambda_home: float
95 lambda_away: float
97 def pmf(self, data: GoalGrid | pd.Series) -> pd.Series:
98 """Calculate probability mass function for goal differences."""
99 goal_diffs: pd.Series
100 if isinstance(data, GoalGrid):
101 # Extract unique goal differences from the grid
102 diffs_arr = np.unique((data.home - data.away).to_numpy().flatten())
103 goal_diffs = pd.Series(diffs_arr, index=diffs_arr)
104 else:
105 goal_diffs = data
107 return pd.Series(
108 skellam.pmf(goal_diffs.values, self.lambda_home, self.lambda_away), index=goal_diffs.index, dtype=float
109 )
111 def cdf(self, data: GoalGrid | pd.Series) -> pd.Series:
112 """Calculate cumulative distribution function for goal differences."""
113 goal_diffs: pd.Series
114 if isinstance(data, GoalGrid):
115 diffs_arr = np.unique((data.home - data.away).to_numpy().flatten())
116 goal_diffs = pd.Series(diffs_arr, index=diffs_arr)
117 else:
118 goal_diffs = data
120 return pd.Series(
121 skellam.cdf(goal_diffs.values, self.lambda_home, self.lambda_away), index=goal_diffs.index, dtype=float
122 )
124 def plot(self, data: GoalGrid | pd.Series, *args: Any, **kwds: Any) -> Axes:
125 """Plot the distribution."""
126 pmf_values = self.pmf(data)
127 ax: Axes = pmf_values.plot.bar(*args, **kwds)
128 ax.set_xlabel("AsianHandicap")
129 ax.set_ylabel("Probability")
130 ax.set_title(f"Skellam Distribution (λ₁={self.lambda_home}, λ₂={self.lambda_away})")
131 return ax
134@attrs.define
135class BivariatePoisson(BivariateDistribution):
136 """Bivariate Poisson distribution for modeling home and away goals."""
138 lambda_home: float
139 lambda_away: float
141 def pmf(self, data: GoalGrid) -> pd.DataFrame:
142 """Calculate probability mass function for home and away goals."""
143 return pd.DataFrame(
144 poisson.pmf(data.home.values, self.lambda_home) * poisson.pmf(data.away.values, self.lambda_away),
145 index=data.home.index,
146 columns=data.home.columns,
147 dtype=float,
148 )
150 def plot(self, data: GoalGrid, *args: Any, **kwds: Any) -> Axes:
151 """Plot the distribution as a heatmap."""
152 ax = super().plot(data, *args, **kwds)
153 ax.set_title(f"Bivariate Poisson (λ_home={self.lambda_home}, λ_away={self.lambda_away})")
154 return ax
157@attrs.define
158class DixonColes(BivariateDistribution):
159 """Dixon-Coles adjusted bivariate Poisson distribution.
161 This class implements the Dixon-Coles (1997) adjustment to the independent bivariate
162 Poisson distribution. The adjustment modifies the probabilities for low-scoring outcomes
163 (0-0, 1-0, 0-1, 1-1) to account for correlation between home and away goals.
165 The adjustment factors are:
166 - P(0,0) *= 1 - λ_home * λ_away * ρ
167 - P(1,0) *= 1 + λ_away * ρ
168 - P(0,1) *= 1 + λ_home * ρ
169 - P(1,1) *= 1 - ρ
171 Where ρ (rho) is the dependence parameter. Typically ρ < 0 is used to increase
172 the probability of low-scoring draws relative to the independent Poisson model.
174 Attributes:
175 dist: The base bivariate distribution to be adjusted.
176 rho: Dependence parameter. Must satisfy constraints to ensure valid probabilities.
178 References:
179 Dixon, M. J., & Coles, S. G. (1997). Modelling Association Football Scores and
180 Inefficiencies in the Football Betting Market. Journal of the Royal Statistical
181 Society: Series C (Applied Statistics), 46(2), 265-280.
182 """
184 dist: BivariatePoisson = attrs.field(init=True)
185 rho: float = attrs.field(init=True, default=0.0)
187 def pmf(self, data: GoalGrid) -> pd.DataFrame:
188 """Calculate probability mass function with Dixon-Coles adjustment.
190 This method adjusts the basic bivariate Poisson PMF by modifying the
191 probabilities for the four low-scoring outcomes (0-0, 1-0, 0-1, 1-1).
193 Args:
194 data: GoalGrid containing home and away goal combinations to calculate probabilities for.
196 Returns:
197 DataFrame with adjusted probabilities for each home-away goal combination.
198 """
199 # Get the base independent bivariate Poisson PMF
200 pmf = self.dist.pmf(data=data)
202 lambda_home = self.dist.lambda_home
203 lambda_away = self.dist.lambda_away
204 rho = self.rho
206 # Calculate the Dixon-Coles adjustment factors for low-scoring outcomes
207 # These are applied as multiplicative adjustments to specific cells
208 tau_00 = 1 - lambda_home * lambda_away * rho
209 tau_10 = 1 + lambda_away * rho
210 tau_01 = 1 + lambda_home * rho
211 tau_11 = 1 - rho
213 # Apply adjustments to the specific cells if they exist in the grid
214 # The grid has home goals as columns and away goals as rows
215 # Note: GoalGrid uses string indices ("0", "1", etc.)
216 pmf_adjusted = pmf.copy()
218 # Check if the required indices exist and apply adjustments
219 if "0" in pmf_adjusted.index and "0" in pmf_adjusted.columns:
220 pmf_adjusted.loc["0", "0"] = cast(float, pmf_adjusted.loc["0", "0"]) * tau_00
221 if "0" in pmf_adjusted.index and "1" in pmf_adjusted.columns:
222 pmf_adjusted.loc["0", "1"] = cast(float, pmf_adjusted.loc["0", "1"]) * tau_10
223 if "1" in pmf_adjusted.index and "0" in pmf_adjusted.columns:
224 pmf_adjusted.loc["1", "0"] = cast(float, pmf_adjusted.loc["1", "0"]) * tau_01
225 if "1" in pmf_adjusted.index and "1" in pmf_adjusted.columns:
226 pmf_adjusted.loc["1", "1"] = cast(float, pmf_adjusted.loc["1", "1"]) * tau_11
228 return pmf_adjusted
230 def plot(self, data: GoalGrid, *args: Any, **kwds: Any) -> Axes:
231 """Plot the distribution as a heatmap."""
232 ax = super().plot(data, *args, **kwds)
233 ax.set_title(f"Dixon-Coles (base={type(self.dist).__name__}, ρ={self.rho})")
234 return ax
236 @classmethod
237 def calibrate(
238 cls,
239 tg_quote: Quote,
240 ah_quote: Quote,
241 rho: float = -0.10,
242 xtol: float = 1e-3,
243 ) -> DixonColes:
244 """Calibrate a Dixon-Coles model from Total Goals and Asian Handicap quotes.
246 Uses a two-step root-finding procedure:
248 1. **Total lambda**: finds the total scoring rate that matches the implied
249 over-probability from the Total Goals quote.
250 2. **Lambda split**: finds the home/away split that matches the implied
251 home-probability from the Asian Handicap quote.
253 Args:
254 tg_quote: A Total Goals market quote (with margin; will be stripped).
255 ah_quote: An Asian Handicap market quote (with margin; will be stripped).
256 rho: Dixon-Coles dependence parameter (typically negative).
257 xtol: Solver tolerance for root-finding.
259 Returns:
260 A calibrated DixonColes instance.
262 Raises:
263 ValueError: If quotes are for the wrong market type.
264 """
265 from scipy.optimize import root_scalar
267 from .market import BetState, Market
268 from .payoffs import AsianHandicap, TotalGoals
270 if tg_quote.market != Market.TOTAL_GOAL:
271 raise ValueError(f"Expected TOTAL_GOAL quote, got {tg_quote.market}")
272 if ah_quote.market != Market.ASIAN_HCAP:
273 raise ValueError(f"Expected ASIAN_HCAP quote, got {ah_quote.market}")
275 # Build kwargs — only pass rho if the class accepts it at init time
276 # (subclasses like RingfinityModelZero fix rho via init=False)
277 rho_field = attrs.fields(cls).rho
278 init_kwargs: dict[str, Any] = {"rho": rho} if rho_field.init else {}
280 def _make(dist: BivariatePoisson) -> DixonColes:
281 """Construct a model instance with the appropriate rho parameter."""
282 return cls(dist=dist, **init_kwargs)
284 # Step 1: calibrate total lambda from Total Goals market
285 tg_fair = tg_quote.margin_strip()
286 target_over_prob = tg_fair.side_a.odds.implied_probability()
287 tg_line = tg_quote.line.value
289 def _tg_objective(lam_total: float) -> float:
290 """Objective function for calibrating total lambda from Total Goals quote."""
291 dist = BivariatePoisson(lambda_home=lam_total / 2, lambda_away=lam_total / 2)
292 model = _make(dist)
293 model_prob = TotalGoals(line=tg_line).implied_prob(model=model)[BetState.OVER] # type: ignore[arg-type]
294 return target_over_prob - model_prob
296 tg_result = root_scalar(_tg_objective, bracket=[1e-5, 30], xtol=xtol)
297 lambda_total = tg_result.root
299 # Step 2: calibrate home/away split from Asian Handicap market
300 ah_fair = ah_quote.margin_strip()
301 target_home_prob = ah_fair.side_a.odds.implied_probability()
302 ah_line = ah_quote.line.value
304 def _ah_objective(y: float) -> float:
305 """Objective function for calibrating home/away split from Asian Handicap quote."""
306 dist = BivariatePoisson(lambda_home=lambda_total / 2 - y, lambda_away=lambda_total / 2 + y)
307 model = _make(dist)
308 model_prob = AsianHandicap(line=ah_line).implied_prob(model=model)[BetState.HOME] # type: ignore[arg-type]
309 return target_home_prob - model_prob
311 eps = 1e-5
312 bracket_bound = lambda_total / 2 - eps
313 ah_result = root_scalar(_ah_objective, bracket=[-bracket_bound, bracket_bound], xtol=xtol)
315 # Build final model
316 lambda_home = lambda_total / 2 - ah_result.root
317 lambda_away = lambda_total / 2 + ah_result.root
318 dist = BivariatePoisson(lambda_home=lambda_home, lambda_away=lambda_away)
319 return _make(dist)
322@attrs.define
323class RingfinityModelZero(DixonColes):
324 """Dixon-Coles model with a fixed dependence parameter (ρ = -0.10).
326 This is a convenience preset that freezes the Dixon-Coles rho parameter,
327 so only the base BivariatePoisson distribution needs to be supplied.
329 Attributes:
330 dist: The base bivariate Poisson distribution.
331 """
333 rho: float = attrs.field(init=False, default=-0.10)