spforge 0.8.35__tar.gz → 0.8.37__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 (120) hide show
  1. {spforge-0.8.35/spforge.egg-info → spforge-0.8.37}/PKG-INFO +1 -1
  2. {spforge-0.8.35 → spforge-0.8.37}/pyproject.toml +1 -1
  3. {spforge-0.8.35 → spforge-0.8.37}/spforge/performance_transformers/_performance_manager.py +4 -4
  4. {spforge-0.8.35 → spforge-0.8.37}/spforge/ratings/_base.py +13 -10
  5. {spforge-0.8.35 → spforge-0.8.37}/spforge/ratings/_player_rating.py +14 -7
  6. {spforge-0.8.35 → spforge-0.8.37/spforge.egg-info}/PKG-INFO +1 -1
  7. {spforge-0.8.35 → spforge-0.8.37}/tests/performance_transformers/test_performance_manager.py +98 -2
  8. {spforge-0.8.35 → spforge-0.8.37}/tests/ratings/test_player_rating_generator.py +138 -0
  9. {spforge-0.8.35 → spforge-0.8.37}/LICENSE +0 -0
  10. {spforge-0.8.35 → spforge-0.8.37}/MANIFEST.in +0 -0
  11. {spforge-0.8.35 → spforge-0.8.37}/README.md +0 -0
  12. {spforge-0.8.35 → spforge-0.8.37}/examples/__init__.py +0 -0
  13. {spforge-0.8.35 → spforge-0.8.37}/examples/game_level_example.py +0 -0
  14. {spforge-0.8.35 → spforge-0.8.37}/examples/lol/__init__.py +0 -0
  15. {spforge-0.8.35 → spforge-0.8.37}/examples/lol/data/__init__.py +0 -0
  16. {spforge-0.8.35 → spforge-0.8.37}/examples/lol/data/subsample_lol_data.parquet +0 -0
  17. {spforge-0.8.35 → spforge-0.8.37}/examples/lol/data/utils.py +0 -0
  18. {spforge-0.8.35 → spforge-0.8.37}/examples/lol/pipeline_transformer_example.py +0 -0
  19. {spforge-0.8.35 → spforge-0.8.37}/examples/nba/__init__.py +0 -0
  20. {spforge-0.8.35 → spforge-0.8.37}/examples/nba/cross_validation_example.py +0 -0
  21. {spforge-0.8.35 → spforge-0.8.37}/examples/nba/data/__init__.py +0 -0
  22. {spforge-0.8.35 → spforge-0.8.37}/examples/nba/data/game_player_subsample.parquet +0 -0
  23. {spforge-0.8.35 → spforge-0.8.37}/examples/nba/data/utils.py +0 -0
  24. {spforge-0.8.35 → spforge-0.8.37}/examples/nba/feature_engineering_example.py +0 -0
  25. {spforge-0.8.35 → spforge-0.8.37}/examples/nba/game_winner_example.py +0 -0
  26. {spforge-0.8.35 → spforge-0.8.37}/examples/nba/predictor_transformers_example.py +0 -0
  27. {spforge-0.8.35 → spforge-0.8.37}/setup.cfg +0 -0
  28. {spforge-0.8.35 → spforge-0.8.37}/spforge/__init__.py +0 -0
  29. {spforge-0.8.35 → spforge-0.8.37}/spforge/autopipeline.py +0 -0
  30. {spforge-0.8.35 → spforge-0.8.37}/spforge/base_feature_generator.py +0 -0
  31. {spforge-0.8.35 → spforge-0.8.37}/spforge/cross_validator/__init__.py +0 -0
  32. {spforge-0.8.35 → spforge-0.8.37}/spforge/cross_validator/_base.py +0 -0
  33. {spforge-0.8.35 → spforge-0.8.37}/spforge/cross_validator/cross_validator.py +0 -0
  34. {spforge-0.8.35 → spforge-0.8.37}/spforge/data_structures.py +0 -0
  35. {spforge-0.8.35 → spforge-0.8.37}/spforge/distributions/__init__.py +0 -0
  36. {spforge-0.8.35 → spforge-0.8.37}/spforge/distributions/_negative_binomial_estimator.py +0 -0
  37. {spforge-0.8.35 → spforge-0.8.37}/spforge/distributions/_normal_distribution_predictor.py +0 -0
  38. {spforge-0.8.35 → spforge-0.8.37}/spforge/distributions/_student_t_distribution_estimator.py +0 -0
  39. {spforge-0.8.35 → spforge-0.8.37}/spforge/estimator/__init__.py +0 -0
  40. {spforge-0.8.35 → spforge-0.8.37}/spforge/estimator/_conditional_estimator.py +0 -0
  41. {spforge-0.8.35 → spforge-0.8.37}/spforge/estimator/_frequency_bucketing_classifier.py +0 -0
  42. {spforge-0.8.35 → spforge-0.8.37}/spforge/estimator/_granularity_estimator.py +0 -0
  43. {spforge-0.8.35 → spforge-0.8.37}/spforge/estimator/_group_by_estimator.py +0 -0
  44. {spforge-0.8.35 → spforge-0.8.37}/spforge/estimator/_ordinal_classifier.py +0 -0
  45. {spforge-0.8.35 → spforge-0.8.37}/spforge/estimator/_sklearn_enhancer_estimator.py +0 -0
  46. {spforge-0.8.35 → spforge-0.8.37}/spforge/feature_generator/__init__.py +0 -0
  47. {spforge-0.8.35 → spforge-0.8.37}/spforge/feature_generator/_base.py +0 -0
  48. {spforge-0.8.35 → spforge-0.8.37}/spforge/feature_generator/_lag.py +0 -0
  49. {spforge-0.8.35 → spforge-0.8.37}/spforge/feature_generator/_net_over_predicted.py +0 -0
  50. {spforge-0.8.35 → spforge-0.8.37}/spforge/feature_generator/_regressor_feature_generator.py +0 -0
  51. {spforge-0.8.35 → spforge-0.8.37}/spforge/feature_generator/_rolling_against_opponent.py +0 -0
  52. {spforge-0.8.35 → spforge-0.8.37}/spforge/feature_generator/_rolling_mean_binary.py +0 -0
  53. {spforge-0.8.35 → spforge-0.8.37}/spforge/feature_generator/_rolling_mean_days.py +0 -0
  54. {spforge-0.8.35 → spforge-0.8.37}/spforge/feature_generator/_rolling_window.py +0 -0
  55. {spforge-0.8.35 → spforge-0.8.37}/spforge/feature_generator/_utils.py +0 -0
  56. {spforge-0.8.35 → spforge-0.8.37}/spforge/features_generator_pipeline.py +0 -0
  57. {spforge-0.8.35 → spforge-0.8.37}/spforge/hyperparameter_tuning/__init__.py +0 -0
  58. {spforge-0.8.35 → spforge-0.8.37}/spforge/hyperparameter_tuning/_default_search_spaces.py +0 -0
  59. {spforge-0.8.35 → spforge-0.8.37}/spforge/hyperparameter_tuning/_tuner.py +0 -0
  60. {spforge-0.8.35 → spforge-0.8.37}/spforge/performance_transformers/__init__.py +0 -0
  61. {spforge-0.8.35 → spforge-0.8.37}/spforge/performance_transformers/_performances_transformers.py +0 -0
  62. {spforge-0.8.35 → spforge-0.8.37}/spforge/ratings/__init__.py +0 -0
  63. {spforge-0.8.35 → spforge-0.8.37}/spforge/ratings/_team_rating.py +0 -0
  64. {spforge-0.8.35 → spforge-0.8.37}/spforge/ratings/enums.py +0 -0
  65. {spforge-0.8.35 → spforge-0.8.37}/spforge/ratings/league_identifier.py +0 -0
  66. {spforge-0.8.35 → spforge-0.8.37}/spforge/ratings/league_start_rating_optimizer.py +0 -0
  67. {spforge-0.8.35 → spforge-0.8.37}/spforge/ratings/player_performance_predictor.py +0 -0
  68. {spforge-0.8.35 → spforge-0.8.37}/spforge/ratings/start_rating_generator.py +0 -0
  69. {spforge-0.8.35 → spforge-0.8.37}/spforge/ratings/team_performance_predictor.py +0 -0
  70. {spforge-0.8.35 → spforge-0.8.37}/spforge/ratings/team_start_rating_generator.py +0 -0
  71. {spforge-0.8.35 → spforge-0.8.37}/spforge/ratings/utils.py +0 -0
  72. {spforge-0.8.35 → spforge-0.8.37}/spforge/scorer/__init__.py +0 -0
  73. {spforge-0.8.35 → spforge-0.8.37}/spforge/scorer/_score.py +0 -0
  74. {spforge-0.8.35 → spforge-0.8.37}/spforge/transformers/__init__.py +0 -0
  75. {spforge-0.8.35 → spforge-0.8.37}/spforge/transformers/_base.py +0 -0
  76. {spforge-0.8.35 → spforge-0.8.37}/spforge/transformers/_net_over_predicted.py +0 -0
  77. {spforge-0.8.35 → spforge-0.8.37}/spforge/transformers/_operator.py +0 -0
  78. {spforge-0.8.35 → spforge-0.8.37}/spforge/transformers/_other_transformer.py +0 -0
  79. {spforge-0.8.35 → spforge-0.8.37}/spforge/transformers/_predictor.py +0 -0
  80. {spforge-0.8.35 → spforge-0.8.37}/spforge/transformers/_simple_transformer.py +0 -0
  81. {spforge-0.8.35 → spforge-0.8.37}/spforge/transformers/_team_ratio_predictor.py +0 -0
  82. {spforge-0.8.35 → spforge-0.8.37}/spforge/utils.py +0 -0
  83. {spforge-0.8.35 → spforge-0.8.37}/spforge.egg-info/SOURCES.txt +0 -0
  84. {spforge-0.8.35 → spforge-0.8.37}/spforge.egg-info/dependency_links.txt +0 -0
  85. {spforge-0.8.35 → spforge-0.8.37}/spforge.egg-info/requires.txt +0 -0
  86. {spforge-0.8.35 → spforge-0.8.37}/spforge.egg-info/top_level.txt +0 -0
  87. {spforge-0.8.35 → spforge-0.8.37}/tests/cross_validator/test_cross_validator.py +0 -0
  88. {spforge-0.8.35 → spforge-0.8.37}/tests/distributions/test_distribution.py +0 -0
  89. {spforge-0.8.35 → spforge-0.8.37}/tests/end_to_end/test_estimator_hyperparameter_tuning.py +0 -0
  90. {spforge-0.8.35 → spforge-0.8.37}/tests/end_to_end/test_league_start_rating_optimizer.py +0 -0
  91. {spforge-0.8.35 → spforge-0.8.37}/tests/end_to_end/test_lol_player_kills.py +0 -0
  92. {spforge-0.8.35 → spforge-0.8.37}/tests/end_to_end/test_nba_player_points.py +0 -0
  93. {spforge-0.8.35 → spforge-0.8.37}/tests/end_to_end/test_nba_player_ratings_hyperparameter_tuning.py +0 -0
  94. {spforge-0.8.35 → spforge-0.8.37}/tests/end_to_end/test_nba_prediction_consistency.py +0 -0
  95. {spforge-0.8.35 → spforge-0.8.37}/tests/estimator/test_sklearn_estimator.py +0 -0
  96. {spforge-0.8.35 → spforge-0.8.37}/tests/feature_generator/test_lag.py +0 -0
  97. {spforge-0.8.35 → spforge-0.8.37}/tests/feature_generator/test_regressor_feature_generator.py +0 -0
  98. {spforge-0.8.35 → spforge-0.8.37}/tests/feature_generator/test_rolling_against_opponent.py +0 -0
  99. {spforge-0.8.35 → spforge-0.8.37}/tests/feature_generator/test_rolling_mean_binary.py +0 -0
  100. {spforge-0.8.35 → spforge-0.8.37}/tests/feature_generator/test_rolling_mean_days.py +0 -0
  101. {spforge-0.8.35 → spforge-0.8.37}/tests/feature_generator/test_rolling_window.py +0 -0
  102. {spforge-0.8.35 → spforge-0.8.37}/tests/hyperparameter_tuning/test_estimator_tuner.py +0 -0
  103. {spforge-0.8.35 → spforge-0.8.37}/tests/hyperparameter_tuning/test_rating_tuner.py +0 -0
  104. {spforge-0.8.35 → spforge-0.8.37}/tests/performance_transformers/test_performances_transformers.py +0 -0
  105. {spforge-0.8.35 → spforge-0.8.37}/tests/ratings/test_player_rating_no_mutation.py +0 -0
  106. {spforge-0.8.35 → spforge-0.8.37}/tests/ratings/test_ratings_property.py +0 -0
  107. {spforge-0.8.35 → spforge-0.8.37}/tests/ratings/test_team_rating_generator.py +0 -0
  108. {spforge-0.8.35 → spforge-0.8.37}/tests/ratings/test_utils_scaled_weights.py +0 -0
  109. {spforge-0.8.35 → spforge-0.8.37}/tests/scorer/test_score.py +0 -0
  110. {spforge-0.8.35 → spforge-0.8.37}/tests/scorer/test_score_aggregation_granularity.py +0 -0
  111. {spforge-0.8.35 → spforge-0.8.37}/tests/scorer/test_scorer_name.py +0 -0
  112. {spforge-0.8.35 → spforge-0.8.37}/tests/test_autopipeline.py +0 -0
  113. {spforge-0.8.35 → spforge-0.8.37}/tests/test_autopipeline_context.py +0 -0
  114. {spforge-0.8.35 → spforge-0.8.37}/tests/test_feature_generator_pipeline.py +0 -0
  115. {spforge-0.8.35 → spforge-0.8.37}/tests/transformers/test_estimator_transformer_context.py +0 -0
  116. {spforge-0.8.35 → spforge-0.8.37}/tests/transformers/test_net_over_predicted.py +0 -0
  117. {spforge-0.8.35 → spforge-0.8.37}/tests/transformers/test_other_transformer.py +0 -0
  118. {spforge-0.8.35 → spforge-0.8.37}/tests/transformers/test_predictor_transformer.py +0 -0
  119. {spforge-0.8.35 → spforge-0.8.37}/tests/transformers/test_simple_transformer.py +0 -0
  120. {spforge-0.8.35 → spforge-0.8.37}/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.35
