spforge 0.8.20__tar.gz → 0.8.23__tar.gz

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 (119) hide show
  1. {spforge-0.8.20/spforge.egg-info → spforge-0.8.23}/PKG-INFO +1 -1
  2. {spforge-0.8.20 → spforge-0.8.23}/pyproject.toml +1 -1
  3. {spforge-0.8.20 → spforge-0.8.23}/spforge/ratings/_base.py +6 -0
  4. {spforge-0.8.20 → spforge-0.8.23}/spforge/ratings/_player_rating.py +40 -27
  5. {spforge-0.8.20 → spforge-0.8.23}/spforge/ratings/_team_rating.py +23 -20
  6. {spforge-0.8.20 → spforge-0.8.23/spforge.egg-info}/PKG-INFO +1 -1
  7. {spforge-0.8.20 → spforge-0.8.23}/tests/ratings/test_player_rating_generator.py +149 -118
  8. {spforge-0.8.20 → spforge-0.8.23}/tests/ratings/test_team_rating_generator.py +153 -11
  9. {spforge-0.8.20 → spforge-0.8.23}/LICENSE +0 -0
  10. {spforge-0.8.20 → spforge-0.8.23}/MANIFEST.in +0 -0
  11. {spforge-0.8.20 → spforge-0.8.23}/README.md +0 -0
  12. {spforge-0.8.20 → spforge-0.8.23}/examples/__init__.py +0 -0
  13. {spforge-0.8.20 → spforge-0.8.23}/examples/game_level_example.py +0 -0
  14. {spforge-0.8.20 → spforge-0.8.23}/examples/lol/__init__.py +0 -0
  15. {spforge-0.8.20 → spforge-0.8.23}/examples/lol/data/__init__.py +0 -0
  16. {spforge-0.8.20 → spforge-0.8.23}/examples/lol/data/subsample_lol_data.parquet +0 -0
  17. {spforge-0.8.20 → spforge-0.8.23}/examples/lol/data/utils.py +0 -0
  18. {spforge-0.8.20 → spforge-0.8.23}/examples/lol/pipeline_transformer_example.py +0 -0
  19. {spforge-0.8.20 → spforge-0.8.23}/examples/nba/__init__.py +0 -0
  20. {spforge-0.8.20 → spforge-0.8.23}/examples/nba/cross_validation_example.py +0 -0
  21. {spforge-0.8.20 → spforge-0.8.23}/examples/nba/data/__init__.py +0 -0
  22. {spforge-0.8.20 → spforge-0.8.23}/examples/nba/data/game_player_subsample.parquet +0 -0
  23. {spforge-0.8.20 → spforge-0.8.23}/examples/nba/data/utils.py +0 -0
  24. {spforge-0.8.20 → spforge-0.8.23}/examples/nba/feature_engineering_example.py +0 -0
  25. {spforge-0.8.20 → spforge-0.8.23}/examples/nba/game_winner_example.py +0 -0
  26. {spforge-0.8.20 → spforge-0.8.23}/examples/nba/predictor_transformers_example.py +0 -0
  27. {spforge-0.8.20 → spforge-0.8.23}/setup.cfg +0 -0
  28. {spforge-0.8.20 → spforge-0.8.23}/spforge/__init__.py +0 -0
  29. {spforge-0.8.20 → spforge-0.8.23}/spforge/autopipeline.py +0 -0
  30. {spforge-0.8.20 → spforge-0.8.23}/spforge/base_feature_generator.py +0 -0
  31. {spforge-0.8.20 → spforge-0.8.23}/spforge/cross_validator/__init__.py +0 -0
  32. {spforge-0.8.20 → spforge-0.8.23}/spforge/cross_validator/_base.py +0 -0
  33. {spforge-0.8.20 → spforge-0.8.23}/spforge/cross_validator/cross_validator.py +0 -0
  34. {spforge-0.8.20 → spforge-0.8.23}/spforge/data_structures.py +0 -0
  35. {spforge-0.8.20 → spforge-0.8.23}/spforge/distributions/__init__.py +0 -0
  36. {spforge-0.8.20 → spforge-0.8.23}/spforge/distributions/_negative_binomial_estimator.py +0 -0
  37. {spforge-0.8.20 → spforge-0.8.23}/spforge/distributions/_normal_distribution_predictor.py +0 -0
  38. {spforge-0.8.20 → spforge-0.8.23}/spforge/distributions/_student_t_distribution_estimator.py +0 -0
  39. {spforge-0.8.20 → spforge-0.8.23}/spforge/estimator/__init__.py +0 -0
  40. {spforge-0.8.20 → spforge-0.8.23}/spforge/estimator/_conditional_estimator.py +0 -0
  41. {spforge-0.8.20 → spforge-0.8.23}/spforge/estimator/_frequency_bucketing_classifier.py +0 -0
  42. {spforge-0.8.20 → spforge-0.8.23}/spforge/estimator/_granularity_estimator.py +0 -0
  43. {spforge-0.8.20 → spforge-0.8.23}/spforge/estimator/_group_by_estimator.py +0 -0
  44. {spforge-0.8.20 → spforge-0.8.23}/spforge/estimator/_ordinal_classifier.py +0 -0
  45. {spforge-0.8.20 → spforge-0.8.23}/spforge/estimator/_sklearn_enhancer_estimator.py +0 -0
  46. {spforge-0.8.20 → spforge-0.8.23}/spforge/feature_generator/__init__.py +0 -0
  47. {spforge-0.8.20 → spforge-0.8.23}/spforge/feature_generator/_base.py +0 -0
  48. {spforge-0.8.20 → spforge-0.8.23}/spforge/feature_generator/_lag.py +0 -0
  49. {spforge-0.8.20 → spforge-0.8.23}/spforge/feature_generator/_net_over_predicted.py +0 -0
  50. {spforge-0.8.20 → spforge-0.8.23}/spforge/feature_generator/_regressor_feature_generator.py +0 -0
  51. {spforge-0.8.20 → spforge-0.8.23}/spforge/feature_generator/_rolling_against_opponent.py +0 -0
  52. {spforge-0.8.20 → spforge-0.8.23}/spforge/feature_generator/_rolling_mean_binary.py +0 -0
  53. {spforge-0.8.20 → spforge-0.8.23}/spforge/feature_generator/_rolling_mean_days.py +0 -0
  54. {spforge-0.8.20 → spforge-0.8.23}/spforge/feature_generator/_rolling_window.py +0 -0
  55. {spforge-0.8.20 → spforge-0.8.23}/spforge/feature_generator/_utils.py +0 -0
  56. {spforge-0.8.20 → spforge-0.8.23}/spforge/features_generator_pipeline.py +0 -0
  57. {spforge-0.8.20 → spforge-0.8.23}/spforge/hyperparameter_tuning/__init__.py +0 -0
  58. {spforge-0.8.20 → spforge-0.8.23}/spforge/hyperparameter_tuning/_default_search_spaces.py +0 -0
  59. {spforge-0.8.20 → spforge-0.8.23}/spforge/hyperparameter_tuning/_tuner.py +0 -0
  60. {spforge-0.8.20 → spforge-0.8.23}/spforge/performance_transformers/__init__.py +0 -0
  61. {spforge-0.8.20 → spforge-0.8.23}/spforge/performance_transformers/_performance_manager.py +0 -0
  62. {spforge-0.8.20 → spforge-0.8.23}/spforge/performance_transformers/_performances_transformers.py +0 -0
  63. {spforge-0.8.20 → spforge-0.8.23}/spforge/ratings/__init__.py +0 -0
  64. {spforge-0.8.20 → spforge-0.8.23}/spforge/ratings/enums.py +0 -0
  65. {spforge-0.8.20 → spforge-0.8.23}/spforge/ratings/league_identifier.py +0 -0
  66. {spforge-0.8.20 → spforge-0.8.23}/spforge/ratings/league_start_rating_optimizer.py +0 -0
  67. {spforge-0.8.20 → spforge-0.8.23}/spforge/ratings/player_performance_predictor.py +0 -0
  68. {spforge-0.8.20 → spforge-0.8.23}/spforge/ratings/start_rating_generator.py +0 -0
  69. {spforge-0.8.20 → spforge-0.8.23}/spforge/ratings/team_performance_predictor.py +0 -0
  70. {spforge-0.8.20 → spforge-0.8.23}/spforge/ratings/team_start_rating_generator.py +0 -0
  71. {spforge-0.8.20 → spforge-0.8.23}/spforge/ratings/utils.py +0 -0
  72. {spforge-0.8.20 → spforge-0.8.23}/spforge/scorer/__init__.py +0 -0
  73. {spforge-0.8.20 → spforge-0.8.23}/spforge/scorer/_score.py +0 -0
  74. {spforge-0.8.20 → spforge-0.8.23}/spforge/transformers/__init__.py +0 -0
  75. {spforge-0.8.20 → spforge-0.8.23}/spforge/transformers/_base.py +0 -0
  76. {spforge-0.8.20 → spforge-0.8.23}/spforge/transformers/_net_over_predicted.py +0 -0
  77. {spforge-0.8.20 → spforge-0.8.23}/spforge/transformers/_operator.py +0 -0
  78. {spforge-0.8.20 → spforge-0.8.23}/spforge/transformers/_other_transformer.py +0 -0
  79. {spforge-0.8.20 → spforge-0.8.23}/spforge/transformers/_predictor.py +0 -0
  80. {spforge-0.8.20 → spforge-0.8.23}/spforge/transformers/_simple_transformer.py +0 -0
  81. {spforge-0.8.20 → spforge-0.8.23}/spforge/transformers/_team_ratio_predictor.py +0 -0
  82. {spforge-0.8.20 → spforge-0.8.23}/spforge/utils.py +0 -0
  83. {spforge-0.8.20 → spforge-0.8.23}/spforge.egg-info/SOURCES.txt +0 -0
  84. {spforge-0.8.20 → spforge-0.8.23}/spforge.egg-info/dependency_links.txt +0 -0
  85. {spforge-0.8.20 → spforge-0.8.23}/spforge.egg-info/requires.txt +0 -0
  86. {spforge-0.8.20 → spforge-0.8.23}/spforge.egg-info/top_level.txt +0 -0
  87. {spforge-0.8.20 → spforge-0.8.23}/tests/cross_validator/test_cross_validator.py +0 -0
  88. {spforge-0.8.20 → spforge-0.8.23}/tests/distributions/test_distribution.py +0 -0
  89. {spforge-0.8.20 → spforge-0.8.23}/tests/end_to_end/test_estimator_hyperparameter_tuning.py +0 -0
  90. {spforge-0.8.20 → spforge-0.8.23}/tests/end_to_end/test_league_start_rating_optimizer.py +0 -0
  91. {spforge-0.8.20 → spforge-0.8.23}/tests/end_to_end/test_lol_player_kills.py +0 -0
  92. {spforge-0.8.20 → spforge-0.8.23}/tests/end_to_end/test_nba_player_points.py +0 -0
  93. {spforge-0.8.20 → spforge-0.8.23}/tests/end_to_end/test_nba_player_ratings_hyperparameter_tuning.py +0 -0
  94. {spforge-0.8.20 → spforge-0.8.23}/tests/end_to_end/test_nba_prediction_consistency.py +0 -0
  95. {spforge-0.8.20 → spforge-0.8.23}/tests/estimator/test_sklearn_estimator.py +0 -0
  96. {spforge-0.8.20 → spforge-0.8.23}/tests/feature_generator/test_lag.py +0 -0
  97. {spforge-0.8.20 → spforge-0.8.23}/tests/feature_generator/test_regressor_feature_generator.py +0 -0
  98. {spforge-0.8.20 → spforge-0.8.23}/tests/feature_generator/test_rolling_against_opponent.py +0 -0
  99. {spforge-0.8.20 → spforge-0.8.23}/tests/feature_generator/test_rolling_mean_binary.py +0 -0
  100. {spforge-0.8.20 → spforge-0.8.23}/tests/feature_generator/test_rolling_mean_days.py +0 -0
  101. {spforge-0.8.20 → spforge-0.8.23}/tests/feature_generator/test_rolling_window.py +0 -0
  102. {spforge-0.8.20 → spforge-0.8.23}/tests/hyperparameter_tuning/test_estimator_tuner.py +0 -0
  103. {spforge-0.8.20 → spforge-0.8.23}/tests/hyperparameter_tuning/test_rating_tuner.py +0 -0
  104. {spforge-0.8.20 → spforge-0.8.23}/tests/performance_transformers/test_performance_manager.py +0 -0
  105. {spforge-0.8.20 → spforge-0.8.23}/tests/performance_transformers/test_performances_transformers.py +0 -0
  106. {spforge-0.8.20 → spforge-0.8.23}/tests/ratings/test_player_rating_no_mutation.py +0 -0
  107. {spforge-0.8.20 → spforge-0.8.23}/tests/ratings/test_ratings_property.py +0 -0
  108. {spforge-0.8.20 → spforge-0.8.23}/tests/ratings/test_utils_scaled_weights.py +0 -0
  109. {spforge-0.8.20 → spforge-0.8.23}/tests/scorer/test_score.py +0 -0
  110. {spforge-0.8.20 → spforge-0.8.23}/tests/scorer/test_score_aggregation_granularity.py +0 -0
  111. {spforge-0.8.20 → spforge-0.8.23}/tests/test_autopipeline.py +0 -0
  112. {spforge-0.8.20 → spforge-0.8.23}/tests/test_autopipeline_context.py +0 -0
  113. {spforge-0.8.20 → spforge-0.8.23}/tests/test_feature_generator_pipeline.py +0 -0
  114. {spforge-0.8.20 → spforge-0.8.23}/tests/transformers/test_estimator_transformer_context.py +0 -0
  115. {spforge-0.8.20 → spforge-0.8.23}/tests/transformers/test_net_over_predicted.py +0 -0
  116. {spforge-0.8.20 → spforge-0.8.23}/tests/transformers/test_other_transformer.py +0 -0
  117. {spforge-0.8.20 → spforge-0.8.23}/tests/transformers/test_predictor_transformer.py +0 -0
  118. {spforge-0.8.20 → spforge-0.8.23}/tests/transformers/test_simple_transformer.py +0 -0
  119. {spforge-0.8.20 → spforge-0.8.23}/tests/transformers/test_team_ratio_predictor.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: spforge
