spforge 0.8.4__py3-none-any.whl → 0.8.18__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.

Files changed (37) hide show
  1. examples/lol/pipeline_transformer_example.py +69 -86
  2. examples/nba/cross_validation_example.py +4 -11
  3. examples/nba/feature_engineering_example.py +33 -15
  4. examples/nba/game_winner_example.py +24 -14
  5. examples/nba/predictor_transformers_example.py +29 -16
  6. spforge/__init__.py +1 -0
  7. spforge/autopipeline.py +169 -5
  8. spforge/estimator/_group_by_estimator.py +11 -3
  9. spforge/features_generator_pipeline.py +8 -4
  10. spforge/hyperparameter_tuning/__init__.py +12 -0
  11. spforge/hyperparameter_tuning/_default_search_spaces.py +159 -1
  12. spforge/hyperparameter_tuning/_tuner.py +192 -0
  13. spforge/performance_transformers/_performance_manager.py +2 -4
  14. spforge/ratings/__init__.py +4 -0
  15. spforge/ratings/_player_rating.py +142 -28
  16. spforge/ratings/league_start_rating_optimizer.py +201 -0
  17. spforge/ratings/start_rating_generator.py +1 -1
  18. spforge/ratings/team_start_rating_generator.py +1 -1
  19. spforge/ratings/utils.py +16 -6
  20. spforge/scorer/_score.py +42 -11
  21. spforge/transformers/_other_transformer.py +38 -8
  22. {spforge-0.8.4.dist-info → spforge-0.8.18.dist-info}/METADATA +12 -19
  23. {spforge-0.8.4.dist-info → spforge-0.8.18.dist-info}/RECORD +37 -31
  24. {spforge-0.8.4.dist-info → spforge-0.8.18.dist-info}/WHEEL +1 -1
  25. tests/end_to_end/test_estimator_hyperparameter_tuning.py +85 -0
  26. tests/end_to_end/test_league_start_rating_optimizer.py +117 -0
  27. tests/end_to_end/test_nba_player_ratings_hyperparameter_tuning.py +5 -0
  28. tests/hyperparameter_tuning/test_estimator_tuner.py +167 -0
  29. tests/performance_transformers/test_performance_manager.py +15 -0
  30. tests/ratings/test_player_rating_generator.py +154 -0
  31. tests/ratings/test_player_rating_no_mutation.py +214 -0
  32. tests/ratings/test_utils_scaled_weights.py +136 -0
  33. tests/scorer/test_score.py +232 -0
  34. tests/test_autopipeline.py +336 -6
  35. tests/test_feature_generator_pipeline.py +43 -0
  36. {spforge-0.8.4.dist-info → spforge-0.8.18.dist-info}/licenses/LICENSE +0 -0
  37. {spforge-0.8.4.dist-info → spforge-0.8.18.dist-info}/top_level.txt +0 -0
@@ -551,6 +551,63 @@ def test_fit_transform_scales_participation_weight_by_fit_quantile(base_cn):
551
551
  assert p1_change / p2_change == pytest.approx(expected_ratio, rel=1e-6)
552
552
 
553
553
 
554
+ def test_fit_transform_auto_scales_participation_weight_when_out_of_bounds(base_cn):
555
+ """Automatically enable scaling when participation weights exceed [0, 1]."""
556
+ df = pl.DataFrame(
557
+ {
558
+ "pid": ["P1", "P2", "O1", "O2"],
559
+ "tid": ["T1", "T1", "T2", "T2"],
560
+ "mid": ["M1", "M1", "M1", "M1"],
561
+ "dt": ["2024-01-01"] * 4,
562
+ "perf": [0.9, 0.9, 0.1, 0.1],
563
+ "pw": [10.0, 20.0, 10.0, 10.0],
564
+ }
565
+ )
566
+ gen = PlayerRatingGenerator(
567
+ performance_column="perf",
568
+ column_names=base_cn,
569
+ auto_scale_performance=True,
570
+ start_harcoded_start_rating=1000.0,
571
+ )
572
+ gen.fit_transform(df)
573
+
574
+ start_rating = 1000.0
575
+ p1_change = gen._player_off_ratings["P1"].rating_value - start_rating
576
+ p2_change = gen._player_off_ratings["P2"].rating_value - start_rating
577
+
578
+ q = df["pw"].quantile(0.99, "linear")
579
+ expected_ratio = min(1.0, 10.0 / q) / min(1.0, 20.0 / q)
580
+
581
+ assert gen.scale_participation_weights is True
582
+ assert p1_change / p2_change == pytest.approx(expected_ratio, rel=1e-6)
583
+
584
+
585
+ def test_fit_transform_auto_scale_logs_warning_when_out_of_bounds(base_cn, caplog):
586
+ """Auto-scaling should emit a warning when participation weights exceed [0, 1]."""
587
+ df = pl.DataFrame(
588
+ {
589
+ "pid": ["P1", "P2", "O1", "O2"],
590
+ "tid": ["T1", "T1", "T2", "T2"],
591
+ "mid": ["M1", "M1", "M1", "M1"],
592
+ "dt": ["2024-01-01"] * 4,
593
+ "perf": [0.9, 0.9, 0.1, 0.1],
594
+ "pw": [10.0, 20.0, 10.0, 10.0],
595
+ }
596
+ )
597
+ gen = PlayerRatingGenerator(
598
+ performance_column="perf",
599
+ column_names=base_cn,
600
+ auto_scale_performance=True,
601
+ start_harcoded_start_rating=1000.0,
602
+ )
603
+ with caplog.at_level("WARNING"):
604
+ gen.fit_transform(df)
605
+
606
+ assert any(
607
+ "Auto-scaling participation weights" in record.message for record in caplog.records
608
+ )
609
+
610
+
554
611
  def test_future_transform_scales_projected_participation_weight_by_fit_quantile():
555
612
  """Future projected participation weights should scale with fit quantile and be clipped."""
556
613
  cn = ColumnNames(
@@ -1662,3 +1719,100 @@ def test_player_rating_team_with_strong_offense_and_weak_defense_gets_expected_r
1662
1719
 
1663
1720
  assert a_off > start_rating
1664
1721
  assert a_def < start_rating
1722
+
1723
+
1724
+ def test_fit_transform__player_rating_difference_from_team_projected_feature(base_cn, sample_df):
1725
+ """PLAYER_RATING_DIFFERENCE_FROM_TEAM_PROJECTED computes player_off_rating - team_off_rating_projected."""
1726
+ gen = PlayerRatingGenerator(
1727
+ performance_column="perf",
1728
+ column_names=base_cn,
1729
+ auto_scale_performance=True,
1730
+ features_out=[
1731
+ RatingKnownFeatures.PLAYER_RATING_DIFFERENCE_FROM_TEAM_PROJECTED,
1732
+ RatingKnownFeatures.PLAYER_OFF_RATING,
1733
+ RatingKnownFeatures.TEAM_OFF_RATING_PROJECTED,
1734
+ ],
1735
+ )
1736
+ result = gen.fit_transform(sample_df)
1737
+
1738
+ diff_col = "player_rating_difference_from_team_projected_perf"
1739
+ player_col = "player_off_rating_perf"
1740
+ team_col = "team_off_rating_projected_perf"
1741
+
1742
+ assert diff_col in result.columns
1743
+ assert player_col in result.columns
1744
+ assert team_col in result.columns
1745
+
1746
+ for row in result.iter_rows(named=True):
1747
+ expected = row[player_col] - row[team_col]
1748
+ assert row[diff_col] == pytest.approx(expected, rel=1e-9)
1749
+
1750
+
1751
+ def test_fit_transform__start_league_quantile_uses_existing_player_ratings(base_cn):
1752
+ """
1753
+ Bug reproduction: start_league_quantile should use percentile of existing player
1754
+ ratings for new players, but update_players_to_leagues is never called so
1755
+ _league_player_ratings stays empty and all new players get default rating.
1756
+
1757
+ Expected: New player P_NEW should start at 5th percentile of existing ratings (~920)
1758
+ Actual: New player starts at default 1000 because _league_player_ratings is empty
1759
+ """
1760
+ import numpy as np
1761
+
1762
+ num_existing_players = 60
1763
+ player_ids = [f"P{i}" for i in range(num_existing_players)]
1764
+ team_ids = [f"T{i % 2 + 1}" for i in range(num_existing_players)]
1765
+
1766
+ df1 = pl.DataFrame(
1767
+ {
1768
+ "pid": player_ids,
1769
+ "tid": team_ids,
1770
+ "mid": ["M1"] * num_existing_players,
1771
+ "dt": ["2024-01-01"] * num_existing_players,
1772
+ "perf": [0.3 + (i % 10) * 0.07 for i in range(num_existing_players)],
1773
+ "pw": [1.0] * num_existing_players,
1774
+ }
1775
+ )
1776
+
1777
+ gen = PlayerRatingGenerator(
1778
+ performance_column="perf",
1779
+ column_names=base_cn,
1780
+ auto_scale_performance=True,
1781
+ start_league_quantile=0.05,
1782
+ start_min_count_for_percentiles=50,
1783
+ features_out=[RatingKnownFeatures.PLAYER_OFF_RATING],
1784
+ )
1785
+ gen.fit_transform(df1)
1786
+
1787
+ existing_ratings = [
1788
+ gen._player_off_ratings[pid].rating_value for pid in player_ids
1789
+ ]
1790
+ expected_quantile_rating = np.percentile(existing_ratings, 5)
1791
+
1792
+ srg = gen.start_rating_generator
1793
+ assert len(srg._league_player_ratings.get(None, [])) >= 50, (
1794
+ f"Expected _league_player_ratings to have >=50 entries but got "
1795
+ f"{len(srg._league_player_ratings.get(None, []))}. "
1796
+ "update_players_to_leagues is never called."
1797
+ )
1798
+
1799
+ df2 = pl.DataFrame(
1800
+ {
1801
+ "pid": ["P_NEW", "P0"],
1802
+ "tid": ["T1", "T2"],
1803
+ "mid": ["M2", "M2"],
1804
+ "dt": ["2024-01-02", "2024-01-02"],
1805
+ "pw": [1.0, 1.0],
1806
+ }
1807
+ )
1808
+ result = gen.future_transform(df2)
1809
+
1810
+ new_player_start_rating = result.filter(pl.col("pid") == "P_NEW")[
1811
+ "player_off_rating_perf"
1812
+ ][0]
1813
+
1814
+ assert new_player_start_rating == pytest.approx(expected_quantile_rating, rel=0.1), (
1815
+ f"New player should start at 5th percentile ({expected_quantile_rating:.1f}) "
1816
+ f"but got {new_player_start_rating:.1f}. "
1817
+ "start_league_quantile has no effect because update_players_to_leagues is never called."
1818
+ )
@@ -0,0 +1,214 @@
1
+ """Tests to ensure PlayerRatingGenerator does not mutate input columns."""
2
+
3
+ import polars as pl
4
+ import pytest
5
+
6
+ from spforge import ColumnNames
7
+ from spforge.ratings import PlayerRatingGenerator, RatingKnownFeatures
8
+
9
+
10
+ @pytest.fixture
11
+ def cn_with_projected():
12
+ """ColumnNames with both participation_weight and projected_participation_weight."""
13
+ return ColumnNames(
14
+ player_id="pid",
15
+ team_id="tid",
16
+ match_id="mid",
17
+ start_date="dt",
18
+ update_match_id="mid",
19
+ participation_weight="minutes",
20
+ projected_participation_weight="minutes_prediction",
21
+ )
22
+
23
+
24
+ @pytest.fixture
25
+ def fit_df():
26
+ """Training data with minutes > 1 (will trigger auto-scaling)."""
27
+ return pl.DataFrame(
28
+ {
29
+ "pid": ["P1", "P2", "P3", "P4"],
30
+ "tid": ["T1", "T1", "T2", "T2"],
31
+ "mid": ["M1", "M1", "M1", "M1"],
32
+ "dt": ["2024-01-01"] * 4,
33
+ "perf": [0.6, 0.4, 0.7, 0.3],
34
+ "minutes": [30.0, 25.0, 32.0, 28.0],
35
+ "minutes_prediction": [28.0, 24.0, 30.0, 26.0],
36
+ }
37
+ )
38
+
39
+
40
+ @pytest.fixture
41
+ def future_df():
42
+ """Future prediction data with minutes > 1 (will trigger auto-scaling)."""
43
+ return pl.DataFrame(
44
+ {
45
+ "pid": ["P1", "P2", "P3", "P4"],
46
+ "tid": ["T1", "T1", "T2", "T2"],
47
+ "mid": ["M2", "M2", "M2", "M2"],
48
+ "dt": ["2024-01-02"] * 4,
49
+ "minutes": [30.0, 25.0, 32.0, 28.0],
50
+ "minutes_prediction": [28.0, 24.0, 30.0, 26.0],
51
+ }
52
+ )
53
+
54
+
55
+ def test_fit_transform_does_not_mutate_participation_weight(cn_with_projected, fit_df):
56
+ """fit_transform should not modify the participation_weight column values."""
57
+ # Join result with original to compare values by player_id
58
+ gen = PlayerRatingGenerator(
59
+ performance_column="perf",
60
+ column_names=cn_with_projected,
61
+ auto_scale_performance=True,
62
+ features_out=[RatingKnownFeatures.PLAYER_OFF_RATING],
63
+ )
64
+ result = gen.fit_transform(fit_df)
65
+
66
+ # Check that each player's minutes value is preserved
67
+ original_by_player = dict(zip(fit_df["pid"].to_list(), fit_df["minutes"].to_list()))
68
+ result_by_player = dict(zip(result["pid"].to_list(), result["minutes"].to_list()))
69
+
70
+ for pid, original_val in original_by_player.items():
71
+ result_val = result_by_player[pid]
72
+ assert result_val == original_val, (
73
+ f"participation_weight for player {pid} was mutated. "
74
+ f"Expected {original_val}, got {result_val}"
75
+ )
76
+
77
+
78
+ def test_fit_transform_does_not_mutate_projected_participation_weight(cn_with_projected, fit_df):
79
+ """fit_transform should not modify the projected_participation_weight column values."""
80
+ gen = PlayerRatingGenerator(
81
+ performance_column="perf",
82
+ column_names=cn_with_projected,
83
+ auto_scale_performance=True,
84
+ features_out=[RatingKnownFeatures.PLAYER_OFF_RATING],
85
+ )
86
+ result = gen.fit_transform(fit_df)
87
+
88
+ # Check that each player's minutes_prediction value is preserved
89
+ original_by_player = dict(zip(fit_df["pid"].to_list(), fit_df["minutes_prediction"].to_list()))
90
+ result_by_player = dict(zip(result["pid"].to_list(), result["minutes_prediction"].to_list()))
91
+
92
+ for pid, original_val in original_by_player.items():
93
+ result_val = result_by_player[pid]
94
+ assert result_val == original_val, (
95
+ f"projected_participation_weight for player {pid} was mutated. "
96
+ f"Expected {original_val}, got {result_val}"
97
+ )
98
+
99
+
100
+ def test_transform_does_not_mutate_participation_weight(cn_with_projected, fit_df, future_df):
101
+ """transform should not modify the participation_weight column values."""
102
+ gen = PlayerRatingGenerator(
103
+ performance_column="perf",
104
+ column_names=cn_with_projected,
105
+ auto_scale_performance=True,
106
+ features_out=[RatingKnownFeatures.PLAYER_OFF_RATING],
107
+ )
108
+ gen.fit_transform(fit_df)
109
+
110
+ result = gen.transform(future_df)
111
+
112
+ # Check that each player's minutes value is preserved
113
+ original_by_player = dict(zip(future_df["pid"].to_list(), future_df["minutes"].to_list()))
114
+ result_by_player = dict(zip(result["pid"].to_list(), result["minutes"].to_list()))
115
+
116
+ for pid, original_val in original_by_player.items():
117
+ result_val = result_by_player[pid]
118
+ assert result_val == original_val, (
119
+ f"participation_weight for player {pid} was mutated during transform. "
120
+ f"Expected {original_val}, got {result_val}"
121
+ )
122
+
123
+
124
+ def test_transform_does_not_mutate_projected_participation_weight(cn_with_projected, fit_df, future_df):
125
+ """transform should not modify the projected_participation_weight column values."""
126
+ gen = PlayerRatingGenerator(
127
+ performance_column="perf",
128
+ column_names=cn_with_projected,
129
+ auto_scale_performance=True,
130
+ features_out=[RatingKnownFeatures.PLAYER_OFF_RATING],
131
+ )
132
+ gen.fit_transform(fit_df)
133
+
134
+ result = gen.transform(future_df)
135
+
136
+ # Check that each player's minutes_prediction value is preserved
137
+ original_by_player = dict(zip(future_df["pid"].to_list(), future_df["minutes_prediction"].to_list()))
138
+ result_by_player = dict(zip(result["pid"].to_list(), result["minutes_prediction"].to_list()))
139
+
140
+ for pid, original_val in original_by_player.items():
141
+ result_val = result_by_player[pid]
142
+ assert result_val == original_val, (
143
+ f"projected_participation_weight for player {pid} was mutated during transform. "
144
+ f"Expected {original_val}, got {result_val}"
145
+ )
146
+
147
+
148
+ def test_future_transform_does_not_mutate_participation_weight(cn_with_projected, fit_df, future_df):
149
+ """future_transform should not modify the participation_weight column values."""
150
+ gen = PlayerRatingGenerator(
151
+ performance_column="perf",
152
+ column_names=cn_with_projected,
153
+ auto_scale_performance=True,
154
+ features_out=[RatingKnownFeatures.PLAYER_OFF_RATING],
155
+ )
156
+ gen.fit_transform(fit_df)
157
+
158
+ original_minutes = future_df["minutes"].to_list()
159
+ result = gen.future_transform(future_df)
160
+
161
+ # The minutes column should have the same values as before
162
+ result_minutes = result["minutes"].to_list()
163
+ assert result_minutes == original_minutes, (
164
+ f"participation_weight column was mutated during future_transform. "
165
+ f"Expected {original_minutes}, got {result_minutes}"
166
+ )
167
+
168
+
169
+ def test_future_transform_does_not_mutate_projected_participation_weight(cn_with_projected, fit_df, future_df):
170
+ """future_transform should not modify the projected_participation_weight column values."""
171
+ gen = PlayerRatingGenerator(
172
+ performance_column="perf",
173
+ column_names=cn_with_projected,
174
+ auto_scale_performance=True,
175
+ features_out=[RatingKnownFeatures.PLAYER_OFF_RATING],
176
+ )
177
+ gen.fit_transform(fit_df)
178
+
179
+ original_minutes_pred = future_df["minutes_prediction"].to_list()
180
+ result = gen.future_transform(future_df)
181
+
182
+ # The minutes_prediction column should have the same values as before
183
+ result_minutes_pred = result["minutes_prediction"].to_list()
184
+ assert result_minutes_pred == original_minutes_pred, (
185
+ f"projected_participation_weight column was mutated during future_transform. "
186
+ f"Expected {original_minutes_pred}, got {result_minutes_pred}"
187
+ )
188
+
189
+
190
+ def test_multiple_transforms_do_not_compound_scaling(cn_with_projected, fit_df, future_df):
191
+ """Multiple transform calls should not compound the scaling effect."""
192
+ gen = PlayerRatingGenerator(
193
+ performance_column="perf",
194
+ column_names=cn_with_projected,
195
+ auto_scale_performance=True,
196
+ features_out=[RatingKnownFeatures.PLAYER_OFF_RATING],
197
+ )
198
+ gen.fit_transform(fit_df)
199
+
200
+ # Call transform multiple times
201
+ result1 = gen.transform(future_df)
202
+ result2 = gen.transform(result1)
203
+ result3 = gen.transform(result2)
204
+
205
+ # After 3 transforms, each player's values should still be the same as original
206
+ original_by_player = dict(zip(future_df["pid"].to_list(), future_df["minutes_prediction"].to_list()))
207
+ final_by_player = dict(zip(result3["pid"].to_list(), result3["minutes_prediction"].to_list()))
208
+
209
+ for pid, original_val in original_by_player.items():
210
+ final_val = final_by_player[pid]
211
+ assert final_val == original_val, (
212
+ f"Multiple transforms compounded the scaling for player {pid}. "
213
+ f"Expected {original_val}, got {final_val}"
214
+ )
@@ -0,0 +1,136 @@
1
+ """Tests to ensure utility functions use scaled participation weights when available."""
2
+
3
+ import polars as pl
4
+ import pytest
5
+
6
+ from spforge import ColumnNames
7
+ from spforge.ratings.utils import (
8
+ _SCALED_PPW,
9
+ add_team_rating_projected,
10
+ add_rating_mean_projected,
11
+ )
12
+
13
+
14
+ @pytest.fixture
15
+ def column_names():
16
+ return ColumnNames(
17
+ player_id="pid",
18
+ team_id="tid",
19
+ match_id="mid",
20
+ start_date="dt",
21
+ projected_participation_weight="ppw",
22
+ )
23
+
24
+
25
+ @pytest.fixture
26
+ def df_with_scaled():
27
+ """DataFrame with both raw and scaled projected participation weights."""
28
+ return pl.DataFrame({
29
+ "pid": ["A", "B", "C", "D"],
30
+ "tid": ["T1", "T1", "T2", "T2"],
31
+ "mid": ["M1", "M1", "M1", "M1"],
32
+ "dt": ["2024-01-01"] * 4,
33
+ "rating": [1100.0, 900.0, 1050.0, 950.0],
34
+ "ppw": [20.0, 5.0, 10.0, 10.0], # Raw weights (would give wrong answer)
35
+ _SCALED_PPW: [1.0, 0.5, 1.0, 1.0], # Scaled/clipped weights
36
+ })
37
+
38
+
39
+ @pytest.fixture
40
+ def df_without_scaled():
41
+ """DataFrame with only raw projected participation weights (no scaled column)."""
42
+ return pl.DataFrame({
43
+ "pid": ["A", "B", "C", "D"],
44
+ "tid": ["T1", "T1", "T2", "T2"],
45
+ "mid": ["M1", "M1", "M1", "M1"],
46
+ "dt": ["2024-01-01"] * 4,
47
+ "rating": [1100.0, 900.0, 1050.0, 950.0],
48
+ "ppw": [0.8, 0.4, 1.0, 1.0], # Already scaled weights
49
+ })
50
+
51
+
52
+ def test_add_team_rating_projected_uses_scaled_column(column_names, df_with_scaled):
53
+ """add_team_rating_projected should use _SCALED_PPW when available."""
54
+ result = add_team_rating_projected(
55
+ df=df_with_scaled,
56
+ column_names=column_names,
57
+ player_rating_col="rating",
58
+ team_rating_out="team_rating",
59
+ )
60
+
61
+ # With scaled weights (1.0, 0.5), T1 team rating = (1100*1.0 + 900*0.5) / (1.0+0.5) = 1450/1.5 = 966.67
62
+ # If it used raw weights (20.0, 5.0), it would be (1100*20 + 900*5) / 25 = 26500/25 = 1060
63
+ t1_rating = result.filter(pl.col("tid") == "T1")["team_rating"][0]
64
+
65
+ expected_with_scaled = (1100.0 * 1.0 + 900.0 * 0.5) / (1.0 + 0.5)
66
+ wrong_with_raw = (1100.0 * 20.0 + 900.0 * 5.0) / (20.0 + 5.0)
67
+
68
+ assert t1_rating == pytest.approx(expected_with_scaled, rel=1e-6)
69
+ assert t1_rating != pytest.approx(wrong_with_raw, rel=1e-6)
70
+
71
+
72
+ def test_add_team_rating_projected_falls_back_to_raw(column_names, df_without_scaled):
73
+ """add_team_rating_projected should use raw ppw when _SCALED_PPW is not available."""
74
+ result = add_team_rating_projected(
75
+ df=df_without_scaled,
76
+ column_names=column_names,
77
+ player_rating_col="rating",
78
+ team_rating_out="team_rating",
79
+ )
80
+
81
+ # With raw weights (0.8, 0.4), T1 team rating = (1100*0.8 + 900*0.4) / (0.8+0.4) = 1240/1.2 = 1033.33
82
+ t1_rating = result.filter(pl.col("tid") == "T1")["team_rating"][0]
83
+
84
+ expected = (1100.0 * 0.8 + 900.0 * 0.4) / (0.8 + 0.4)
85
+ assert t1_rating == pytest.approx(expected, rel=1e-6)
86
+
87
+
88
+ def test_add_rating_mean_projected_uses_scaled_column(column_names, df_with_scaled):
89
+ """add_rating_mean_projected should use _SCALED_PPW when available."""
90
+ result = add_rating_mean_projected(
91
+ df=df_with_scaled,
92
+ column_names=column_names,
93
+ player_rating_col="rating",
94
+ rating_mean_out="mean_rating",
95
+ )
96
+
97
+ # With scaled weights, mean = (1100*1.0 + 900*0.5 + 1050*1.0 + 950*1.0) / (1.0+0.5+1.0+1.0)
98
+ # = (1100 + 450 + 1050 + 950) / 3.5 = 3550/3.5 = 1014.29
99
+ mean_rating = result["mean_rating"][0]
100
+
101
+ expected_with_scaled = (1100.0*1.0 + 900.0*0.5 + 1050.0*1.0 + 950.0*1.0) / (1.0+0.5+1.0+1.0)
102
+ wrong_with_raw = (1100.0*20.0 + 900.0*5.0 + 1050.0*10.0 + 950.0*10.0) / (20.0+5.0+10.0+10.0)
103
+
104
+ assert mean_rating == pytest.approx(expected_with_scaled, rel=1e-6)
105
+ assert mean_rating != pytest.approx(wrong_with_raw, rel=1e-6)
106
+
107
+
108
+ def test_add_rating_mean_projected_falls_back_to_raw(column_names, df_without_scaled):
109
+ """add_rating_mean_projected should use raw ppw when _SCALED_PPW is not available."""
110
+ result = add_rating_mean_projected(
111
+ df=df_without_scaled,
112
+ column_names=column_names,
113
+ player_rating_col="rating",
114
+ rating_mean_out="mean_rating",
115
+ )
116
+
117
+ # With raw weights (0.8, 0.4, 1.0, 1.0)
118
+ mean_rating = result["mean_rating"][0]
119
+
120
+ expected = (1100.0*0.8 + 900.0*0.4 + 1050.0*1.0 + 950.0*1.0) / (0.8+0.4+1.0+1.0)
121
+ assert mean_rating == pytest.approx(expected, rel=1e-6)
122
+
123
+
124
+ def test_scaled_weights_not_in_output(column_names, df_with_scaled):
125
+ """Verify utility functions don't add scaled columns to output unnecessarily."""
126
+ result = add_team_rating_projected(
127
+ df=df_with_scaled,
128
+ column_names=column_names,
129
+ player_rating_col="rating",
130
+ team_rating_out="team_rating",
131
+ )
132
+
133
+ # The scaled column should still be present (it was in input)
134
+ # but no new internal columns should be added
135
+ assert _SCALED_PPW in result.columns
136
+ assert "team_rating" in result.columns