3
+ Version: 0.8.37
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.35"
7
+ version = "0.8.37"
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"
@@ -86,8 +86,8 @@ class PerformanceManager(BaseEstimator, TransformerMixin):
86
86
  transformer_names: list[TransformerName] | None = None,
87
87
  custom_transformers: list[NarwhalsFeatureTransformer] | None = None,
88
88
  prefix: str = "performance__",
89
- min_value: float = -0.02,
90
- max_value: float = 1.02,
89
+ min_value: float = 0.0,
90
+ max_value: float = 1.0,
91
91
  zero_inflation_threshold: float = 0.15,
92
92
  ):
93
93
  self.features = features
@@ -209,8 +209,8 @@ class PerformanceWeightsManager(PerformanceManager):
209
209
  transformer_names: (
210
210
  list[Literal["partial_standard_scaler", "symmetric", "min_max"]] | None
211
211
  ) = None,
212
- max_value: float = 1.02,
213
- min_value: float = -0.02,
212
+ max_value: float = 1.0,
213
+ min_value: float = 0.0,
214
214
  prefix: str = "performance__",
215
215
  return_all_features: bool = False,
216
216
  zero_inflation_threshold: float = 0.15,
@@ -156,17 +156,20 @@ class RatingGenerator(FeatureGenerator):
156
156
  )
