spforge 0.8.19__py3-none-any.whl → 0.8.23__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/ratings/_base.py CHANGED
@@ -176,6 +176,9 @@ class RatingGenerator(FeatureGenerator):
176
176
  @to_polars
177
177
  @nw.narwhalify
178
178
  def transform(self, df: IntoFrameT) -> IntoFrameT:
179
+ if self.performance_manager and self.performance_manager.ori_performance_column in df.columns:
180
+ df = nw.from_native(self.performance_manager.transform(df))
181
+
179
182
  pl_df: pl.DataFrame
180
183
  pl_df = df.to_native() if df.implementation.is_polars() else df.to_polars().to_native()
181
184
  return self._historical_transform(pl_df)
@@ -188,6 +191,9 @@ class RatingGenerator(FeatureGenerator):
188
191
  - use existing ratings to compute pre-match ratings/features
189
192
  - do NOT update ratings
190
193
  """
194
+ if self.performance_manager and self.performance_manager.ori_performance_column in df.columns:
195
+ df = nw.from_native(self.performance_manager.transform(df))
196
+
191
197
  pl_df: pl.DataFrame
192
198
  pl_df = df.to_native() if df.implementation.is_polars() else df.to_polars().to_native()
193
199
  return self._future_transform(pl_df)
@@ -433,9 +433,16 @@ class PlayerRatingGenerator(RatingGenerator):
433
433
  team1_off_perf = self._team_off_perf_from_collection(c1)
434
434
  team2_off_perf = self._team_off_perf_from_collection(c2)
435
435
 
436
+ team1_def_perf: float | None = None
437
+ team2_def_perf: float | None = None
438
+
436
439
  if self.use_off_def_split:
437
- team1_def_perf = 1.0 - team2_off_perf
438
- team2_def_perf = 1.0 - team1_off_perf
440
+ team1_def_perf = (
441
+ 1.0 - team2_off_perf if team2_off_perf is not None else None
442
+ )
443
+ team2_def_perf = (
444
+ 1.0 - team1_off_perf if team1_off_perf is not None else None
445
+ )
439
446
  else:
440
447
  team1_def_perf = team1_off_perf
441
448
  team2_def_perf = team2_off_perf
@@ -484,26 +491,33 @@ class PlayerRatingGenerator(RatingGenerator):
484
491
  ),
485
492
  )
486
493
 
487
- off_perf = float(pre_player.match_performance.performance_value)
488
- def_perf = float(team1_def_perf) # same for all players on team1 (derived)
494
+ perf_value = pre_player.match_performance.performance_value
495
+ if perf_value is None:
496
+ off_change = 0.0
497
+ else:
498
+ off_perf = float(perf_value)
499
+ mult_off = self._applied_multiplier_off(off_state)
500
+ off_change = (
501
+ (off_perf - float(pred_off))
502
+ * mult_off
503
+ * float(pre_player.match_performance.participation_weight)
504
+ )
489
505
 
490
- if not self.use_off_def_split:
491
- pred_def = pred_off
492
- def_perf = off_perf
506
+ if perf_value is None or team1_def_perf is None:
507
+ def_change = 0.0
508
+ else:
509
+ def_perf = float(team1_def_perf)
493
510
 
494
- mult_off = self._applied_multiplier_off(off_state)
495
- mult_def = self._applied_multiplier_def(def_state)
511
+ if not self.use_off_def_split:
512
+ pred_def = pred_off
513
+ def_perf = float(perf_value)
496
514
 
497
- off_change = (
498
- (off_perf - float(pred_off))
499
- * mult_off
500
- * float(pre_player.match_performance.participation_weight)
501
- )
502
- def_change = (
503
- (def_perf - float(pred_def))
504
- * mult_def
505
- * float(pre_player.match_performance.participation_weight)
506
- )
515
+ mult_def = self._applied_multiplier_def(def_state)
516
+ def_change = (
517
+ (def_perf - float(pred_def))
518
+ * mult_def
519
+ * float(pre_player.match_performance.participation_weight)
520
+ )
507
521
 
508
522
  if math.isnan(off_change) or math.isnan(def_change):
509
523
  raise ValueError(
@@ -562,26 +576,33 @@ class PlayerRatingGenerator(RatingGenerator):
562
576
  ),
563
577
  )
564
578
 
565
- off_perf = float(pre_player.match_performance.performance_value)
566
- def_perf = float(team2_def_perf)
579
+ perf_value = pre_player.match_performance.performance_value
580
+ if perf_value is None:
581
+ off_change = 0.0
582
+ else:
583
+ off_perf = float(perf_value)
584
+ mult_off = self._applied_multiplier_off(off_state)
585
+ off_change = (
586
+ (off_perf - float(pred_off))
587
+ * mult_off
588
+ * float(pre_player.match_performance.participation_weight)
589
+ )
567
590
 
568
- if not self.use_off_def_split:
569
- pred_def = pred_off
570
- def_perf = off_perf
591
+ if perf_value is None or team2_def_perf is None:
592
+ def_change = 0.0
593
+ else:
594
+ def_perf = float(team2_def_perf)
571
595
 
572
- mult_off = self._applied_multiplier_off(off_state)
573
- mult_def = self._applied_multiplier_def(def_state)
596
+ if not self.use_off_def_split:
597
+ pred_def = pred_off
598
+ def_perf = float(perf_value)
574
599
 
575
- off_change = (
576
- (off_perf - float(pred_off))
577
- * mult_off
578
- * float(pre_player.match_performance.participation_weight)
579
- )
580
- def_change = (
581
- (def_perf - float(pred_def))
582
- * mult_def
583
- * float(pre_player.match_performance.participation_weight)
584
- )
600
+ mult_def = self._applied_multiplier_def(def_state)
601
+ def_change = (
602
+ (def_perf - float(pred_def))
603
+ * mult_def
604
+ * float(pre_player.match_performance.participation_weight)
605
+ )
585
606
 
586
607
  if math.isnan(off_change) or math.isnan(def_change):
587
608
  raise ValueError(
@@ -933,7 +954,7 @@ class PlayerRatingGenerator(RatingGenerator):
933
954
  self.performance_column in team_player
934
955
  and team_player[self.performance_column] is not None
935
956
  )
936
- else 0.0
957
+ else None
937
958
  )
938
959
 
939
960
  mp = MatchPerformance(
@@ -1021,22 +1042,28 @@ class PlayerRatingGenerator(RatingGenerator):
1021
1042
 
1022
1043
  return pre_match_player_ratings, pre_match_player_off_values
1023
1044
 
1024
- def _team_off_perf_from_collection(self, c: PreMatchPlayersCollection) -> float:
1045
+ def _team_off_perf_from_collection(
1046
+ self, c: PreMatchPlayersCollection
1047
+ ) -> float | None:
1025
1048
  # observed offense perf = weighted mean of player performance_value using participation_weight if present
1049
+ # skip players with null performance
1026
1050
  cn = self.column_names
1027
1051
  if not c.pre_match_player_ratings:
1028
- return 0.0
1052
+ return None
1029
1053
  wsum = 0.0
1030
1054
  psum = 0.0
1031
1055
  for pre in c.pre_match_player_ratings:
1056
+ perf_val = pre.match_performance.performance_value
1057
+ if perf_val is None:
1058
+ continue
1032
1059
  w = (
1033
1060
  float(pre.match_performance.participation_weight)
1034
1061
  if cn.participation_weight
1035
1062
  else 1.0
1036
1063
  )
1037
- psum += float(pre.match_performance.performance_value) * w
1064
+ psum += float(perf_val) * w
1038
1065
  wsum += w
1039
- return psum / wsum if wsum else 0.0
1066
+ return psum / wsum if wsum else None
1040
1067
 
1041
1068
  def _team_off_def_rating_from_collection(
1042
1069
  self, c: PreMatchPlayersCollection
@@ -1101,13 +1128,13 @@ class PlayerRatingGenerator(RatingGenerator):
1101
1128
  self.PLAYER_PRED_PERF_COL: [],
1102
1129
  }
1103
1130
 
1104
- def get_perf_value(team_player: dict) -> float:
1131
+ def get_perf_value(team_player: dict) -> float | None:
1105
1132
  if (
1106
1133
  self.performance_column in team_player
1107
1134
  and team_player[self.performance_column] is not None
1108
1135
  ):
1109
1136
  return float(team_player[self.performance_column])
1110
- return 0.0
1137
+ return None
1111
1138
 
1112
1139
  def ensure_new_player(
1113
1140
  pid: str,
@@ -1187,8 +1214,9 @@ class PlayerRatingGenerator(RatingGenerator):
1187
1214
  )
1188
1215
  off_vals.append(float(local_off[pid].rating_value))
1189
1216
 
1190
- psum += float(mp.performance_value) * float(pw)
1191
- wsum += float(pw)
1217
+ if mp.performance_value is not None:
1218
+ psum += float(mp.performance_value) * float(pw)
1219
+ wsum += float(pw)
1192
1220
 
1193
1221
  team_off_perf = psum / wsum if wsum else 0.0
1194
1222
  return pre_list, player_ids, off_vals, proj_w, team_off_perf
@@ -326,16 +326,7 @@ class TeamRatingGenerator(RatingGenerator):
326
326
  opp_off_pre = float(o_off.rating_value)
327
327
  opp_def_pre = float(o_def.rating_value)
328
328
 
329
- off_perf = (
330
- float(r[self.performance_column])
331
- if r.get(self.performance_column) is not None
332
- else 0.0
333
- )
334
- opp_off_perf = float(r[perf_opp_col]) if r.get(perf_opp_col) is not None else 0.0
335
- if self.use_off_def_split:
336
- def_perf = 1.0 - opp_off_perf
337
- else:
338
- def_perf = off_perf
329
+ off_perf_raw = r.get(self.performance_column)
339
330
 
340
331
  pred_off = self._performance_predictor.predict_performance(
341
332
  rating_value=s_off.rating_value, opponent_team_rating_value=o_def.rating_value
@@ -346,16 +337,28 @@ class TeamRatingGenerator(RatingGenerator):
346
337
  if not self.use_off_def_split:
347
338
  pred_def = pred_off
348
339
 
349
- mult_off = self._applied_multiplier(s_off, self.rating_change_multiplier_offense)
350
- mult_def = self._applied_multiplier(s_def, self.rating_change_multiplier_defense)
351
-
352
- off_change = (off_perf - pred_off) * mult_off
353
- def_change = (def_perf - pred_def) * mult_def
354
-
355
- if math.isnan(off_change) or math.isnan(def_change):
356
- raise ValueError(
357
- f"NaN rating change for team_id={team_id}, match_id={r[cn.match_id]}"
358
- )
340
+ # Null performance means no rating change
341
+ if off_perf_raw is None:
342
+ off_change = 0.0
343
+ def_change = 0.0
344
+ else:
345
+ off_perf = float(off_perf_raw)
346
+ opp_off_perf = float(r[perf_opp_col]) if r.get(perf_opp_col) is not None else 0.0
347
+ if self.use_off_def_split:
348
+ def_perf = 1.0 - opp_off_perf
349
+ else:
350
+ def_perf = off_perf
351
+
352
+ mult_off = self._applied_multiplier(s_off, self.rating_change_multiplier_offense)
353
+ mult_def = self._applied_multiplier(s_def, self.rating_change_multiplier_defense)
354
+
355
+ off_change = (off_perf - pred_off) * mult_off
356
+ def_change = (def_perf - pred_def) * mult_def
357
+
358
+ if math.isnan(off_change) or math.isnan(def_change):
359
+ raise ValueError(
360
+ f"NaN rating change for team_id={team_id}, match_id={r[cn.match_id]}"
361
+ )
359
362
 
360
363
  rows.append(
361
364
  {
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: spforge
3
- Version: 0.8.19
3
+ Version: 0.8.23
4
4
  Summary: A flexible framework for generating features, ratings, and building machine learning or other models for training and inference on sports data.
5
5
  Author-email: Mathias Holmstrøm <mathiasholmstom@gmail.com>
6
6
  License: See LICENSE file
@@ -50,9 +50,9 @@ spforge/performance_transformers/__init__.py,sha256=U6d7_kltbUMLYCGBk4QAFVPJTxXD
50
50
  spforge/performance_transformers/_performance_manager.py,sha256=WmjmlMEnq7y75MiI_s9Y-9eMXIyhPTUKrwsXRtgYp0k,9620
51
51
  spforge/performance_transformers/_performances_transformers.py,sha256=0lxuWjAfWBRXRgQsNJHjw3P-nlTtHBu4_bOVdoy7hq4,15536
52
52
  spforge/ratings/__init__.py,sha256=OZVH2Lo6END3n1X8qi4QcyAPlThIwAYwVKCiIuOQSQU,576
53
- spforge/ratings/_base.py,sha256=dRMkIGj5-2zKddygaEA4g16WCyXon7v8Xa1ymm7IuoM,14335
54
- spforge/ratings/_player_rating.py,sha256=JSTXdaRw_b8ZoZxgmMnZrYG7gPg8GKawqalLd16SK1M,56066
55
- spforge/ratings/_team_rating.py,sha256=T0kFiv3ykYSrVGGsVRa8ZxLB0WMnagxqdFDzl9yZ_9g,24813
53
+ spforge/ratings/_base.py,sha256=ne4BRrYFPqMirdFPVnyDN44wjFQwOQgWoUXu_59xgWE,14687
54
+ spforge/ratings/_player_rating.py,sha256=zhTI6isbNXYy9xAyMt_6nlOktsk6TukDVWV7vS7G4qg,57190
55
+ spforge/ratings/_team_rating.py,sha256=3m90-R2zW0k5EHwjw-83Hacz91fGmxW1LQ8ZUGHlgt4,24970
56
56
  spforge/ratings/enums.py,sha256=s7z_RcZS6Nlgfa_6tasO8_IABZJwywexe7sep9DJBgo,1739
57
57
  spforge/ratings/league_identifier.py,sha256=_KDUKOwoNU6RNFKE5jju4eYFGVNGBdJsv5mhNvMakfc,6019
58
58
  spforge/ratings/league_start_rating_optimizer.py,sha256=Q4Vo3QT-r55qP4aD9WftsTB00UOSRvxM1khlyuAGWNM,8582
@@ -71,7 +71,7 @@ spforge/transformers/_other_transformer.py,sha256=w2a7Wnki3vJe4GAkSa4kealw0GILIo
71
71
  spforge/transformers/_predictor.py,sha256=2sE6gfVrilXzPVcBurSrtqHw33v2ljygQcEYXt9LhZc,3119
72
72
  spforge/transformers/_simple_transformer.py,sha256=zGUFNQYMeoDSa2CoQejQNiNmKCBN5amWTvyOchiUHj0,5660
73
73
  spforge/transformers/_team_ratio_predictor.py,sha256=g8_bR53Yyv0iNCtol1O9bgJSeZcIco_AfbQuUxQJkeY,6884
74
- spforge-0.8.19.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
74
+ spforge-0.8.23.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
75
75
  tests/test_autopipeline.py,sha256=7cNAn-nmGolfyfk3THh9IKcHZfRA-pLYC_xAyMg-No4,26863
76
76
  tests/test_autopipeline_context.py,sha256=IuRUY4IA6uMObvbl2pXSaXO2_tl3qX6wEbTZY0dkTMI,1240
77
77
  tests/test_feature_generator_pipeline.py,sha256=CK0zVL8PfTncy3RmG9i-YpgwjOIV7yJhV7Q44tbetI8,19020
@@ -94,10 +94,10 @@ tests/hyperparameter_tuning/test_estimator_tuner.py,sha256=iewME41d6LR2aQ0OtohGF
94
94
  tests/hyperparameter_tuning/test_rating_tuner.py,sha256=usjC2ioO_yWRjjNAlRTyMVYheOrCi0kKocmHQHdTmpM,18699
95
95
  tests/performance_transformers/test_performance_manager.py,sha256=gjuuV_hb27kCo_kUecPKG3Cbot2Gqis1W3kw2A4ovS4,10690
96
96
  tests/performance_transformers/test_performances_transformers.py,sha256=A-tGiCx7kXrj1cVj03Bc7prOeZ1_Ryz8YFx9uj3eK6w,11064
97
- tests/ratings/test_player_rating_generator.py,sha256=SKLaBQBsHYslc2Nia2AxZ8A9Cy16MbZAWjLyOjvcMnA,64094
97
+ tests/ratings/test_player_rating_generator.py,sha256=51iWgQRBHbb2-IPeajpej9ncGDWI1eUYdWrLXaKd9Ig,72232
98
98
  tests/ratings/test_player_rating_no_mutation.py,sha256=GzO3Hl__5K68DS3uRLefwnbcTJOvBM7cZqww4M21UZM,8493
99
99
  tests/ratings/test_ratings_property.py,sha256=ckyfGILXa4tfQvsgyXEzBDNr2DUmHwFRV13N60w66iE,6561
100
- tests/ratings/test_team_rating_generator.py,sha256=cDnf1zHiYC7pkgydE3MYr8wSTJIq-bPfSqhIRI_4Tic,95357
100
+ tests/ratings/test_team_rating_generator.py,sha256=SqQcfckNmJJc99feCdnmkNYDape-p69e92Dp8Vzpu2w,101156
101
101
  tests/ratings/test_utils_scaled_weights.py,sha256=iHxe6ZDUB_I2B6HT0xTGqXBkl7gRlqVV0e_7Lwun5po,4988
102
102
  tests/scorer/test_score.py,sha256=rw3xJs6xqWVpalVMUQz557m2JYGR7PmhrsjfTex0b0c,79121
103
103
  tests/scorer/test_score_aggregation_granularity.py,sha256=h-hyFOLzwp-92hYVU7CwvlRJ8jhB4DzXCtqgI-zcoqM,13677
@@ -107,7 +107,7 @@ tests/transformers/test_other_transformer.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRk
107
107
  tests/transformers/test_predictor_transformer.py,sha256=N1aBYLjN3ldpYZLwjih_gTFYSMitrZu-PNK78W6RHaQ,6877
108
108
  tests/transformers/test_simple_transformer.py,sha256=wWR0qjLb_uS4HXrJgGdiqugOY1X7kwd1_OPS02IT2b8,4676
109
109
  tests/transformers/test_team_ratio_predictor.py,sha256=fOUP_JvNJi-3kom3ZOs1EdG0I6Z8hpLpYKNHu1eWtOw,8562
110
- spforge-0.8.19.dist-info/METADATA,sha256=4q1uKNTzmI9bwRwMJQaM0N6SAaC1RDembf_Gfbm2-mw,20048
111
- spforge-0.8.19.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
112
- spforge-0.8.19.dist-info/top_level.txt,sha256=6UW2M5a7WKOeaAi900qQmRKNj5-HZzE8-eUD9Y9LTq0,23
113
- spforge-0.8.19.dist-info/RECORD,,
110
+ spforge-0.8.23.dist-info/METADATA,sha256=jlkQ3fEjfwmJ_euPrFO6OlI-hT0LMQN928wz87B1qVU,20048
111
+ spforge-0.8.23.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
112
+ spforge-0.8.23.dist-info/top_level.txt,sha256=6UW2M5a7WKOeaAi900qQmRKNj5-HZzE8-eUD9Y9LTq0,23
113
+ spforge-0.8.23.dist-info/RECORD,,
@@ -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,210 @@ 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
+
725
+
726
+ def test_fit_transform_null_performance__no_rating_change(base_cn):
727
+ """Players with null performance should have zero rating change, not be treated as 0.0 perf."""
728
+ # Match 1: Both players have performance (P1=0.6, P2=0.4)
729
+ # Match 2: P1 has null performance, P2 has 0.6
730
+ # Match 3: Both players have performance again
731
+ df = pl.DataFrame(
732
+ {
733
+ "pid": ["P1", "P2", "P1", "P2", "P1", "P2"],
734
+ "tid": ["T1", "T2", "T1", "T2", "T1", "T2"],
735
+ "mid": ["M1", "M1", "M2", "M2", "M3", "M3"],
736
+ "dt": [
737
+ "2024-01-01",
738
+ "2024-01-01",
739
+ "2024-01-02",
740
+ "2024-01-02",
741
+ "2024-01-03",
742
+ "2024-01-03",
743
+ ],
744
+ "perf": [0.6, 0.4, None, 0.6, 0.6, 0.4], # P1 has null in M2
745
+ "pw": [1.0, 1.0, 1.0, 1.0, 1.0, 1.0],
746
+ }
747
+ )
748
+
749
+ gen = PlayerRatingGenerator(
750
+ performance_column="perf",
751
+ column_names=base_cn,
752
+ features_out=[RatingKnownFeatures.PLAYER_OFF_RATING],
753
+ )
754
+ result = gen.fit_transform(df)
755
+
756
+ # Get P1's pre-match rating for M2 (after M1) and M3 (after M2 with null perf)
757
+ p1_rating_before_m2 = result.filter(
758
+ (pl.col("pid") == "P1") & (pl.col("mid") == "M2")
759
+ )["player_off_rating_perf"][0]
760
+ p1_rating_before_m3 = result.filter(
761
+ (pl.col("pid") == "P1") & (pl.col("mid") == "M3")
762
+ )["player_off_rating_perf"][0]
763
+
764
+ # Key assertion: P1's rating before M3 should equal rating before M2
765
+ # because null performance in M2 means NO rating change
766
+ assert p1_rating_before_m3 == p1_rating_before_m2, (
767
+ f"P1's rating changed after null performance game! "
768
+ f"Before M2={p1_rating_before_m2}, Before M3={p1_rating_before_m3}"
769
+ )
770
+
771
+ # Also verify null is not treated as 0.0 by comparing with explicit 0.0
772
+ df_with_zero = df.with_columns(
773
+ pl.when((pl.col("pid") == "P1") & (pl.col("mid") == "M2"))
774
+ .then(0.0)
775
+ .otherwise(pl.col("perf"))
776
+ .alias("perf")
777
+ )
778
+
779
+ gen_zero = PlayerRatingGenerator(
780
+ performance_column="perf",
781
+ column_names=base_cn,
782
+ features_out=[RatingKnownFeatures.PLAYER_OFF_RATING],
783
+ )
784
+ result_zero = gen_zero.fit_transform(df_with_zero)
785
+
786
+ p1_rating_before_m3_with_zero = result_zero.filter(
787
+ (pl.col("pid") == "P1") & (pl.col("mid") == "M3")
788
+ )["player_off_rating_perf"][0]
789
+
790
+ # With 0.0 perf, rating should drop (different from null)
791
+ assert p1_rating_before_m3 > p1_rating_before_m3_with_zero, (
792
+ f"Null performance is being treated as 0.0! "
793
+ f"Rating with null={p1_rating_before_m3}, rating with 0.0={p1_rating_before_m3_with_zero}"
794
+ )
795
+
796
+
797
+ def test_fit_transform_null_performance__still_outputs_player_rating(base_cn):
798
+ """Players with null performance should still have their pre-match rating in output."""
799
+ df = pl.DataFrame(
800
+ {
801
+ "pid": ["P1", "P2", "P3", "P4"],
802
+ "tid": ["T1", "T1", "T2", "T2"],
803
+ "mid": ["M1", "M1", "M1", "M1"],
804
+ "dt": ["2024-01-01"] * 4,
805
+ "perf": [0.6, None, 0.4, 0.5], # P2 has null performance
806
+ "pw": [1.0, 1.0, 1.0, 1.0],
807
+ }
808
+ )
809
+
810
+ gen = PlayerRatingGenerator(
811
+ performance_column="perf",
812
+ column_names=base_cn,
813
+ features_out=[RatingKnownFeatures.PLAYER_OFF_RATING],
814
+ )
815
+ result = gen.fit_transform(df)
816
+
817
+ # P2 should still be in output with their pre-match rating
818
+ assert len(result) == 4
819
+ p2_row = result.filter(pl.col("pid") == "P2")
820
+ assert len(p2_row) == 1
821
+ assert "player_off_rating_perf" in result.columns
822
+ # P2's rating should be the start rating (1000.0) since they're new and had no update
823
+ assert p2_row["player_off_rating_perf"][0] == 1000.0
824
+
825
+
826
+ def test_transform_null_performance__no_rating_change(base_cn):
827
+ """In transform (historical), null performance should result in no rating change."""
828
+ # First fit with some data
829
+ fit_df = pl.DataFrame(
830
+ {
831
+ "pid": ["P1", "P2"],
832
+ "tid": ["T1", "T2"],
833
+ "mid": ["M1", "M1"],
834
+ "dt": ["2024-01-01", "2024-01-01"],
835
+ "perf": [0.6, 0.4],
836
+ "pw": [1.0, 1.0],
837
+ }
838
+ )
839
+
840
+ gen = PlayerRatingGenerator(
841
+ performance_column="perf",
842
+ column_names=base_cn,
843
+ features_out=[RatingKnownFeatures.PLAYER_OFF_RATING],
844
+ )
845
+ gen.fit_transform(fit_df)
846
+
847
+ p1_rating_before = gen._player_off_ratings["P1"].rating_value
848
+
849
+ # Now transform with P1 having null performance
850
+ transform_df = pl.DataFrame(
851
+ {
852
+ "pid": ["P1", "P2"],
853
+ "tid": ["T1", "T2"],
854
+ "mid": ["M2", "M2"],
855
+ "dt": ["2024-01-02", "2024-01-02"],
856
+ "perf": [None, 0.6], # P1 has null
857
+ "pw": [1.0, 1.0],
858
+ }
859
+ )
860
+
861
+ gen.transform(transform_df)
862
+
863
+ p1_rating_after = gen._player_off_ratings["P1"].rating_value
864
+
865
+ # P1's rating should not change significantly (only confidence decay, not performance-based)
866
+ # Since null perf means no rating change from performance
867
+ assert abs(p1_rating_after - p1_rating_before) < 0.01, (
868
+ f"P1's rating changed significantly with null performance: "
869
+ f"before={p1_rating_before}, after={p1_rating_after}"
870
+ )
871
+
872
+
873
+ def test_future_transform_null_performance__outputs_projections(base_cn):
874
+ """In future_transform, null performance should still output rating projections."""
875
+ # First fit with some data
876
+ fit_df = pl.DataFrame(
877
+ {
878
+ "pid": ["P1", "P2"],
879
+ "tid": ["T1", "T2"],
880
+ "mid": ["M1", "M1"],
881
+ "dt": ["2024-01-01", "2024-01-01"],
882
+ "perf": [0.6, 0.4],
883
+ "pw": [1.0, 1.0],
884
+ }
885
+ )
886
+
887
+ gen = PlayerRatingGenerator(
888
+ performance_column="perf",
889
+ column_names=base_cn,
890
+ features_out=[RatingKnownFeatures.PLAYER_OFF_RATING],
891
+ )
892
+ gen.fit_transform(fit_df)
893
+
894
+ p1_rating_before = gen._player_off_ratings["P1"].rating_value
895
+
896
+ # Future transform (no performance needed, but if null it shouldn't affect anything)
897
+ future_df = pl.DataFrame(
898
+ {
899
+ "pid": ["P1", "P2"],
900
+ "tid": ["T1", "T2"],
901
+ "mid": ["M2", "M2"],
902
+ "dt": ["2024-01-02", "2024-01-02"],
903
+ "pw": [1.0, 1.0],
904
+ # No perf column - this is a future match
905
+ }
906
+ )
907
+
908
+ result = gen.future_transform(future_df)
909
+
910
+ # Should output projections for all players
911
+ assert len(result) == 2
912
+ assert "player_off_rating_perf" in result.columns
913
+
914
+ # Ratings should NOT be updated (future_transform doesn't update state)
915
+ assert gen._player_off_ratings["P1"].rating_value == p1_rating_before
916
+
724
917
 
725
918
  # --- transform & future_transform Tests ---
726
919
 
@@ -1514,7 +1707,6 @@ def test_player_rating_features_out_combinations(
1514
1707
  )
1515
1708
  result = gen.fit_transform(sample_df)
1516
1709
 
1517
- # Check that all expected columns are present
1518
1710
  result_cols = (
1519
1711
  result.columns.tolist() if hasattr(result.columns, "tolist") else list(result.columns)
1520
1712
  )
@@ -1523,93 +1715,17 @@ def test_player_rating_features_out_combinations(
1523
1715
  col in result_cols
1524
1716
  ), f"Expected column '{col}' not found in output. Columns: {result_cols}"
1525
1717
 
1526
- # Check that result has data
1527
- assert len(result) > 0
1718
+ assert len(result) == 4
1528
1719
 
1529
-
1530
- @pytest.mark.parametrize("output_suffix", [None, "v2", "custom_suffix", "test123"])
1531
- def test_player_rating_suffix_applied_to_all_features(base_cn, sample_df, output_suffix):
1532
- """Test that output_suffix is correctly applied to all requested features."""
1533
- features = [
1534
- RatingKnownFeatures.PLAYER_OFF_RATING,
1535
- RatingKnownFeatures.PLAYER_DEF_RATING,
1536
- RatingKnownFeatures.TEAM_OFF_RATING_PROJECTED,
1537
- ]
1538
- non_predictor = [
1539
- RatingUnknownFeatures.PLAYER_PREDICTED_OFF_PERFORMANCE,
1540
- RatingUnknownFeatures.TEAM_RATING,
1541
- ]
1542
-
1543
- gen = PlayerRatingGenerator(
1544
- performance_column="perf",
1545
- column_names=base_cn,
1546
- auto_scale_performance=True,
1547
- features_out=features,
1548
- non_predictor_features_out=non_predictor,
1549
- output_suffix=output_suffix,
1550
- )
1551
- result = gen.fit_transform(sample_df)
1552
-
1553
- # Build expected column names
1554
- if output_suffix:
1555
- expected_cols = [
1556
- f"player_off_rating_{output_suffix}",
1557
- f"player_def_rating_{output_suffix}",
1558
- f"team_off_rating_projected_{output_suffix}",
1559
- f"player_predicted_off_performance_{output_suffix}",
1560
- f"team_rating_{output_suffix}",
1561
- ]
1562
- else:
1563
- # When output_suffix=None, it defaults to performance column name ("perf")
1564
- expected_cols = [
1565
- "player_off_rating_perf",
1566
- "player_def_rating_perf",
1567
- "team_off_rating_projected_perf",
1568
- "player_predicted_off_performance_perf",
1569
- "team_rating_perf",
1570
- ]
1571
-
1572
- result_cols = (
1573
- result.columns.tolist() if hasattr(result.columns, "tolist") else list(result.columns)
1574
- )
1575
1720
  for col in expected_cols:
1576
- assert col in result_cols, f"Expected column '{col}' not found. Columns: {result_cols}"
1577
-
1578
-
1579
- def test_player_rating_only_requested_features_present(base_cn, sample_df):
1580
- """Test that only requested features (and input columns) are present in output."""
1581
- gen = PlayerRatingGenerator(
1582
- performance_column="perf",
1583
- column_names=base_cn,
1584
- auto_scale_performance=True,
1585
- features_out=[RatingKnownFeatures.PLAYER_OFF_RATING],
1586
- non_predictor_features_out=None,
1587
- output_suffix=None,
1588
- )
1589
- result = gen.fit_transform(sample_df)
1590
-
1591
- # Should have input columns + requested feature
1592
- input_cols = set(sample_df.columns)
1593
- result_cols = set(result.columns)
1594
-
1595
- # Check that input columns are preserved
1596
- for col in input_cols:
1597
- assert col in result_cols, f"Input column '{col}' missing from output"
1598
-
1599
- # Check that requested feature is present (with performance column suffix)
1600
- assert "player_off_rating_perf" in result_cols
1601
-
1602
- # Check that other rating features are NOT present (unless they're input columns)
1603
- unwanted_features = [
1604
- "player_def_rating",
1605
- "team_off_rating_projected",
1606
- "player_predicted_off_performance",
1607
- ]
1608
- for feature in unwanted_features:
1609
- if feature not in input_cols:
1610
- assert (
1611
- feature not in result_cols
1612
- ), 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]"
1613
1729
 
1614
1730
 
1615
1731
  def test_player_rating_team_with_strong_offense_and_weak_defense_gets_expected_ratings_and_predictions(
@@ -1816,3 +1932,110 @@ def test_fit_transform__start_league_quantile_uses_existing_player_ratings(base_
1816
1932
  f"but got {new_player_start_rating:.1f}. "
1817
1933
  "start_league_quantile has no effect because update_players_to_leagues is never called."
1818
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
+ )
@@ -835,6 +835,149 @@ def test_fit_transform_when_performance_out_of_range_then_error_is_raised(column
835
835
  generator.fit_transform(df)
836
836
 
837
837
 
838
+ def test_fit_transform_when_performance_is_null_then_no_rating_change(column_names):
839
+ """
840
+ When performance is null, then we should expect to see no rating change
841
+ because null means missing data, not 0.0 (worst) performance.
842
+ The team's pre-match rating for the next game should equal their rating before the null game.
843
+ """
844
+ generator = TeamRatingGenerator(
845
+ performance_column="won",
846
+ column_names=column_names,
847
+ start_team_rating=1000.0,
848
+ confidence_weight=0.0,
849
+ output_suffix="",
850
+ features_out=[RatingKnownFeatures.TEAM_OFF_RATING_PROJECTED],
851
+ )
852
+
853
+ # Match 1: team_a perf=0.6, team_b perf=0.4
854
+ # Match 2: team_a has null performance, team_b perf=0.6
855
+ # Match 3: team_a perf=0.6, team_b perf=0.4
856
+ df = pl.DataFrame(
857
+ {
858
+ "match_id": [1, 1, 2, 2, 3, 3],
859
+ "team_id": ["team_a", "team_b", "team_a", "team_b", "team_a", "team_b"],
860
+ "start_date": [
861
+ datetime(2024, 1, 1),
862
+ datetime(2024, 1, 1),
863
+ datetime(2024, 1, 2),
864
+ datetime(2024, 1, 2),
865
+ datetime(2024, 1, 3),
866
+ datetime(2024, 1, 3),
867
+ ],
868
+ "won": [0.6, 0.4, None, 0.6, 0.6, 0.4], # team_a has null in match 2
869
+ }
870
+ )
871
+
872
+ result = generator.fit_transform(df)
873
+
874
+ # Get team_a's pre-match rating for match 2 (after match 1) and match 3 (after match 2)
875
+ team_a_rating_before_m2 = result.filter(
876
+ (pl.col("team_id") == "team_a") & (pl.col("match_id") == 2)
877
+ )["team_off_rating_projected"][0]
878
+ team_a_rating_before_m3 = result.filter(
879
+ (pl.col("team_id") == "team_a") & (pl.col("match_id") == 3)
880
+ )["team_off_rating_projected"][0]
881
+
882
+ # Key assertion: rating before M3 should equal rating before M2
883
+ # because null performance in M2 means NO rating change
884
+ assert team_a_rating_before_m3 == team_a_rating_before_m2, (
885
+ f"team_a's rating changed after null performance game! "
886
+ f"Before M2={team_a_rating_before_m2}, Before M3={team_a_rating_before_m3}"
887
+ )
888
+
889
+ # Also verify null is not treated as 0.0 by comparing with explicit 0.0
890
+ # Use 0.3 instead of 0.0 to keep mean in valid range
891
+ df_with_low_perf = df.with_columns(
892
+ pl.when((pl.col("team_id") == "team_a") & (pl.col("match_id") == 2))
893
+ .then(0.3) # Low performance (below predicted ~0.5) causes rating drop
894
+ .otherwise(pl.col("won"))
895
+ .alias("won")
896
+ )
897
+
898
+ gen_low = TeamRatingGenerator(
899
+ performance_column="won",
900
+ column_names=column_names,
901
+ start_team_rating=1000.0,
902
+ confidence_weight=0.0,
903
+ output_suffix="",
904
+ features_out=[RatingKnownFeatures.TEAM_OFF_RATING_PROJECTED],
905
+ )
906
+ result_low = gen_low.fit_transform(df_with_low_perf)
907
+
908
+ team_a_rating_before_m3_with_low = result_low.filter(
909
+ (pl.col("team_id") == "team_a") & (pl.col("match_id") == 3)
910
+ )["team_off_rating_projected"][0]
911
+
912
+ # With low perf (0.3), rating should drop (different from null which has no change)
913
+ assert team_a_rating_before_m3 > team_a_rating_before_m3_with_low, (
914
+ f"Null performance is being treated as low performance! "
915
+ f"Rating with null={team_a_rating_before_m3}, rating with low perf={team_a_rating_before_m3_with_low}"
916
+ )
917
+
918
+
919
+ def test_transform_when_auto_scale_performance_then_uses_correct_column(column_names):
920
+ """
921
+ When auto_scale_performance=True, the performance manager renames the column
922
+ (e.g., 'won' -> 'performance__won'). Transform should still work by applying
923
+ the performance manager to transform the input data.
924
+
925
+ Bug: Currently transform doesn't apply the performance manager, causing
926
+ a column mismatch where it looks for 'performance__won' but data has 'won'.
927
+ This results in None being returned and defaulting to 0.0 performance.
928
+ """
929
+ generator = TeamRatingGenerator(
930
+ performance_column="won",
931
+ column_names=column_names,
932
+ start_team_rating=1000.0,
933
+ confidence_weight=0.0,
934
+ output_suffix="",
935
+ auto_scale_performance=True,
936
+ )
937
+
938
+ # fit_transform with valid performance values
939
+ fit_df = pl.DataFrame(
940
+ {
941
+ "match_id": [1, 1],
942
+ "team_id": ["team_a", "team_b"],
943
+ "start_date": [datetime(2024, 1, 1), datetime(2024, 1, 1)],
944
+ "won": [0.6, 0.4],
945
+ }
946
+ )
947
+ generator.fit_transform(fit_df)
948
+
949
+ # After fit_transform, performance_column is changed to 'performance__won'
950
+ assert generator.performance_column == "performance__won", (
951
+ f"Expected performance_column to be 'performance__won' but got '{generator.performance_column}'"
952
+ )
953
+
954
+ team_a_rating_before = generator._team_off_ratings["team_a"].rating_value
955
+
956
+ # transform with same format data (original column name 'won')
957
+ # team_a has good performance (0.6 > predicted ~0.5), so rating should INCREASE
958
+ transform_df = pl.DataFrame(
959
+ {
960
+ "match_id": [2, 2],
961
+ "team_id": ["team_a", "team_b"],
962
+ "start_date": [datetime(2024, 1, 2), datetime(2024, 1, 2)],
963
+ "won": [0.6, 0.4], # Original column name, not 'performance__won'
964
+ }
965
+ )
966
+
967
+ generator.transform(transform_df)
968
+
969
+ team_a_rating_after = generator._team_off_ratings["team_a"].rating_value
970
+
971
+ # With 0.6 performance (above predicted ~0.5), rating should INCREASE
972
+ # Bug: column mismatch causes perf to default to 0.0, making rating DECREASE
973
+ assert team_a_rating_after > team_a_rating_before, (
974
+ f"Rating should increase with good performance (0.6), but it went from "
975
+ f"{team_a_rating_before} to {team_a_rating_after}. This indicates transform "
976
+ f"is not finding the performance column (looking for '{generator.performance_column}' "
977
+ f"but data has 'won') and defaulting to 0.0 performance."
978
+ )
979
+
980
+
838
981
  @pytest.mark.parametrize("confidence_weight", [0.0, 0.5, 1.0])
839
982
  def test_fit_transform_when_confidence_weight_varies_then_new_teams_have_different_rating_changes(
840
983
  column_names, confidence_weight
@@ -1283,12 +1426,11 @@ def test_transform_when_called_after_fit_transform_then_uses_updated_ratings(
1283
1426
  assert team_a_row["team_off_rating_projected"][0] == pytest.approx(team_a_rating_after_first)
1284
1427
 
1285
1428
 
1286
- def test_transform_when_called_without_performance_column_then_defaults_to_zero(column_names):
1429
+ def test_transform_when_called_without_performance_column_then_no_rating_change(column_names):
1287
1430
  """