3
- Version: 0.8.20
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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "spforge"
7
- version = "0.8.20"
7
+ version = "0.8.23"
8
8
  description = "A flexible framework for generating features, ratings, and building machine learning or other models for training and inference on sports data."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -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
@@ -487,23 +494,25 @@ class PlayerRatingGenerator(RatingGenerator):
487
494
  perf_value = pre_player.match_performance.performance_value
488
495
  if perf_value is None:
489
496
  off_change = 0.0
490
- def_change = 0.0
491
497
  else:
492
498
  off_perf = float(perf_value)
493
- def_perf = float(team1_def_perf)
494
-
495
- if not self.use_off_def_split:
496
- pred_def = pred_off
497
- def_perf = off_perf
498
-
499
499
  mult_off = self._applied_multiplier_off(off_state)
500
- mult_def = self._applied_multiplier_def(def_state)
501
-
502
500
  off_change = (
503
501
  (off_perf - float(pred_off))
504
502
  * mult_off
505
503
  * float(pre_player.match_performance.participation_weight)
506
504
  )
505
+
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)
510
+
511
+ if not self.use_off_def_split:
512
+ pred_def = pred_off
513
+ def_perf = float(perf_value)
514
+
515
+ mult_def = self._applied_multiplier_def(def_state)
507
516
  def_change = (
508
517
  (def_perf - float(pred_def))
509
518
  * mult_def
@@ -570,33 +579,35 @@ class PlayerRatingGenerator(RatingGenerator):
570
579
  perf_value = pre_player.match_performance.performance_value
571
580
  if perf_value is None:
572
581
  off_change = 0.0
573
- def_change = 0.0
574
582
  else:
575
583
  off_perf = float(perf_value)
576
- def_perf = float(team2_def_perf)
577
-
578
- if not self.use_off_def_split:
579
- pred_def = pred_off
580
- def_perf = off_perf
581
-
582
584
  mult_off = self._applied_multiplier_off(off_state)
583
- mult_def = self._applied_multiplier_def(def_state)
584
-
585
585
  off_change = (
586
586
  (off_perf - float(pred_off))
587
587
  * mult_off
588
588
  * float(pre_player.match_performance.participation_weight)
589
589
  )
590
+
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)
595
+
596
+ if not self.use_off_def_split:
597
+ pred_def = pred_off
598
+ def_perf = float(perf_value)
599
+
600
+ mult_def = self._applied_multiplier_def(def_state)
590
601
  def_change = (
591
602
  (def_perf - float(pred_def))
592
603
  * mult_def
593
604
  * float(pre_player.match_performance.participation_weight)
594
605
  )
