Skip to content

Commit bf2e97d

Browse files
author
Your Name
committed
fix: fixed how the PoissonGoalsModel gradient handled the weights
1 parent e4ab2ca commit bf2e97d

File tree

5 files changed

+201
-8
lines changed

5 files changed

+201
-8
lines changed

penaltyblog/models/gradients.pyx

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,8 @@ def poisson_gradient(
4141
cnp.ndarray[long, ndim=1] home_idx,
4242
cnp.ndarray[long, ndim=1] away_idx,
4343
cnp.ndarray[long, ndim=1] goals_home,
44-
cnp.ndarray[long, ndim=1] goals_away
44+
cnp.ndarray[long, ndim=1] goals_away,
45+
cnp.ndarray[double, ndim=1] weights
4546
):
4647
cdef int n_teams = attack.shape[0]
4748
cdef int n_games = home_idx.shape[0]
@@ -62,11 +63,11 @@ def poisson_gradient(
6263
lambda_home = exp(attack[h] + defence[a] + hfa)
6364
lambda_away = exp(attack[a] + defence[h])
6465

65-
grad_attack[h] += goals_home[i] - lambda_home
66-
grad_attack[a] += goals_away[i] - lambda_away
67-
grad_defence[a] += goals_home[i] - lambda_home
68-
grad_defence[h] += goals_away[i] - lambda_away
69-
grad_hfa += goals_home[i] - lambda_home
66+
grad_attack[h] += (goals_home[i] - lambda_home) * weights[i]
67+
grad_attack[a] += (goals_away[i] - lambda_away) * weights[i]
68+
grad_defence[a] += (goals_home[i] - lambda_home) * weights[i]
69+
grad_defence[h] += (goals_away[i] - lambda_away) * weights[i]
70+
grad_hfa += (goals_home[i] - lambda_home) * weights[i]
7071

7172
return np.concatenate([grad_attack, grad_defence, [grad_hfa]])
7273

penaltyblog/models/poisson.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,7 @@ def _gradient(self, params):
131131
self.away_idx,
132132
self.goals_home,
133133
self.goals_away,
134+
self.weights,
134135
)
135136

136137
def _loss_function(self, params):

penaltyblog/version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = "1.6.1" # noqa
1+
__version__ = "1.6.2" # noqa

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "penaltyblog"
3-
version = "1.6.1"
3+
version = "1.6.2"
44
description = "Football (soccer) Data & Modelling Made Easy"
55
authors = [{ name = "Martin Eastwood", email = "[email protected]" }]
66
readme = "README.md"
Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
import numpy as np
2+
import pytest
3+
4+
import penaltyblog as pb
5+
6+
7+
@pytest.mark.local
8+
def test_poisson_gradient_with_weights(fixtures):
9+
"""Test that the Poisson gradient correctly uses weights."""
10+
df = fixtures.copy()
11+
12+
# Create weights that give higher importance to recent matches
13+
# Let's say we have 100 matches, give weight 2.0 to last 20 matches, 1.0 to others
14+
n_matches = len(df)
15+
weights = np.ones(n_matches)
16+
weights[-20:] = 2.0 # Double weight for last 20 matches
17+
18+
# Create model with weights
19+
clf_weighted = pb.models.PoissonGoalsModel(
20+
df["goals_home"], df["goals_away"], df["team_home"], df["team_away"], weights
21+
)
22+
23+
# Create model without weights for comparison
24+
clf_unweighted = pb.models.PoissonGoalsModel(
25+
df["goals_home"], df["goals_away"], df["team_home"], df["team_away"]
26+
)
27+
28+
# Fit both models
29+
clf_weighted.fit(use_gradient=True)
30+
clf_unweighted.fit(use_gradient=True)
31+
32+
# The weighted model should produce different parameters than unweighted
33+
params_weighted = clf_weighted.get_params()
34+
params_unweighted = clf_unweighted.get_params()
35+
36+
# Parameters should be different (at least for some teams)
37+
differences = []
38+
for team in clf_weighted.teams:
39+
attack_diff = abs(
40+
params_weighted[f"attack_{team}"] - params_unweighted[f"attack_{team}"]
41+
)
42+
defense_diff = abs(
43+
params_weighted[f"defense_{team}"] - params_unweighted[f"defense_{team}"]
44+
)
45+
differences.extend([attack_diff, defense_diff])
46+
47+
# At least some parameters should be different
48+
assert any(
49+
diff > 0.01 for diff in differences
50+
), "Weighted and unweighted models should produce different parameters"
51+
52+
53+
@pytest.mark.local
54+
def test_poisson_gradient_weighted_vs_unweighted_consistency(fixtures):
55+
"""Test that gradient with uniform weights matches unweighted gradient."""
56+
df = fixtures
57+
58+
# Create uniform weights (all 1.0)
59+
n_matches = len(df)
60+
uniform_weights = np.ones(n_matches)
61+
62+
# Model with uniform weights
63+
clf_uniform = pb.models.PoissonGoalsModel(
64+
df["goals_home"],
65+
df["goals_away"],
66+
df["team_home"],
67+
df["team_away"],
68+
uniform_weights,
69+
)
70+
71+
# Model without weights
72+
clf_none = pb.models.PoissonGoalsModel(
73+
df["goals_home"], df["goals_away"], df["team_home"], df["team_away"]
74+
)
75+
76+
# Fit both models
77+
clf_uniform.fit(use_gradient=True)
78+
clf_none.fit(use_gradient=True)
79+
80+
# Parameters should be very similar
81+
params_uniform = clf_uniform.get_params()
82+
params_none = clf_none.get_params()
83+
84+
for team in clf_uniform.teams:
85+
attack_key = f"attack_{team}"
86+
defense_key = f"defense_{team}"
87+
88+
# Allow for small numerical differences
89+
assert (
90+
abs(params_uniform[attack_key] - params_none[attack_key]) < 1e-6
91+
), f"Attack parameter for {team} should be nearly identical with uniform weights vs no weights"
92+
assert (
93+
abs(params_uniform[defense_key] - params_none[defense_key]) < 1e-6
94+
), f"Defense parameter for {team} should be nearly identical with uniform weights vs no weights"
95+
96+
# Home advantage should also be nearly identical
97+
assert (
98+
abs(params_uniform["home_advantage"] - params_none["home_advantage"]) < 1e-6
99+
), "Home advantage should be nearly identical with uniform weights vs no weights"
100+
101+
102+
@pytest.mark.local
103+
def test_poisson_gradient_numerical_check_with_weights(fixtures):
104+
"""Test that analytical gradient matches numerical gradient when using weights."""
105+
from scipy.optimize import check_grad
106+
107+
df = fixtures
108+
109+
# Create non-uniform weights
110+
n_matches = len(df)
111+
weights = np.random.uniform(
112+
0.5, 2.0, n_matches
113+
) # Random weights between 0.5 and 2.0
114+
115+
clf = pb.models.PoissonGoalsModel(
116+
df["goals_home"], df["goals_away"], df["team_home"], df["team_away"], weights
117+
)
118+
119+
# Test gradient at initial parameters
120+
initial_params = clf._params.copy()
121+
122+
# Use scipy's check_grad function to compare analytical vs numerical gradients
123+
gradient_error = check_grad(
124+
clf._loss_function, # Function to differentiate
125+
clf._gradient, # Analytical gradient function
126+
initial_params, # Point at which to check
127+
epsilon=1e-7, # Step size for numerical differentiation
128+
)
129+
130+
# check_grad returns the 2-norm of the difference between gradients
131+
# For Poisson models with weights, gradients should still be accurate
132+
assert (
133+
gradient_error < 1e-4
134+
), f"Gradient error {gradient_error:.2e} is too large with weights"
135+
136+
137+
@pytest.mark.local
138+
def test_poisson_gradient_zero_weights(fixtures):
139+
"""Test that gradient handles zero weights correctly."""
140+
df = fixtures.copy()
141+
142+
# Create weights where some matches have zero weight
143+
n_matches = len(df)
144+
weights = np.ones(n_matches)
145+
weights[:10] = 0.0 # First 10 matches have zero weight
146+
147+
clf = pb.models.PoissonGoalsModel(
148+
df["goals_home"], df["goals_away"], df["team_home"], df["team_away"], weights
149+
)
150+
151+
# Should fit without issues
152+
clf.fit(use_gradient=True)
153+
assert clf.fitted
154+
155+
# Get gradient at fitted parameters
156+
fitted_params = clf._params.copy()
157+
gradient = clf._gradient(fitted_params)
158+
159+
# Gradient should be finite
160+
assert np.all(
161+
np.isfinite(gradient)
162+
), "Gradient should be finite even with zero weights"
163+
164+
165+
@pytest.mark.local
166+
def test_poisson_gradient_extreme_weights(fixtures):
167+
"""Test that gradient handles extreme weight values correctly."""
168+
df = fixtures.copy()
169+
170+
# Create extreme weights
171+
n_matches = len(df)
172+
weights = np.ones(n_matches)
173+
weights[::2] = 0.01 # Very small weights for half the matches
174+
weights[1::2] = 100.0 # Very large weights for the other half
175+
176+
clf = pb.models.PoissonGoalsModel(
177+
df["goals_home"], df["goals_away"], df["team_home"], df["team_away"], weights
178+
)
179+
180+
# Should fit without issues
181+
clf.fit(use_gradient=True)
182+
assert clf.fitted
183+
184+
# Get gradient at fitted parameters
185+
fitted_params = clf._params.copy()
186+
gradient = clf._gradient(fitted_params)
187+
188+
# Gradient should be finite
189+
assert np.all(
190+
np.isfinite(gradient)
191+
), "Gradient should be finite even with extreme weights"

0 commit comments

Comments
 (0)