1288
1431
  When transform is called without performance column, then we should expect to see
1289
- it works but defaults performance to 0.0 because _calculate_ratings uses
1290
- r.get(self.performance_column) which returns None, defaulting to 0.0.
1291
- This means ratings will be updated as if teams lost (performance=0.0).
1432
+ ratings remain unchanged because null/missing performance means no rating update
1433
+ (not treated as 0.0 which would cause a rating drop).
1292
1434
  """
1293
1435
  generator = TeamRatingGenerator(
1294
1436
  performance_column="won",
@@ -1321,7 +1463,8 @@ def test_transform_when_called_without_performance_column_then_defaults_to_zero(
1321
1463
  generator.transform(df)
1322
1464
  team_a_rating_after = generator._team_off_ratings["team_a"].rating_value
1323
1465
 
1324
- assert team_a_rating_after < team_a_rating_before
1466
+ # Null/missing performance means no rating change
1467
+ assert team_a_rating_after == team_a_rating_before
1325
1468
 
1326
1469
 
1327
1470
  def test_future_transform_when_called_then_ratings_not_updated(basic_rating_generator):
@@ -1539,14 +1682,13 @@ def test_transform_vs_future_transform_when_same_match_then_transform_updates_ra
1539
1682
  assert team_a_rating_after_future == team_a_rating_before_2
1540
1683
 
1541
1684
 
1542
- def test_transform_vs_future_transform_when_performance_column_missing_then_both_work_but_transform_defaults_performance(
1685
+ def test_transform_vs_future_transform_when_performance_column_missing_then_both_work_with_no_rating_change(
1543
1686
  column_names,
1544
1687
  ):
1545
1688
  """
1546
1689
  When performance column is missing, then we should expect to see
1547
- both future_transform and transform work, but transform defaults performance
1548
- to 0.0 (treating it as a loss), while future_transform doesn't need performance
1549
- at all since it only computes predictions.
1690
+ both future_transform and transform work, and both result in no rating change
1691
+ because null/missing performance means no update (not treated as 0.0).
1550
1692
  """
1551
1693
  generator = TeamRatingGenerator(
1552
1694
  performance_column="won",
@@ -1594,8 +1736,9 @@ def test_transform_vs_future_transform_when_performance_column_missing_then_both
1594
1736
  result_transform = generator.transform(transform_df)
1595
1737
  assert result_transform is not None
1596
1738
 
1739
+ # Null/missing performance means no rating change
1597
1740
  team_a_rating_after_transform = generator._team_off_ratings["team_a"].rating_value
1598
- assert team_a_rating_after_transform < team_a_rating_before
1741
+ assert team_a_rating_after_transform == team_a_rating_before
1599
1742
 
1600
1743
 
1601
1744
  def test_transform_vs_future_transform_when_games_played_then_transform_increments_but_future_transform_does_not(
@@ -1878,7 +2021,6 @@ def test_transform_when_date_formats_vary_then_processes_successfully(column_nam
1878
2021
  start_team_rating=1000.0,
1879
2022
  confidence_weight=0.0,
1880
2023
  output_suffix="",
1881
- auto_scale_performance=True,
1882
2024
  )
1883
2025
 
1884
2026
  fit_df = pl.DataFrame(