157
157
 
158
158
  perf = df[self.performance_column]
159
- if perf.max() > 1.02 or perf.min() < -0.02:
160
- raise ValueError(
161
- f"Max {self.performance_column} must be less than than 1.02 and min value larger than -0.02. "
162
- "Either transform it manually or set auto_scale_performance to True"
163
- )
159
+ # Filter to finite values for validation (NaN/inf are treated as missing data)
160
+ finite_perf = perf.filter(perf.is_finite())
161
+ if len(finite_perf) > 0:
162
+ if finite_perf.max() > 1.02 or finite_perf.min() < -0.02:
163
+ raise ValueError(
164
+ f"Max {self.performance_column} must be less than than 1.02 and min value larger than -0.02. "
165
+ "Either transform it manually or set auto_scale_performance to True"
166
+ )
164
167
 
165
- if perf.mean() < 0.42 or perf.mean() > 0.58:
166
- raise ValueError(
167
- f"Mean {self.performance_column} must be between 0.42 and 0.58. "
168
- "Either transform it manually or set auto_scale_performance to True"
169
- )
168
+ if finite_perf.mean() < 0.42 or finite_perf.mean() > 0.58:
169
+ raise ValueError(
170
+ f"Mean {self.performance_column} must be between 0.42 and 0.58. "
171
+ "Either transform it manually or set auto_scale_performance to True"
172
+ )
170
173
 
171
174
  pl_df: pl.DataFrame
172
175
  pl_df = df.to_native() if df.implementation.is_polars() else df.to_polars().to_native()
@@ -587,8 +587,9 @@ class PlayerRatingGenerator(RatingGenerator):
587
587
  )
588
588
 
589
589
  perf_value = pre_player.match_performance.performance_value
590
+ perf_is_valid = perf_value is not None and math.isfinite(float(perf_value))
590
591
 
591
- if perf_value is None:
592
+ if not perf_is_valid:
592
593
  off_change = 0.0
593
594
  else:
594
595
  off_perf = float(perf_value)
@@ -599,7 +600,7 @@ class PlayerRatingGenerator(RatingGenerator):
599
600
  * float(pre_player.match_performance.participation_weight)
600
601
  )
601
602
 
602
- if team1_def_perf is None or (not self.use_off_def_split and perf_value is None):
603
+ if team1_def_perf is None or (not self.use_off_def_split and not perf_is_valid):
603
604
  def_change = 0.0
604
605
  else:
605
606
  def_perf = float(team1_def_perf)
@@ -681,8 +682,9 @@ class PlayerRatingGenerator(RatingGenerator):
681
682
  )
682
683
 
683
684
  perf_value = pre_player.match_performance.performance_value
685
+ perf_is_valid = perf_value is not None and math.isfinite(float(perf_value))
684
686
 
685
- if perf_value is None:
687
+ if not perf_is_valid:
686
688
  off_change = 0.0
687
689
  else:
688
690
  off_perf = float(perf_value)
@@ -693,7 +695,7 @@ class PlayerRatingGenerator(RatingGenerator):
693
695
  * float(pre_player.match_performance.participation_weight)
694
696
  )
695
697
 
696
- if team2_def_perf is None or (not self.use_off_def_split and perf_value is None):
698
+ if team2_def_perf is None or (not self.use_off_def_split and not perf_is_valid):
697
699
  def_change = 0.0
698
700
  else:
699
701
  def_perf = float(team2_def_perf)
@@ -1254,7 +1256,7 @@ class PlayerRatingGenerator(RatingGenerator):
1254
1256
  self, c: PreMatchPlayersCollection
1255
1257
  ) -> float | None:
1256
1258
  # observed offense perf = weighted mean of player performance_value using participation_weight if present
1257
- # skip players with null performance
1259
+ # skip players with null/non-finite performance
1258
1260
  cn = self.column_names
1259
1261
  if not c.pre_match_player_ratings:
1260
1262
  return None
@@ -1264,12 +1266,15 @@ class PlayerRatingGenerator(RatingGenerator):
1264
1266
  perf_val = pre.match_performance.performance_value
1265
1267
  if perf_val is None:
1266
1268
  continue
1269
+ perf_float = float(perf_val)
1270
+ if not math.isfinite(perf_float):
1271
+ continue
1267
1272
  w = (
1268
1273
  float(pre.match_performance.participation_weight)
1269
1274
  if cn.participation_weight
1270
1275
  else 1.0
1271
1276
  )