595
606
 
596
- if math.isnan(off_change) or math.isnan(def_change):
597
- raise ValueError(
598
- f"NaN player rating change for player_id={pid}, match_id={r[cn.match_id]}"
599
- )
607
+ if math.isnan(off_change) or math.isnan(def_change):
608
+ raise ValueError(
609
+ f"NaN player rating change for player_id={pid}, match_id={r[cn.match_id]}"
610
+ )
600
611
 
601
612
  player_updates.append(
602
613
  (
@@ -1031,12 +1042,14 @@ class PlayerRatingGenerator(RatingGenerator):
1031
1042
 
1032
1043
  return pre_match_player_ratings, pre_match_player_off_values
1033
1044
 
1034
- def _team_off_perf_from_collection(self, c: PreMatchPlayersCollection) -> float:
1045
+ def _team_off_perf_from_collection(
1046
+ self, c: PreMatchPlayersCollection
1047
+ ) -> float | None:
1035
1048
  # observed offense perf = weighted mean of player performance_value using participation_weight if present
1036
1049
  # skip players with null performance
1037
1050
  cn = self.column_names
1038
1051
  if not c.pre_match_player_ratings:
1039
- return 0.0
1052
+ return None
1040
1053
  wsum = 0.0
1041
1054
  psum = 0.0
1042
1055
  for pre in c.pre_match_player_ratings:
@@ -1050,7 +1063,7 @@ class PlayerRatingGenerator(RatingGenerator):
1050
1063
  )
1051
1064
  psum += float(perf_val) * w
1052
1065
  wsum += w
1053
- return psum / wsum if wsum else 0.0
1066
+ return psum / wsum if wsum else None
1054
1067
 
1055
1068
  def _team_off_def_rating_from_collection(
1056
1069
  self, c: PreMatchPlayersCollection
@@ -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.20
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
@@ -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,110 @@ 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
+ )
@@ -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(
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes