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

1"""Statistical models for betting analysis.""" 

2 

3from __future__ import annotations 

4 

5import abc 

6from typing import TYPE_CHECKING, Any, cast 

7 

8import attrs 

9import matplotlib.pyplot as plt 

10import numpy as np 

11import pandas as pd 

12from matplotlib.axes import Axes 

13 

14if TYPE_CHECKING: 

15 from .market import Quote 

16from scipy.stats import poisson, skellam 

17 

18from .core import GoalGrid 

19 

20 

21@attrs.define 

22class Distribution(abc.ABC): 

23 """Base class for all probability distributions.""" 

24 

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 

29 

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 

34 

35 

36@attrs.define 

37class UnivariateDistribution(Distribution): 

38 """Base class for univariate distributions.""" 

39 

40 @abc.abstractmethod 

41 def cdf(self, data: pd.Series) -> pd.Series: 

42 """Calculate cumulative distribution function.""" 

43 raise NotImplementedError # pragma: no cover 

44 

45 

46@attrs.define 

47class BivariateDistribution(Distribution): 

48 """Base class for bivariate distributions.""" 

49 

50 @abc.abstractmethod 

51 def pmf(self, data: GoalGrid) -> pd.DataFrame: # ty: ignore 

52 """Calculate probability mass function.""" 

53 raise NotImplementedError # pragma: no cover 

54 

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 

64 

65 

66@attrs.define 

67class Poisson(UnivariateDistribution): 

68 """Poisson distribution for modeling goal counts.""" 

69 

70 lambda_: float 

71 

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) 

75 

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) 

79 

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 

88 

89 

90@attrs.define 

91class Skellam(UnivariateDistribution): 

92 """Skellam distribution for modeling goal differences.""" 

93 

94 lambda_home: float 

95 lambda_away: float 

96 

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 

106 

107 return pd.Series( 

108 skellam.pmf(goal_diffs.values, self.lambda_home, self.lambda_away), index=goal_diffs.index, dtype=float 

109 ) 

110 

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 

119 

120 return pd.Series( 

121 skellam.cdf(goal_diffs.values, self.lambda_home, self.lambda_away), index=goal_diffs.index, dtype=float 

122 ) 

123 

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 

132 

133 

134@attrs.define 

135class BivariatePoisson(BivariateDistribution): 

136 """Bivariate Poisson distribution for modeling home and away goals.""" 

137 

138 lambda_home: float 

139 lambda_away: float 

140 

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 ) 

149 

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 

155 

156 

157@attrs.define 

158class DixonColes(BivariateDistribution): 

159 """Dixon-Coles adjusted bivariate Poisson distribution. 

160 

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. 

164 

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 - ρ 

170 

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. 

173 

174 Attributes: 

175 dist: The base bivariate distribution to be adjusted. 

176 rho: Dependence parameter. Must satisfy constraints to ensure valid probabilities. 

177 

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 """ 

183 

184 dist: BivariatePoisson = attrs.field(init=True) 

185 rho: float = attrs.field(init=True, default=0.0) 

186 

187 def pmf(self, data: GoalGrid) -> pd.DataFrame: 

188 """Calculate probability mass function with Dixon-Coles adjustment. 

189 

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). 

192 

193 Args: 

194 data: GoalGrid containing home and away goal combinations to calculate probabilities for. 

195 

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) 

201 

202 lambda_home = self.dist.lambda_home 

203 lambda_away = self.dist.lambda_away 

204 rho = self.rho 

205 

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 

212 

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() 

217 

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 

227 

228 return pmf_adjusted 

229 

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 

235 

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. 

245 

246 Uses a two-step root-finding procedure: 

247 

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. 

252 

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. 

258 

259 Returns: 

260 A calibrated DixonColes instance. 

261 

262 Raises: 

263 ValueError: If quotes are for the wrong market type. 

264 """ 

265 from scipy.optimize import root_scalar 

266 

267 from .market import BetState, Market 

268 from .payoffs import AsianHandicap, TotalGoals 

269 

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}") 

274 

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 {} 

279 

280 def _make(dist: BivariatePoisson) -> DixonColes: 

281 """Construct a model instance with the appropriate rho parameter.""" 

282 return cls(dist=dist, **init_kwargs) 

283 

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 

288 

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 

295 

296 tg_result = root_scalar(_tg_objective, bracket=[1e-5, 30], xtol=xtol) 

297 lambda_total = tg_result.root 

298 

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 

303 

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 

310 

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) 

314 

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) 

320 

321 

322@attrs.define 

323class RingfinityModelZero(DixonColes): 

324 """Dixon-Coles model with a fixed dependence parameter (ρ = -0.10). 

325 

326 This is a convenience preset that freezes the Dixon-Coles rho parameter, 

327 so only the base BivariatePoisson distribution needs to be supplied. 

328 

329 Attributes: 

330 dist: The base bivariate Poisson distribution. 

331 """ 

332 

333 rho: float = attrs.field(init=False, default=-0.10)