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.

@@ -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", column_names=cn, auto_scale_performance=True
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) >= 2
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
- @pytest.mark.parametrize(
136
- "feature",
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", column_names=base_cn, auto_scale_performance=True
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
- assert len(res) >= 2
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
- assert len(res_transform) == len(res_future)
206
-
207
- assert set(res_transform.columns) == set(res_future.columns)
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) >= 2
407
+ assert len(result) == 4
410
408
 
411
- assert len(gen._player_off_ratings) > 0
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 or skipped without crashing."""
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
- # Check that result has data
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
- assert col in result_cols, f"Expected column '{col}' not found. Columns: {result_cols}"
1769
-
1770
-
1771
- def test_player_rating_only_requested_features_present(base_cn, sample_df):
1772
- """Test that only requested features (and input columns) are present in output."""
1773
- gen = PlayerRatingGenerator(
1774
- performance_column="perf",
1775
- column_names=base_cn,
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