1272
- psum += float(perf_val) * w
1277
+ psum += perf_float * w
1273
1278
  wsum += w
1274
1279
  return psum / wsum if wsum else None
1275
1280
 
@@ -1341,7 +1346,9 @@ class PlayerRatingGenerator(RatingGenerator):
1341
1346
  self.performance_column in team_player
1342
1347
  and team_player[self.performance_column] is not None
1343
1348
  ):
1344
- return float(team_player[self.performance_column])
1349
+ val = float(team_player[self.performance_column])
1350
+ if math.isfinite(val):
1351
+ return val
1345
1352
  return None
1346
1353
 
1347
1354
  def ensure_new_player(
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: spforge
3
- Version: 0.8.35
3
+ Version: 0.8.37
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
@@ -203,8 +203,8 @@ def test_performance_manager_aliases_unprefixed_input_when_transformer_expects_p
203
203
  transformer_names=[],
204
204
  prefix="performance__",
205
205
  performance_column="weighted_performance",
206
- min_value=-0.02,
207
- max_value=1.02,
206
+ min_value=0.0,
207
+ max_value=1.0,
208
208
  )
209
209
 
210
210
  pm.fit(df)
@@ -434,3 +434,99 @@ class TestZeroInflationHandling:
434
434
 
435
435
  # Should have switched to quantile scaler
436
436
  assert manager._using_quantile_scaler is True
437
+
438
+
439
+ class TestAutoScalePerformanceBounds:
440
+ """Tests for ensuring scaled performance stays within [0, 1] bounds."""
441
+
442
+ @pytest.mark.parametrize("frame", ["pd", "pl"])
443
+ def test_auto_scale_performance_preserves_non_negative(self, frame):
444
+ """Scaled performance should be non-negative when input is non-negative."""
445
+ np.random.seed(42)
446
+ # Create data similar to free throw % - centered around 0.77 with some zeros
447
+ n = 400
448
+ data = []
449
+ for _ in range(n):
450
+ if np.random.random() < 0.25: # 25% zeros (missed all free throws)
451
+ data.append(0.0)
452
+ else:
453
+ # Values between 0.6 and 1.0, centered around 0.77
454
+ data.append(np.clip(np.random.normal(0.77, 0.15), 0.0, 1.0))
455
+
456
+ df = _make_native_df(frame, {"x": data})
457
+
458
+ pm = PerformanceManager(
459
+ features=["x"],
460
+ transformer_names=["symmetric", "partial_standard_scaler", "min_max"],
461
+ prefix="performance__",
462
+ performance_column="perf",
463
+ )
464
+
465
+ result = pm.fit_transform(df)
466
+ result_nw = nw.from_native(result)
467
+ scaled = result_nw["performance__perf"].to_numpy()
468
+
469
+ assert np.all(scaled >= 0), f"Scaled performance should not be negative, min was {scaled.min()}"
470
+
471
+ @pytest.mark.parametrize("frame", ["pd", "pl"])
472
+ def test_auto_scale_performance_output_range(self, frame):
473
+ """Scaled performance should be in [0, 1] when input is in [0, 1]."""
474
+ np.random.seed(42)
475
+ # Create data with performance in [0, 1] but skewed distribution
476
+ n = 400
477
+ data = []
478
+ for _ in range(n):
479
+ if np.random.random() < 0.25:
480
+ data.append(0.0)
481
+ else:
482
+ data.append(np.clip(np.random.normal(0.77, 0.15), 0.0, 1.0))
483
+
484
+ df = _make_native_df(frame, {"x": data})
485
+
486
+ pm = PerformanceManager(
487
+ features=["x"],
488
+ transformer_names=["symmetric", "partial_standard_scaler", "min_max"],
489
+ prefix="performance__",
490
+ performance_column="perf",
491
+ )
492
+
493
+ result = pm.fit_transform(df)
494
+ result_nw = nw.from_native(result)
495
+ scaled = result_nw["performance__perf"].to_numpy()
496
+
497
+ assert np.all(scaled >= 0.0), f"Scaled min should be >= 0, got {scaled.min()}"
498
+ assert np.all(scaled <= 1.0), f"Scaled max should be <= 1, got {scaled.max()}"
499
+
500
+ @pytest.mark.parametrize("frame", ["pd", "pl"])
501
+ def test_default_bounds_are_unit_interval(self, frame):
502
+ """Test that default bounds are [0, 1]."""
503
+ pm = PerformanceManager(
504
+ features=["x"],
505
+ transformer_names=[],
506
+ prefix="",
507
+ performance_column="x",
508
+ )
509
+
510
+ assert pm.min_value == 0.0
511
+ assert pm.max_value == 1.0
512
+
513
+ @pytest.mark.parametrize("frame", ["pd", "pl"])
514
+ def test_custom_bounds_still_work(self, frame):
515
+ """Test that custom bounds can still be specified."""
516
+ df = _make_native_df(frame, {"x": [-10.0, 0.5, 10.0]})
517
+
518
+ pm = PerformanceManager(
519
+ features=["x"],
520
+ transformer_names=[],
521
+ prefix="",
522
+ performance_column="x",
523
+ min_value=-0.5,
524
+ max_value=1.5,
525
+ )
526
+
527
+ result = pm.fit_transform(df)
528
+ result_nw = nw.from_native(result)
529
+ scaled = result_nw["x"].to_numpy()
530
+
531
+ assert scaled.min() >= -0.5
532
+ assert scaled.max() <= 1.5
@@ -2938,3 +2938,141 @@ def test_player_opponent_mean_projected_feature(base_cn, sample_df):
2938
2938
  (pl.col("player_opponent_mean_projected_perf") - expected).abs().max()
