spforge 0.8.20__py3-none-any.whl → 0.8.25__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of spforge might be problematic. Click here for more details.
- spforge/feature_generator/_base.py +2 -0
- spforge/ratings/_base.py +6 -0
- spforge/ratings/_player_rating.py +120 -43
- spforge/ratings/_team_rating.py +23 -20
- spforge/ratings/player_performance_predictor.py +1 -1
- {spforge-0.8.20.dist-info → spforge-0.8.25.dist-info}/METADATA +1 -1
- {spforge-0.8.20.dist-info → spforge-0.8.25.dist-info}/RECORD +13 -13
- tests/feature_generator/test_rolling_window.py +36 -0
- tests/ratings/test_player_rating_generator.py +429 -118
- tests/ratings/test_team_rating_generator.py +153 -11
- {spforge-0.8.20.dist-info → spforge-0.8.25.dist-info}/WHEEL +0 -0
- {spforge-0.8.20.dist-info → spforge-0.8.25.dist-info}/licenses/LICENSE +0 -0
- {spforge-0.8.20.dist-info → spforge-0.8.25.dist-info}/top_level.txt +0 -0
|
@@ -107,6 +107,7 @@ def test_fit_transform_participation_weight_scaling(base_cn):
|
|
|
107
107
|
|
|
108
108
|
def test_fit_transform_batch_update_logic(base_cn):
|
|
109
109
|
"""Test that ratings do not update between matches if update_match_id is the same."""
|
|
110
|
+
from dataclasses import replace
|
|
110
111
|
|
|
111
112
|
df = pl.DataFrame(
|
|
112
113
|
{
|
|
@@ -119,36 +120,25 @@ def test_fit_transform_batch_update_logic(base_cn):
|
|
|
119
120
|
"pw": [1.0, 1.0, 1.0, 1.0],
|
|
120
121
|
}
|
|
121
122
|
)
|
|
122
|
-
from dataclasses import replace
|
|
123
123
|
|
|
124
124
|
cn = replace(base_cn, update_match_id="update_id")
|
|
125
125
|
gen = PlayerRatingGenerator(
|
|
126
|
-
performance_column="perf",
|
|
126
|
+
performance_column="perf",
|
|
127
|
+
column_names=cn,
|
|
128
|
+
auto_scale_performance=True,
|
|
129
|
+
features_out=[RatingKnownFeatures.PLAYER_OFF_RATING],
|
|
127
130
|
)
|
|
128
131
|
output = gen.fit_transform(df)
|
|
129
132
|
|
|
130
|
-
assert len(output)
|
|
131
|
-
|
|
132
|
-
assert len(gen._player_off_ratings) > 0
|
|
133
|
+
assert len(output) == 4
|
|
133
134
|
|
|
135
|
+
m1_rows = output.filter(pl.col("mid") == "M1")
|
|
136
|
+
m2_rows = output.filter(pl.col("mid") == "M2")
|
|
137
|
+
assert m1_rows["player_off_rating_perf"][0] == 1000.0
|
|
138
|
+
assert m2_rows["player_off_rating_perf"][0] == 1000.0
|
|
134
139
|
|
|
135
|
-
|
|
136
|
-
"
|
|
137
|
-
[
|
|
138
|
-
RatingKnownFeatures.PLAYER_OFF_RATING,
|
|
139
|
-
RatingKnownFeatures.TEAM_OFF_RATING_PROJECTED,
|
|
140
|
-
RatingKnownFeatures.TEAM_RATING_DIFFERENCE_PROJECTED,
|
|
141
|
-
],
|
|
142
|
-
)
|
|
143
|
-
def test_fit_transform_requested_features_presence(base_cn, sample_df, feature):
|
|
144
|
-
"""Verify that specific requested features appear in the resulting DataFrame."""
|
|
145
|
-
gen = PlayerRatingGenerator(
|
|
146
|
-
performance_column="perf", column_names=base_cn, features_out=[feature]
|
|
147
|
-
)
|
|
148
|
-
res = gen.fit_transform(sample_df)
|
|
149
|
-
|
|
150
|
-
expected_col = f"{feature}_perf"
|
|
151
|
-
assert expected_col in res.columns
|
|
140
|
+
assert gen._player_off_ratings["P1"].rating_value > 1000.0
|
|
141
|
+
assert gen._player_off_ratings["P2"].rating_value < 1000.0
|
|
152
142
|
|
|
153
143
|
|
|
154
144
|
def test_future_transform_no_state_mutation(base_cn, sample_df):
|
|
@@ -170,7 +160,10 @@ def test_future_transform_no_state_mutation(base_cn, sample_df):
|
|
|
170
160
|
def test_future_transform_cold_start_player(base_cn, sample_df):
|
|
171
161
|
"""Check that future_transform handles players not seen during fit_transform."""
|
|
172
162
|
gen = PlayerRatingGenerator(
|
|
173
|
-
performance_column="perf",
|
|
163
|
+
performance_column="perf",
|
|
164
|
+
column_names=base_cn,
|
|
165
|
+
auto_scale_performance=True,
|
|
166
|
+
features_out=[RatingKnownFeatures.PLAYER_OFF_RATING],
|
|
174
167
|
)
|
|
175
168
|
gen.fit_transform(sample_df)
|
|
176
169
|
|
|
@@ -187,12 +180,16 @@ def test_future_transform_cold_start_player(base_cn, sample_df):
|
|
|
187
180
|
res = gen.future_transform(new_player_df)
|
|
188
181
|
|
|
189
182
|
assert "P99" in res["pid"].to_list()
|
|
183
|
+
assert len(res) == 4
|
|
190
184
|
|
|
191
|
-
|
|
185
|
+
p99_row = res.filter(pl.col("pid") == "P99")
|
|
186
|
+
assert p99_row["player_off_rating_perf"][0] == 1000.0
|
|
192
187
|
|
|
193
188
|
|
|
194
189
|
def test_transform_is_identical_to_future_transform(base_cn, sample_df):
|
|
195
190
|
"""Verify that the standard transform() call redirects to future_transform logic."""
|
|
191
|
+
import polars.testing as pl_testing
|
|
192
|
+
|
|
196
193
|
gen = PlayerRatingGenerator(
|
|
197
194
|
performance_column="perf", column_names=base_cn, auto_scale_performance=True
|
|
198
195
|
)
|
|
@@ -202,9 +199,10 @@ def test_transform_is_identical_to_future_transform(base_cn, sample_df):
|
|
|
202
199
|
res_transform = gen.transform(sample_df)
|
|
203
200
|
res_future = gen.future_transform(sample_df)
|
|
204
201
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
202
|
+
pl_testing.assert_frame_equal(
|
|
203
|
+
res_transform.select(sorted(res_transform.columns)).sort("pid"),
|
|
204
|
+
res_future.select(sorted(res_future.columns)).sort("pid"),
|
|
205
|
+
)
|
|
208
206
|
|
|
209
207
|
|
|
210
208
|
def test_fit_transform_offense_defense_independence(base_cn):
|
|
@@ -406,9 +404,11 @@ def test_fit_transform_when_date_formats_vary_then_processes_successfully_player
|
|
|
406
404
|
|
|
407
405
|
result = gen.fit_transform(df)
|
|
408
406
|
|
|
409
|
-
assert len(result)
|
|
407
|
+
assert len(result) == 4
|
|
410
408
|
|
|
411
|
-
assert
|
|
409
|
+
assert gen._player_off_ratings["P1"].rating_value > 1000.0
|
|
410
|
+
assert gen._player_off_ratings["P2"].rating_value < 1000.0
|
|
411
|
+
assert gen._player_off_ratings["P3"].rating_value > gen._player_off_ratings["P4"].rating_value
|
|
412
412
|
|
|
413
413
|
|
|
414
414
|
@pytest.mark.parametrize(
|
|
@@ -710,17 +710,18 @@ def test_fit_transform_confidence_decay_over_time(base_cn):
|
|
|
710
710
|
|
|
711
711
|
|
|
712
712
|
def test_fit_transform_null_performance_handling(base_cn, sample_df):
|
|
713
|
-
"""Rows with null performance should be handled
|
|
713
|
+
"""Rows with null performance should be handled without crashing and not affect ratings."""
|
|
714
714
|
df_with_null = sample_df.with_columns(
|
|
715
715
|
pl.when(pl.col("pid") == "P1").then(None).otherwise(pl.col("perf")).alias("perf")
|
|
716
716
|
)
|
|
717
717
|
gen = PlayerRatingGenerator(performance_column="perf", column_names=base_cn)
|
|
718
718
|
|
|
719
|
-
# Depending on implementation, it might skip P1 or treat as 0.
|
|
720
|
-
# The key is that the generator shouldn't crash.
|
|
721
719
|
res = gen.fit_transform(df_with_null)
|
|
722
720
|
assert len(res) == 4
|
|
723
721
|
|
|
722
|
+
assert gen._player_off_ratings["P2"].rating_value < 1000.0
|
|
723
|
+
assert gen._player_off_ratings["P3"].rating_value > 1000.0
|
|
724
|
+
|
|
724
725
|
|
|
725
726
|
def test_fit_transform_null_performance__no_rating_change(base_cn):
|
|
726
727
|
"""Players with null performance should have zero rating change, not be treated as 0.0 perf."""
|
|
@@ -1706,7 +1707,6 @@ def test_player_rating_features_out_combinations(
|
|
|
1706
1707
|
)
|
|
1707
1708
|
result = gen.fit_transform(sample_df)
|
|
1708
1709
|
|
|
1709
|
-
# Check that all expected columns are present
|
|
1710
1710
|
result_cols = (
|
|
1711
1711
|
result.columns.tolist() if hasattr(result.columns, "tolist") else list(result.columns)
|
|
1712
1712
|
)
|
|
@@ -1715,93 +1715,17 @@ def test_player_rating_features_out_combinations(
|
|
|
1715
1715
|
col in result_cols
|
|
1716
1716
|
), f"Expected column '{col}' not found in output. Columns: {result_cols}"
|
|
1717
1717
|
|
|
1718
|
-
|
|
1719
|
-
assert len(result) > 0
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
@pytest.mark.parametrize("output_suffix", [None, "v2", "custom_suffix", "test123"])
|
|
1723
|
-
def test_player_rating_suffix_applied_to_all_features(base_cn, sample_df, output_suffix):
|
|
1724
|
-
"""Test that output_suffix is correctly applied to all requested features."""
|
|
1725
|
-
features = [
|
|
1726
|
-
RatingKnownFeatures.PLAYER_OFF_RATING,
|
|
1727
|
-
RatingKnownFeatures.PLAYER_DEF_RATING,
|
|
1728
|
-
RatingKnownFeatures.TEAM_OFF_RATING_PROJECTED,
|
|
1729
|
-
]
|
|
1730
|
-
non_predictor = [
|
|
1731
|
-
RatingUnknownFeatures.PLAYER_PREDICTED_OFF_PERFORMANCE,
|
|
1732
|
-
RatingUnknownFeatures.TEAM_RATING,
|
|
1733
|
-
]
|
|
1734
|
-
|
|
1735
|
-
gen = PlayerRatingGenerator(
|
|
1736
|
-
performance_column="perf",
|
|
1737
|
-
column_names=base_cn,
|
|
1738
|
-
auto_scale_performance=True,
|
|
1739
|
-
features_out=features,
|
|
1740
|
-
non_predictor_features_out=non_predictor,
|
|
1741
|
-
output_suffix=output_suffix,
|
|
1742
|
-
)
|
|
1743
|
-
result = gen.fit_transform(sample_df)
|
|
1744
|
-
|
|
1745
|
-
# Build expected column names
|
|
1746
|
-
if output_suffix:
|
|
1747
|
-
expected_cols = [
|
|
1748
|
-
f"player_off_rating_{output_suffix}",
|
|
1749
|
-
f"player_def_rating_{output_suffix}",
|
|
1750
|
-
f"team_off_rating_projected_{output_suffix}",
|
|
1751
|
-
f"player_predicted_off_performance_{output_suffix}",
|
|
1752
|
-
f"team_rating_{output_suffix}",
|
|
1753
|
-
]
|
|
1754
|
-
else:
|
|
1755
|
-
# When output_suffix=None, it defaults to performance column name ("perf")
|
|
1756
|
-
expected_cols = [
|
|
1757
|
-
"player_off_rating_perf",
|
|
1758
|
-
"player_def_rating_perf",
|
|
1759
|
-
"team_off_rating_projected_perf",
|
|
1760
|
-
"player_predicted_off_performance_perf",
|
|
1761
|
-
"team_rating_perf",
|
|
1762
|
-
]
|
|
1718
|
+
assert len(result) == 4
|
|
1763
1719
|
|
|
1764
|
-
result_cols = (
|
|
1765
|
-
result.columns.tolist() if hasattr(result.columns, "tolist") else list(result.columns)
|
|
1766
|
-
)
|
|
1767
1720
|
for col in expected_cols:
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
auto_scale_performance=True,
|
|
1777
|
-
features_out=[RatingKnownFeatures.PLAYER_OFF_RATING],
|
|
1778
|
-
non_predictor_features_out=None,
|
|
1779
|
-
output_suffix=None,
|
|
1780
|
-
)
|
|
1781
|
-
result = gen.fit_transform(sample_df)
|
|
1782
|
-
|
|
1783
|
-
# Should have input columns + requested feature
|
|
1784
|
-
input_cols = set(sample_df.columns)
|
|
1785
|
-
result_cols = set(result.columns)
|
|
1786
|
-
|
|
1787
|
-
# Check that input columns are preserved
|
|
1788
|
-
for col in input_cols:
|
|
1789
|
-
assert col in result_cols, f"Input column '{col}' missing from output"
|
|
1790
|
-
|
|
1791
|
-
# Check that requested feature is present (with performance column suffix)
|
|
1792
|
-
assert "player_off_rating_perf" in result_cols
|
|
1793
|
-
|
|
1794
|
-
# Check that other rating features are NOT present (unless they're input columns)
|
|
1795
|
-
unwanted_features = [
|
|
1796
|
-
"player_def_rating",
|
|
1797
|
-
"team_off_rating_projected",
|
|
1798
|
-
"player_predicted_off_performance",
|
|
1799
|
-
]
|
|
1800
|
-
for feature in unwanted_features:
|
|
1801
|
-
if feature not in input_cols:
|
|
1802
|
-
assert (
|
|
1803
|
-
feature not in result_cols
|
|
1804
|
-
), f"Unrequested feature '{feature}' should not be in output"
|
|
1721
|
+
if "rating" in col and "difference" not in col:
|
|
1722
|
+
values = result[col].to_list()
|
|
1723
|
+
for v in values:
|
|
1724
|
+
assert 500 < v < 1500, f"Rating {col}={v} outside reasonable range"
|
|
1725
|
+
elif "predicted" in col:
|
|
1726
|
+
values = result[col].to_list()
|
|
1727
|
+
for v in values:
|
|
1728
|
+
assert 0.0 <= v <= 1.0, f"Prediction {col}={v} outside [0,1]"
|
|
1805
1729
|
|
|
1806
1730
|
|
|
1807
1731
|
def test_player_rating_team_with_strong_offense_and_weak_defense_gets_expected_ratings_and_predictions(
|
|
@@ -2008,3 +1932,390 @@ def test_fit_transform__start_league_quantile_uses_existing_player_ratings(base_
|
|
|
2008
1932
|
f"but got {new_player_start_rating:.1f}. "
|
|
2009
1933
|
"start_league_quantile has no effect because update_players_to_leagues is never called."
|
|
2010
1934
|
)
|
|
1935
|
+
|
|
1936
|
+
|
|
1937
|
+
def test_fit_transform__precise_rating_calculation(base_cn, sample_df):
|
|
1938
|
+
"""Verify precise rating calculations match expected formulas."""
|
|
1939
|
+
gen = PlayerRatingGenerator(
|
|
1940
|
+
performance_column="perf",
|
|
1941
|
+
column_names=base_cn,
|
|
1942
|
+
auto_scale_performance=False,
|
|
1943
|
+
features_out=[RatingKnownFeatures.PLAYER_OFF_RATING],
|
|
1944
|
+
)
|
|
1945
|
+
gen.fit_transform(sample_df)
|
|
1946
|
+
|
|
1947
|
+
expected_mult = 50 * (17 / 14) * 0.9 + 5
|
|
1948
|
+
|
|
1949
|
+
assert gen._player_off_ratings["P1"].rating_value == pytest.approx(
|
|
1950
|
+
1000 + 0.1 * expected_mult, rel=1e-6
|
|
1951
|
+
)
|
|
1952
|
+
|
|
1953
|
+
assert gen._player_off_ratings["P2"].rating_value == pytest.approx(
|
|
1954
|
+
1000 - 0.1 * expected_mult, rel=1e-6
|
|
1955
|
+
)
|
|
1956
|
+
|
|
1957
|
+
assert gen._player_off_ratings["P3"].rating_value == pytest.approx(
|
|
1958
|
+
1000 + 0.2 * expected_mult, rel=1e-6
|
|
1959
|
+
)
|
|
1960
|
+
|
|
1961
|
+
assert gen._player_off_ratings["P4"].rating_value == pytest.approx(
|
|
1962
|
+
1000 - 0.2 * expected_mult, rel=1e-6
|
|
1963
|
+
)
|
|
1964
|
+
|
|
1965
|
+
assert gen._player_def_ratings["P1"].rating_value == pytest.approx(1000.0, rel=1e-6)
|
|
1966
|
+
|
|
1967
|
+
|
|
1968
|
+
def test_fit_transform_when_all_players_have_null_performance_then_no_rating_change(base_cn):
|
|
1969
|
+
"""
|
|
1970
|
+
When ALL players on a team have null performance, opponent defense ratings should not change.
|
|
1971
|
+
|
|
1972
|
+
Scenario:
|
|
1973
|
+
- Match 1: Normal match with performance values (P1=0.6 on T1, P2=0.4 on T2)
|
|
1974
|
+
- Match 2: T1 has ALL null performance, T2 has normal performance (0.6)
|
|
1975
|
+
|
|
1976
|
+
Expected:
|
|
1977
|
+
- T2 players' defensive rating should NOT change after M2 because T1's offensive
|
|
1978
|
+
performance is unknown (all null) - we can't evaluate how well T2 defended
|
|
1979
|
+
- T1 players' offensive rating should NOT change after M2 (null perf = no update)
|
|
1980
|
+
"""
|
|
1981
|
+
df = pl.DataFrame(
|
|
1982
|
+
{
|
|
1983
|
+
"pid": ["P1", "P2", "P3", "P4", "P1", "P2", "P3", "P4"],
|
|
1984
|
+
"tid": ["T1", "T1", "T2", "T2", "T1", "T1", "T2", "T2"],
|
|
1985
|
+
"mid": ["M1", "M1", "M1", "M1", "M2", "M2", "M2", "M2"],
|
|
1986
|
+
"dt": [
|
|
1987
|
+
"2024-01-01",
|
|
1988
|
+
"2024-01-01",
|
|
1989
|
+
"2024-01-01",
|
|
1990
|
+
"2024-01-01",
|
|
1991
|
+
"2024-01-02",
|
|
1992
|
+
"2024-01-02",
|
|
1993
|
+
"2024-01-02",
|
|
1994
|
+
"2024-01-02",
|
|
1995
|
+
],
|
|
1996
|
+
"perf": [0.6, 0.4, 0.6, 0.4, None, None, 0.6, 0.4],
|
|
1997
|
+
"pw": [1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0],
|
|
1998
|
+
}
|
|
1999
|
+
)
|
|
2000
|
+
|
|
2001
|
+
gen = PlayerRatingGenerator(
|
|
2002
|
+
performance_column="perf",
|
|
2003
|
+
column_names=base_cn,
|
|
2004
|
+
features_out=[
|
|
2005
|
+
RatingKnownFeatures.PLAYER_OFF_RATING,
|
|
2006
|
+
RatingKnownFeatures.PLAYER_DEF_RATING,
|
|
2007
|
+
],
|
|
2008
|
+
)
|
|
2009
|
+
result = gen.fit_transform(df)
|
|
2010
|
+
|
|
2011
|
+
p3_def_before_m2 = result.filter((pl.col("pid") == "P3") & (pl.col("mid") == "M2"))[
|
|
2012
|
+
"player_def_rating_perf"
|
|
2013
|
+
][0]
|
|
2014
|
+
p4_def_before_m2 = result.filter((pl.col("pid") == "P4") & (pl.col("mid") == "M2"))[
|
|
2015
|
+
"player_def_rating_perf"
|
|
2016
|
+
][0]
|
|
2017
|
+
|
|
2018
|
+
p3_def_after_m2 = gen._player_def_ratings["P3"].rating_value
|
|
2019
|
+
p4_def_after_m2 = gen._player_def_ratings["P4"].rating_value
|
|
2020
|
+
|
|
2021
|
+
assert p3_def_before_m2 == p3_def_after_m2, (
|
|
2022
|
+
f"P3's def rating changed after M2 with all-null T1 performance! "
|
|
2023
|
+
f"Before={p3_def_before_m2}, After={p3_def_after_m2}. "
|
|
2024
|
+
"T2 defense should not be evaluated when T1 offense is unknown."
|
|
2025
|
+
)
|
|
2026
|
+
assert p4_def_before_m2 == p4_def_after_m2, (
|
|
2027
|
+
f"P4's def rating changed after M2 with all-null T1 performance! "
|
|
2028
|
+
f"Before={p4_def_before_m2}, After={p4_def_after_m2}. "
|
|
2029
|
+
"T2 defense should not be evaluated when T1 offense is unknown."
|
|
2030
|
+
)
|
|
2031
|
+
|
|
2032
|
+
p1_off_before_m2 = result.filter((pl.col("pid") == "P1") & (pl.col("mid") == "M2"))[
|
|
2033
|
+
"player_off_rating_perf"
|
|
2034
|
+
][0]
|
|
2035
|
+
p1_off_after_m2 = gen._player_off_ratings["P1"].rating_value
|
|
2036
|
+
|
|
2037
|
+
assert p1_off_before_m2 == p1_off_after_m2, (
|
|
2038
|
+
f"P1's off rating changed after M2 with null performance! "
|
|
2039
|
+
f"Before={p1_off_before_m2}, After={p1_off_after_m2}. "
|
|
2040
|
+
"Null performance should result in no rating change."
|
|
2041
|
+
)
|
|
2042
|
+
|
|
2043
|
+
|
|
2044
|
+
# --- team_players_playing_time Tests ---
|
|
2045
|
+
|
|
2046
|
+
|
|
2047
|
+
def test_fit_transform_team_players_playing_time_column_not_found_raises_error(base_cn):
|
|
2048
|
+
"""Specifying a nonexistent team_players_playing_time column should raise ValueError."""
|
|
2049
|
+
from dataclasses import replace
|
|
2050
|
+
|
|
2051
|
+
cn = replace(base_cn, team_players_playing_time="nonexistent_column")
|
|
2052
|
+
|
|
2053
|
+
df = pl.DataFrame(
|
|
2054
|
+
{
|
|
2055
|
+
"pid": ["P1", "P2"],
|
|
2056
|
+
"tid": ["T1", "T2"],
|
|
2057
|
+
"mid": ["M1", "M1"],
|
|
2058
|
+
"dt": ["2024-01-01", "2024-01-01"],
|
|
2059
|
+
"perf": [0.6, 0.4],
|
|
2060
|
+
"pw": [1.0, 1.0],
|
|
2061
|
+
}
|
|
2062
|
+
)
|
|
2063
|
+
|
|
2064
|
+
gen = PlayerRatingGenerator(
|
|
2065
|
+
performance_column="perf",
|
|
2066
|
+
column_names=cn,
|
|
2067
|
+
)
|
|
2068
|
+
|
|
2069
|
+
with pytest.raises(ValueError, match="team_players_playing_time column"):
|
|
2070
|
+
gen.fit_transform(df)
|
|
2071
|
+
|
|
2072
|
+
|
|
2073
|
+
def test_fit_transform_opponent_players_playing_time_column_not_found_raises_error(base_cn):
|
|
2074
|
+
"""Specifying a nonexistent opponent_players_playing_time column should raise ValueError."""
|
|
2075
|
+
from dataclasses import replace
|
|
2076
|
+
|
|
2077
|
+
cn = replace(base_cn, opponent_players_playing_time="nonexistent_column")
|
|
2078
|
+
|
|
2079
|
+
df = pl.DataFrame(
|
|
2080
|
+
{
|
|
2081
|
+
"pid": ["P1", "P2"],
|
|
2082
|
+
"tid": ["T1", "T2"],
|
|
2083
|
+
"mid": ["M1", "M1"],
|
|
2084
|
+
"dt": ["2024-01-01", "2024-01-01"],
|
|
2085
|
+
"perf": [0.6, 0.4],
|
|
2086
|
+
"pw": [1.0, 1.0],
|
|
2087
|
+
}
|
|
2088
|
+
)
|
|
2089
|
+
|
|
2090
|
+
gen = PlayerRatingGenerator(
|
|
2091
|
+
performance_column="perf",
|
|
2092
|
+
column_names=cn,
|
|
2093
|
+
)
|
|
2094
|
+
|
|
2095
|
+
with pytest.raises(ValueError, match="opponent_players_playing_time column"):
|
|
2096
|
+
gen.fit_transform(df)
|
|
2097
|
+
|
|
2098
|
+
|
|
2099
|
+
def test_fit_transform_null_playing_time_uses_standard_team_rating(base_cn):
|
|
2100
|
+
"""When team_players_playing_time is null for a row, should use standard team rating."""
|
|
2101
|
+
from dataclasses import replace
|
|
2102
|
+
|
|
2103
|
+
cn = replace(
|
|
2104
|
+
base_cn,
|
|
2105
|
+
team_players_playing_time="team_pt",
|
|
2106
|
+
opponent_players_playing_time="opp_pt",
|
|
2107
|
+
)
|
|
2108
|
+
|
|
2109
|
+
# First establish ratings with a normal match (no playing time data)
|
|
2110
|
+
df1 = pl.DataFrame(
|
|
2111
|
+
{
|
|
2112
|
+
"pid": ["P1", "P2", "P3", "P4"],
|
|
2113
|
+
"tid": ["T1", "T1", "T2", "T2"],
|
|
2114
|
+
"mid": ["M1", "M1", "M1", "M1"],
|
|
2115
|
+
"dt": ["2024-01-01"] * 4,
|
|
2116
|
+
"perf": [0.8, 0.6, 0.4, 0.2],
|
|
2117
|
+
"pw": [1.0, 1.0, 1.0, 1.0],
|
|
2118
|
+
"team_pt": [None, None, None, None],
|
|
2119
|
+
"opp_pt": [None, None, None, None],
|
|
2120
|
+
}
|
|
2121
|
+
)
|
|
2122
|
+
|
|
2123
|
+
gen = PlayerRatingGenerator(
|
|
2124
|
+
performance_column="perf",
|
|
2125
|
+
column_names=cn,
|
|
2126
|
+
auto_scale_performance=True,
|
|
2127
|
+
features_out=[RatingKnownFeatures.PLAYER_OFF_RATING],
|
|
2128
|
+
non_predictor_features_out=[RatingUnknownFeatures.PLAYER_PREDICTED_OFF_PERFORMANCE],
|
|
2129
|
+
)
|
|
2130
|
+
|
|
2131
|
+
result = gen.fit_transform(df1)
|
|
2132
|
+
|
|
2133
|
+
# Should work without error and produce predictions
|
|
2134
|
+
assert len(result) == 4
|
|
2135
|
+
assert "player_predicted_off_performance_perf" in result.columns
|
|
2136
|
+
|
|
2137
|
+
# All predictions should be valid (between 0 and 1)
|
|
2138
|
+
predictions = result["player_predicted_off_performance_perf"].to_list()
|
|
2139
|
+
for pred in predictions:
|
|
2140
|
+
assert 0.0 <= pred <= 1.0
|
|
2141
|
+
|
|
2142
|
+
|
|
2143
|
+
def test_fit_transform_weighted_calculation_with_playing_time(base_cn):
|
|
2144
|
+
"""Test that playing time weighted calculation produces different predictions."""
|
|
2145
|
+
from dataclasses import replace
|
|
2146
|
+
|
|
2147
|
+
cn = replace(
|
|
2148
|
+
base_cn,
|
|
2149
|
+
team_players_playing_time="team_pt",
|
|
2150
|
+
opponent_players_playing_time="opp_pt",
|
|
2151
|
+
)
|
|
2152
|
+
|
|
2153
|
+
# First establish different ratings for players
|
|
2154
|
+
df1 = pl.DataFrame(
|
|
2155
|
+
{
|
|
2156
|
+
"pid": ["P1", "P2", "P3", "P4"],
|
|
2157
|
+
"tid": ["T1", "T1", "T2", "T2"],
|
|
2158
|
+
"mid": ["M1", "M1", "M1", "M1"],
|
|
2159
|
+
"dt": ["2024-01-01"] * 4,
|
|
2160
|
+
"perf": [0.9, 0.1, 0.5, 0.5], # P1 high rating, P2 low rating
|
|
2161
|
+
"pw": [1.0, 1.0, 1.0, 1.0],
|
|
2162
|
+
"team_pt": [None, None, None, None],
|
|
2163
|
+
"opp_pt": [None, None, None, None],
|
|
2164
|
+
}
|
|
2165
|
+
)
|
|
2166
|
+
|
|
2167
|
+
gen = PlayerRatingGenerator(
|
|
2168
|
+
performance_column="perf",
|
|
2169
|
+
column_names=cn,
|
|
2170
|
+
auto_scale_performance=True,
|
|
2171
|
+
start_harcoded_start_rating=1000.0,
|
|
2172
|
+
non_predictor_features_out=[RatingUnknownFeatures.PLAYER_PREDICTED_OFF_PERFORMANCE],
|
|
2173
|
+
)
|
|
2174
|
+
gen.fit_transform(df1)
|
|
2175
|
+
|
|
2176
|
+
# Verify P1 and P2 have different ratings now
|
|
2177
|
+
p1_rating = gen._player_off_ratings["P1"].rating_value
|
|
2178
|
+
p2_rating = gen._player_off_ratings["P2"].rating_value
|
|
2179
|
+
assert p1_rating > p2_rating, "Setup: P1 should have higher rating than P2"
|
|
2180
|
+
|
|
2181
|
+
# Second match with playing time data
|
|
2182
|
+
# P3 faces opponent P1 80% of time (high rating), P4 faces P2 80% of time (low rating)
|
|
2183
|
+
# Use consistent schema for all dict entries (all keys present in all rows)
|
|
2184
|
+
df2 = pl.DataFrame(
|
|
2185
|
+
{
|
|
2186
|
+
"pid": ["P1", "P2", "P3", "P4"],
|
|
2187
|
+
"tid": ["T1", "T1", "T2", "T2"],
|
|
2188
|
+
"mid": ["M2", "M2", "M2", "M2"],
|
|
2189
|
+
"dt": ["2024-01-02"] * 4,
|
|
2190
|
+
"pw": [1.0, 1.0, 1.0, 1.0],
|
|
2191
|
+
# Team playing time - who they play WITH on same team
|
|
2192
|
+
"team_pt": [
|
|
2193
|
+
{"P1": 0.0, "P2": 1.0, "P3": 0.5, "P4": 0.5}, # P1 on T1, plays with P2
|
|
2194
|
+
{"P1": 1.0, "P2": 0.0, "P3": 0.5, "P4": 0.5}, # P2 on T1, plays with P1
|
|
2195
|
+
{"P1": 0.5, "P2": 0.5, "P3": 0.0, "P4": 1.0}, # P3 on T2, plays with P4
|
|
2196
|
+
{"P1": 0.5, "P2": 0.5, "P3": 1.0, "P4": 0.0}, # P4 on T2, plays with P3
|
|
2197
|
+
],
|
|
2198
|
+
# Opponent playing time - who they face on opposing team
|
|
2199
|
+
"opp_pt": [
|
|
2200
|
+
{"P1": 0.0, "P2": 0.0, "P3": 0.5, "P4": 0.5}, # P1 faces T2 opponents evenly
|
|
2201
|
+
{"P1": 0.0, "P2": 0.0, "P3": 0.5, "P4": 0.5}, # P2 faces T2 opponents evenly
|
|
2202
|
+
{"P1": 0.8, "P2": 0.2, "P3": 0.0, "P4": 0.0}, # P3 faces P1 80% of time
|
|
2203
|
+
{"P1": 0.2, "P2": 0.8, "P3": 0.0, "P4": 0.0}, # P4 faces P2 80% of time
|
|
2204
|
+
],
|
|
2205
|
+
}
|
|
2206
|
+
)
|
|
2207
|
+
|
|
2208
|
+
result = gen.future_transform(df2)
|
|
2209
|
+
|
|
2210
|
+
# Verify we get predictions
|
|
2211
|
+
assert len(result) == 4
|
|
2212
|
+
|
|
2213
|
+
# Get predictions for P3 and P4
|
|
2214
|
+
# P3 faces stronger opponents (mainly P1), P4 faces weaker opponents (mainly P2)
|
|
2215
|
+
# So P3 should have lower predicted performance than P4 (all else equal)
|
|
2216
|
+
p3_pred = result.filter(pl.col("pid") == "P3")["player_predicted_off_performance_perf"][0]
|
|
2217
|
+
p4_pred = result.filter(pl.col("pid") == "P4")["player_predicted_off_performance_perf"][0]
|
|
2218
|
+
|
|
2219
|
+
# P3 faces P1 (high rating) 80% of time, P4 faces P2 (low rating) 80% of time
|
|
2220
|
+
# So P4 should have higher predicted performance
|
|
2221
|
+
assert p4_pred > p3_pred, (
|
|
2222
|
+
f"P4 (facing weak opponents) should have higher prediction than P3 (facing strong opponents). "
|
|
2223
|
+
f"P3 pred={p3_pred:.4f}, P4 pred={p4_pred:.4f}"
|
|
2224
|
+
)
|
|
2225
|
+
|
|
2226
|
+
|
|
2227
|
+
def test_future_transform_weighted_calculation_with_playing_time(base_cn):
|
|
2228
|
+
"""Test that future_transform correctly uses playing time weights."""
|
|
2229
|
+
from dataclasses import replace
|
|
2230
|
+
|
|
2231
|
+
cn = replace(
|
|
2232
|
+
base_cn,
|
|
2233
|
+
team_players_playing_time="team_pt",
|
|
2234
|
+
opponent_players_playing_time="opp_pt",
|
|
2235
|
+
)
|
|
2236
|
+
|
|
2237
|
+
# First establish ratings
|
|
2238
|
+
df1 = pl.DataFrame(
|
|
2239
|
+
{
|
|
2240
|
+
"pid": ["P1", "P2", "P3", "P4"],
|
|
2241
|
+
"tid": ["T1", "T1", "T2", "T2"],
|
|
2242
|
+
"mid": ["M1", "M1", "M1", "M1"],
|
|
2243
|
+
"dt": ["2024-01-01"] * 4,
|
|
2244
|
+
"perf": [0.9, 0.1, 0.5, 0.5],
|
|
2245
|
+
"pw": [1.0, 1.0, 1.0, 1.0],
|
|
2246
|
+
"team_pt": [None, None, None, None],
|
|
2247
|
+
"opp_pt": [None, None, None, None],
|
|
2248
|
+
}
|
|
2249
|
+
)
|
|
2250
|
+
|
|
2251
|
+
gen = PlayerRatingGenerator(
|
|
2252
|
+
performance_column="perf",
|
|
2253
|
+
column_names=cn,
|
|
2254
|
+
auto_scale_performance=True,
|
|
2255
|
+
start_harcoded_start_rating=1000.0,
|
|
2256
|
+
non_predictor_features_out=[RatingUnknownFeatures.PLAYER_PREDICTED_OFF_PERFORMANCE],
|
|
2257
|
+
)
|
|
2258
|
+
gen.fit_transform(df1)
|
|
2259
|
+
|
|
2260
|
+
# Future match with playing time weights (consistent schema)
|
|
2261
|
+
future_df = pl.DataFrame(
|
|
2262
|
+
{
|
|
2263
|
+
"pid": ["P1", "P2", "P3", "P4"],
|
|
2264
|
+
"tid": ["T1", "T1", "T2", "T2"],
|
|
2265
|
+
"mid": ["M2", "M2", "M2", "M2"],
|
|
2266
|
+
"dt": ["2024-01-02"] * 4,
|
|
2267
|
+
"pw": [1.0, 1.0, 1.0, 1.0],
|
|
2268
|
+
"team_pt": [
|
|
2269
|
+
{"P1": 0.0, "P2": 1.0, "P3": 0.5, "P4": 0.5}, # P1 plays with P2
|
|
2270
|
+
{"P1": 1.0, "P2": 0.0, "P3": 0.5, "P4": 0.5}, # P2 plays with P1
|
|
2271
|
+
{"P1": 0.5, "P2": 0.5, "P3": 0.0, "P4": 1.0}, # P3 plays with P4
|
|
2272
|
+
{"P1": 0.5, "P2": 0.5, "P3": 1.0, "P4": 0.0}, # P4 plays with P3
|
|
2273
|
+
],
|
|
2274
|
+
"opp_pt": [
|
|
2275
|
+
{"P1": 0.0, "P2": 0.0, "P3": 1.0, "P4": 0.0}, # P1 faces only P3
|
|
2276
|
+
{"P1": 0.0, "P2": 0.0, "P3": 0.0, "P4": 1.0}, # P2 faces only P4
|
|
2277
|
+
{"P1": 1.0, "P2": 0.0, "P3": 0.0, "P4": 0.0}, # P3 faces only P1
|
|
2278
|
+
{"P1": 0.0, "P2": 1.0, "P3": 0.0, "P4": 0.0}, # P4 faces only P2
|
|
2279
|
+
],
|
|
2280
|
+
}
|
|
2281
|
+
)
|
|
2282
|
+
|
|
2283
|
+
result = gen.future_transform(future_df)
|
|
2284
|
+
|
|
2285
|
+
# Verify predictions are valid
|
|
2286
|
+
assert len(result) == 4
|
|
2287
|
+
predictions = result["player_predicted_off_performance_perf"].to_list()
|
|
2288
|
+
for pred in predictions:
|
|
2289
|
+
assert 0.0 <= pred <= 1.0
|
|
2290
|
+
|
|
2291
|
+
|
|
2292
|
+
def test_fit_transform_backward_compatible_without_playing_time_columns(base_cn):
|
|
2293
|
+
"""Behavior should be unchanged when team_players_playing_time columns are not specified."""
|
|
2294
|
+
df = pl.DataFrame(
|
|
2295
|
+
{
|
|
2296
|
+
"pid": ["P1", "P2", "P3", "P4"],
|
|
2297
|
+
"tid": ["T1", "T1", "T2", "T2"],
|
|
2298
|
+
"mid": ["M1", "M1", "M1", "M1"],
|
|
2299
|
+
"dt": ["2024-01-01"] * 4,
|
|
2300
|
+
"perf": [0.6, 0.4, 0.7, 0.3],
|
|
2301
|
+
"pw": [1.0, 1.0, 1.0, 1.0],
|
|
2302
|
+
}
|
|
2303
|
+
)
|
|
2304
|
+
|
|
2305
|
+
# Without specifying playing time columns (backward compatible)
|
|
2306
|
+
gen = PlayerRatingGenerator(
|
|
2307
|
+
performance_column="perf",
|
|
2308
|
+
column_names=base_cn, # No playing time columns specified
|
|
2309
|
+
auto_scale_performance=True,
|
|
2310
|
+
features_out=[RatingKnownFeatures.PLAYER_OFF_RATING],
|
|
2311
|
+
)
|
|
2312
|
+
|
|
2313
|
+
result = gen.fit_transform(df)
|
|
2314
|
+
|
|
2315
|
+
# Should work normally
|
|
2316
|
+
assert len(result) == 4
|
|
2317
|
+
assert "player_off_rating_perf" in result.columns
|
|
2318
|
+
|
|
2319
|
+
# Ratings should be updated normally
|
|
2320
|
+
assert gen._player_off_ratings["P1"].rating_value != 1000.0
|
|
2321
|
+
assert gen._player_off_ratings["P3"].rating_value > gen._player_off_ratings["P4"].rating_value
|