2939
2939
  ).item()
2940
2940
  assert diff < 1e-6, f"Max difference from expected mean: {diff}"
2941
+
2942
+
2943
+ class TestNaNPerformanceHandling:
2944
+ """Tests that PlayerRatingGenerator handles NaN performance values correctly."""
2945
+
2946
+ @pytest.fixture
2947
+ def nan_cn(self):
2948
+ return ColumnNames(
2949
+ player_id="player_id",
2950
+ team_id="team_id",
2951
+ match_id="match_id",
2952
+ start_date="start_date",
2953
+ participation_weight="participation_weight",
2954
+ )
2955
+
2956
+ def _create_test_df(self, performance_values: list) -> pl.DataFrame:
2957
+ """Create minimal test DataFrame with 2 teams, 2 players each."""
2958
+ import numpy as np
2959
+
2960
+ return pl.DataFrame({
2961
+ "match_id": ["game1"] * 4,
2962
+ "player_id": ["p1", "p2", "p3", "p4"],
2963
+ "team_id": ["A", "A", "B", "B"],
2964
+ "start_date": ["2024-01-01"] * 4,
2965
+ "performance": performance_values,
2966
+ "participation_weight": [1.0] * 4,
2967
+ })
2968
+
2969
+ def test_nan_performance_does_not_raise(self, nan_cn):
2970
+ """NaN performance values should not raise ValueError."""
2971
+ import numpy as np
2972
+
2973
+ # Use values that give mean ~0.5 when NaN is excluded
2974
+ df = self._create_test_df([0.6, np.nan, 0.4, 0.5])
2975
+
2976
+ gen = PlayerRatingGenerator(
2977
+ performance_column="performance",
2978
+ column_names=nan_cn,
2979
+ features_out=[RatingKnownFeatures.PLAYER_OFF_RATING],
2980
+ )
2981
+
2982
+ # Should not raise
2983
+ result = gen.fit_transform(df)
2984
+ assert len(result) == 4
2985
+
2986
+ def test_inf_performance_does_not_raise(self, nan_cn):
2987
+ """Inf performance values should not raise ValueError."""
2988
+ # Use values that give mean ~0.5 when inf is excluded
2989
+ df = self._create_test_df([0.6, float('inf'), 0.4, 0.5])
2990
+
2991
+ gen = PlayerRatingGenerator(
2992
+ performance_column="performance",
2993
+ column_names=nan_cn,
2994
+ features_out=[RatingKnownFeatures.PLAYER_OFF_RATING],
2995
+ )
2996
+
2997
+ result = gen.fit_transform(df)
2998
+ assert len(result) == 4
2999
+
3000
+ def test_neg_inf_performance_does_not_raise(self, nan_cn):
3001
+ """Negative inf performance values should not raise ValueError."""
3002
+ # Use values that give mean ~0.5 when -inf is excluded
3003
+ df = self._create_test_df([0.6, float('-inf'), 0.4, 0.5])
3004
+
3005
+ gen = PlayerRatingGenerator(
3006
+ performance_column="performance",
3007
+ column_names=nan_cn,
3008
+ features_out=[RatingKnownFeatures.PLAYER_OFF_RATING],
3009
+ )
3010
+
3011
+ result = gen.fit_transform(df)
3012
+ assert len(result) == 4
3013
+
3014
+ def test_nan_performance_treated_as_zero_rating_change(self, nan_cn):
3015
+ """Players with NaN performance should have zero rating change."""
3016
+ import numpy as np
3017
+
3018
+ # Two games: first establishes ratings, second tests NaN handling
3019
+ df = pl.DataFrame({
3020
+ "match_id": ["game1"] * 4 + ["game2"] * 4,
3021
+ "player_id": ["p1", "p2", "p3", "p4"] * 2,
3022
+ "team_id": ["A", "A", "B", "B"] * 2,
3023
+ "start_date": ["2024-01-01"] * 4 + ["2024-01-02"] * 4,
3024
+ "performance": [0.5, 0.5, 0.5, 0.5, 0.6, np.nan, 0.4, 0.5],
3025
+ "participation_weight": [1.0] * 8,
3026
+ })
3027
+
3028
+ gen = PlayerRatingGenerator(
3029
+ performance_column="performance",
3030
+ column_names=nan_cn,
3031
+ features_out=[RatingKnownFeatures.PLAYER_OFF_RATING],
3032
+ )
3033
+
3034
+ result = gen.fit_transform(df)
3035
+
3036
+ # Get player p2's ratings for both games
3037
+ p2_game1 = result.filter(
3038
+ (pl.col("player_id") == "p2") & (pl.col("match_id") == "game1")
3039
+ )["player_off_rating_performance"][0]
3040
+
3041
+ p2_game2 = result.filter(
3042
+ (pl.col("player_id") == "p2") & (pl.col("match_id") == "game2")
3043
+ )["player_off_rating_performance"][0]
3044
+
3045
+ # Rating should not change when performance is NaN
3046
+ assert p2_game1 == p2_game2, "NaN performance should result in zero rating change"
3047
+
3048
+ def test_all_nan_performance_in_match_handled(self, nan_cn):
3049
+ """Match where all players have NaN should not raise."""
3050
+ import numpy as np
3051
+
3052
+ # All NaN - validation is skipped when no finite values exist
3053
+ df = self._create_test_df([np.nan, np.nan, np.nan, np.nan])
3054
+
3055
+ gen = PlayerRatingGenerator(
3056
+ performance_column="performance",
3057
+ column_names=nan_cn,
3058
+ features_out=[RatingKnownFeatures.PLAYER_OFF_RATING],
3059
+ )
3060
+
3061
+ result = gen.fit_transform(df)
3062
+ assert len(result) == 4
3063
+
3064
+ def test_mixed_nan_none_performance(self, nan_cn):
3065
+ """Mix of NaN and None performance values should both be handled."""
3066
+ import numpy as np
3067
+
3068
+ # Use values that give mean ~0.5 when NaN/None are excluded
3069
+ df = self._create_test_df([0.6, np.nan, None, 0.5])
3070
+
3071
+ gen = PlayerRatingGenerator(
3072
+ performance_column="performance",
3073
+ column_names=nan_cn,
3074
+ features_out=[RatingKnownFeatures.PLAYER_OFF_RATING],
3075
+ )
3076
+
3077
+ result = gen.fit_transform(df)
3078
+ assert len(result) == 4
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes