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

@@ -7,6 +7,7 @@ from spforge.hyperparameter_tuning._default_search_spaces import (
7
7
  get_default_search_space,
8
8
  get_default_student_t_search_space,
9
9
  get_default_team_rating_search_space,
10
+ get_full_player_rating_search_space,
10
11
  )
11
12
  from spforge.hyperparameter_tuning._tuner import (
12
13
  EstimatorHyperparameterTuner,
@@ -28,4 +29,5 @@ __all__ = [
28
29
  "get_default_team_rating_search_space",
29
30
  "get_default_student_t_search_space",
30
31
  "get_default_search_space",
32
+ "get_full_player_rating_search_space",
31
33
  ]
@@ -128,6 +128,7 @@ def get_default_player_rating_search_space() -> dict[str, ParamSpec]:
128
128
  Default search space for PlayerRatingGenerator.
129
129
 
130
130
  Focuses on core parameters that have the most impact on performance.
131
+ Excludes performance_predictor and team-based start rating params.
131
132
 
132
133
  Returns:
133
134
  Dictionary mapping parameter names to ParamSpec objects
@@ -163,10 +164,6 @@ def get_default_player_rating_search_space() -> dict[str, ParamSpec]:
163
164
  "use_off_def_split": ParamSpec(
164
165
  param_type="bool",
165
166
  ),
166
- "performance_predictor": ParamSpec(
167
- param_type="categorical",
168
- choices=["difference", "mean", "ignore_opponent"],
169
- ),
170
167
  "start_league_quantile": ParamSpec(
171
168
  param_type="float",
172
169
  low=0.05,
@@ -177,24 +174,46 @@ def get_default_player_rating_search_space() -> dict[str, ParamSpec]:
177
174
  low=40,
178
175
  high=500,
179
176
  ),
180
- "start_team_rating_subtract": ParamSpec(
181
- param_type="float",
182
- low=0.0,
183
- high=200.0,
184
- ),
185
- "start_team_weight": ParamSpec(
186
- param_type="float",
187
- low=0.0,
188
- high=1.0,
189
- ),
190
- "start_min_match_count_team_rating": ParamSpec(
191
- param_type="int",
192
- low=1,
193
- high=10,
194
- ),
195
177
  }
196
178
 
197
179
 
180
+ def get_full_player_rating_search_space() -> dict[str, ParamSpec]:
181
+ """
182
+ Full search space for PlayerRatingGenerator including all tunable parameters.
183
+
184
+ Includes performance_predictor and team-based start rating parameters.
185
+ Use this when you want to tune all parameters.
186
+
187
+ Returns:
188
+ Dictionary mapping parameter names to ParamSpec objects
189
+ """
190
+ base = get_default_player_rating_search_space()
191
+ base.update(
192
+ {
193
+ "performance_predictor": ParamSpec(
194
+ param_type="categorical",
195
+ choices=["difference", "mean", "ignore_opponent"],
196
+ ),
197
+ "start_team_rating_subtract": ParamSpec(
198
+ param_type="float",
199
+ low=0.0,
200
+ high=200.0,
201
+ ),
202
+ "start_team_weight": ParamSpec(
203
+ param_type="float",
204
+ low=0.0,
205
+ high=1.0,
206
+ ),
207
+ "start_min_match_count_team_rating": ParamSpec(
208
+ param_type="int",
209
+ low=1,
210
+ high=10,
211
+ ),
212
+ }
213
+ )
214
+ return base
215
+
216
+
198
217
  def get_default_team_rating_search_space() -> dict[str, ParamSpec]:
199
218
  """
200
219
  Default search space for TeamRatingGenerator.
@@ -235,10 +254,6 @@ def get_default_team_rating_search_space() -> dict[str, ParamSpec]:
235
254
  "use_off_def_split": ParamSpec(
236
255
  param_type="bool",
237
256
  ),
238
- "performance_predictor": ParamSpec(
239
- param_type="categorical",
240
- choices=["difference", "mean", "ignore_opponent"],
241
- ),
242
257
  }
243
258
 
244
259
 
@@ -91,6 +91,9 @@ class RatingHyperparameterTuner:
91
91
  scorer: BaseScorer,
92
92
  direction: Literal["minimize", "maximize"],
93
93
  param_search_space: dict[str, ParamSpec] | None = None,
94
+ param_ranges: dict[str, tuple[float | int, float | int]] | None = None,
95
+ exclude_params: list[str] | None = None,
96
+ fixed_params: dict[str, Any] | None = None,
94
97
  n_trials: int = 50,
95
98
  n_jobs: int = 1,
96
99
  storage: str | None = None,
@@ -109,6 +112,14 @@ class RatingHyperparameterTuner:
109
112
  scorer: Scorer for evaluation (must have score(df) -> float | dict)
110
113
  direction: "minimize" or "maximize"
111
114
  param_search_space: Custom search space (merges with defaults if provided)
115
+ param_ranges: Easy range override for float/int params. Maps param name to
116
+ (low, high) tuple. Preserves param_type and log scale from defaults.
117
+ Example: {"confidence_weight": (0.2, 1.0)}
118
+ exclude_params: List of param names to exclude from tuning entirely.
119
+ Example: ["performance_predictor", "use_off_def_split"]
120
+ fixed_params: Parameters to fix at specific values (not tuned).
121
+ These values are applied to the rating generator each trial.
122
+ Example: {"performance_predictor": "mean"}
112
123
  n_trials: Number of optimization trials
113
124
  n_jobs: Number of parallel jobs (1 = sequential)
114
125
  storage: Optuna storage URL (e.g., "sqlite:///optuna.db") for persistence
@@ -123,6 +134,9 @@ class RatingHyperparameterTuner:
123
134
  self.scorer = scorer
124
135
  self.direction = direction
125
136
  self.custom_search_space = param_search_space
137
+ self.param_ranges = param_ranges
138
+ self.exclude_params = exclude_params or []
139
+ self.fixed_params = fixed_params or {}
126
140
  self.n_trials = n_trials
127
141
  self.n_jobs = n_jobs
128
142
  self.storage = storage
@@ -196,6 +210,9 @@ class RatingHyperparameterTuner:
196
210
  try:
197
211
  copied_gen = copy.deepcopy(self.rating_generator)
198
212
 
213
+ for param_name, param_value in self.fixed_params.items():
214
+ setattr(copied_gen, param_name, param_value)
215
+
199
216
  trial_params = self._suggest_params(trial, search_space)
200
217
 
201
218
  for param_name, param_value in trial_params.items():
@@ -243,18 +260,54 @@ class RatingHyperparameterTuner:
243
260
  defaults: dict[str, ParamSpec],
244
261
  ) -> dict[str, ParamSpec]:
245
262
  """
246
- Merge custom search space with defaults (custom takes precedence).
263
+ Merge custom search space with defaults.
264
+
265
+ Priority order (highest to lowest):
266
+ 1. exclude_params - removes param entirely
267
+ 2. fixed_params - removes from search (applied separately)
268
+ 3. custom (param_search_space) - full ParamSpec override
269
+ 4. param_ranges - updates only low/high bounds
270
+ 5. defaults - base search space
247
271
 
248
272
  Args:
249
273
  custom: Custom search space (may be None)
250
274
  defaults: Default search space
251
275
 
252
276
  Returns:
253
- Merged search space
277
+ Merged search space (excludes fixed_params, those are applied separately)
254
278
  """
255
279
  merged = defaults.copy()
280
+
281
+ if self.param_ranges:
282
+ for param_name, (low, high) in self.param_ranges.items():
283
+ if param_name not in merged:
284
+ raise ValueError(
285
+ f"param_ranges contains unknown parameter: '{param_name}'. "
286
+ f"Available parameters: {list(merged.keys())}"
287
+ )
288
+ existing = merged[param_name]
289
+ if existing.param_type not in ("float", "int"):
290
+ raise ValueError(
291
+ f"param_ranges can only override float/int parameters. "
292
+ f"'{param_name}' is {existing.param_type}."
293
+ )
294
+ merged[param_name] = ParamSpec(
295
+ param_type=existing.param_type,
296
+ low=low,
297
+ high=high,
298
+ log=existing.log,
299
+ step=existing.step,
300
+ )
301
+
256
302
  if custom:
257
303
  merged.update(custom)
304
+
305
+ for param_name in self.exclude_params:
306
+ merged.pop(param_name, None)
307
+
308
+ for param_name in self.fixed_params:
309
+ merged.pop(param_name, None)
310
+
258
311
  return merged
259
312
 
260
313
  @staticmethod
@@ -484,26 +484,31 @@ class PlayerRatingGenerator(RatingGenerator):
484
484
  ),
485
485
  )
486
486
 
487
- off_perf = float(pre_player.match_performance.performance_value)
488
- def_perf = float(team1_def_perf) # same for all players on team1 (derived)
489
-
490
- if not self.use_off_def_split:
491
- pred_def = pred_off
492
- def_perf = off_perf
493
-
494
- mult_off = self._applied_multiplier_off(off_state)
495
- mult_def = self._applied_multiplier_def(def_state)
496
-
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
- )
487
+ perf_value = pre_player.match_performance.performance_value
488
+ if perf_value is None:
489
+ off_change = 0.0
490
+ def_change = 0.0
491
+ else:
492
+ 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
+ mult_off = self._applied_multiplier_off(off_state)
500
+ mult_def = self._applied_multiplier_def(def_state)
501
+
502
+ off_change = (
503
+ (off_perf - float(pred_off))
504
+ * mult_off
505
+ * float(pre_player.match_performance.participation_weight)
506
+ )
507
+ def_change = (
508
+ (def_perf - float(pred_def))
509
+ * mult_def
510
+ * float(pre_player.match_performance.participation_weight)
511
+ )
507
512
 
508
513
  if math.isnan(off_change) or math.isnan(def_change):
509
514
  raise ValueError(
@@ -562,32 +567,37 @@ class PlayerRatingGenerator(RatingGenerator):
562
567
  ),
563
568
  )
564
569
 
565
- off_perf = float(pre_player.match_performance.performance_value)
566
- def_perf = float(team2_def_perf)
567
-
568
- if not self.use_off_def_split:
569
- pred_def = pred_off
570
- def_perf = off_perf
571
-
572
- mult_off = self._applied_multiplier_off(off_state)
573
- mult_def = self._applied_multiplier_def(def_state)
574
-
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
- )
585
-
586
- if math.isnan(off_change) or math.isnan(def_change):
587
- raise ValueError(
588
- f"NaN player rating change for player_id={pid}, match_id={r[cn.match_id]}"
570
+ perf_value = pre_player.match_performance.performance_value
571
+ if perf_value is None:
572
+ off_change = 0.0
573
+ def_change = 0.0
574
+ else:
575
+ 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
+ mult_off = self._applied_multiplier_off(off_state)
583
+ mult_def = self._applied_multiplier_def(def_state)
584
+
585
+ off_change = (
586
+ (off_perf - float(pred_off))
587
+ * mult_off
588
+ * float(pre_player.match_performance.participation_weight)
589
+ )
590
+ def_change = (
591
+ (def_perf - float(pred_def))
592
+ * mult_def
593
+ * float(pre_player.match_performance.participation_weight)
589
594
  )
590
595
 
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
+ )
600
+
591
601
  player_updates.append(
592
602
  (
593
603
  pid,
@@ -933,7 +943,7 @@ class PlayerRatingGenerator(RatingGenerator):
933
943
  self.performance_column in team_player
934
944
  and team_player[self.performance_column] is not None
935
945
  )
936
- else 0.0
946
+ else None
937
947
  )
938
948
 
939
949
  mp = MatchPerformance(
@@ -1023,18 +1033,22 @@ class PlayerRatingGenerator(RatingGenerator):
1023
1033
 
1024
1034
  def _team_off_perf_from_collection(self, c: PreMatchPlayersCollection) -> float:
1025
1035
  # observed offense perf = weighted mean of player performance_value using participation_weight if present
1036
+ # skip players with null performance
1026
1037
  cn = self.column_names
1027
1038
  if not c.pre_match_player_ratings:
1028
1039
  return 0.0
1029
1040
  wsum = 0.0
1030
1041
  psum = 0.0
1031
1042
  for pre in c.pre_match_player_ratings:
1043
+ perf_val = pre.match_performance.performance_value
1044
+ if perf_val is None:
1045
+ continue
1032
1046
  w = (
1033
1047
  float(pre.match_performance.participation_weight)
1034
1048
  if cn.participation_weight
1035
1049
  else 1.0
1036
1050
  )
1037
- psum += float(pre.match_performance.performance_value) * w
1051
+ psum += float(perf_val) * w
1038
1052
  wsum += w
1039
1053
  return psum / wsum if wsum else 0.0
1040
1054
 
@@ -1101,13 +1115,13 @@ class PlayerRatingGenerator(RatingGenerator):
1101
1115
  self.PLAYER_PRED_PERF_COL: [],
1102
1116
  }
1103
1117
 
1104
- def get_perf_value(team_player: dict) -> float:
1118
+ def get_perf_value(team_player: dict) -> float | None:
1105
1119
  if (
1106
1120
  self.performance_column in team_player
1107
1121
  and team_player[self.performance_column] is not None
1108
1122
  ):
1109
1123
  return float(team_player[self.performance_column])
1110
- return 0.0
1124
+ return None
1111
1125
 
1112
1126
  def ensure_new_player(
1113
1127
  pid: str,
@@ -1187,8 +1201,9 @@ class PlayerRatingGenerator(RatingGenerator):
1187
1201
  )
1188
1202
  off_vals.append(float(local_off[pid].rating_value))
1189
1203
 
1190
- psum += float(mp.performance_value) * float(pw)
1191
- wsum += float(pw)
1204
+ if mp.performance_value is not None:
1205
+ psum += float(mp.performance_value) * float(pw)
1206
+ wsum += float(pw)
1192
1207
 
1193
1208
  team_off_perf = psum / wsum if wsum else 0.0
1194
1209
  return pre_list, player_ids, off_vals, proj_w, team_off_perf
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: spforge
3
- Version: 0.8.18
3
+ Version: 0.8.20
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
@@ -43,15 +43,15 @@ spforge/feature_generator/_rolling_mean_binary.py,sha256=lmODy-o9Dd9pb8IlA7g4UyA
43
43
  spforge/feature_generator/_rolling_mean_days.py,sha256=EZQmFmYVQB-JjZV5k8bOWnaTxNpPDCZAjdfdhiiG4r4,8415
44
44
  spforge/feature_generator/_rolling_window.py,sha256=HT8LezsRIPNAlMEoP9oTPW2bKFu55ZSRnQZGST7fncw,8836
45
45
  spforge/feature_generator/_utils.py,sha256=KDn33ia1OYJTK8THFpvc_uRiH_Bl3fImGqqbfzs0YA4,9654
46
- spforge/hyperparameter_tuning/__init__.py,sha256=N2sKG4SvG41hlsFT2kx_DQYMmXsQr-8031Tu_rxlxyY,1015
47
- spforge/hyperparameter_tuning/_default_search_spaces.py,sha256=Sm5IrHAW0-vRC8jqCPX0pDi_C-W3L_MoEKGA8bx1Zbc,7546
48
- spforge/hyperparameter_tuning/_tuner.py,sha256=uovhGqhe8-fdhi79aErUmE2h5NCycFQEIRv5WCjpC7E,16732
46
+ spforge/hyperparameter_tuning/__init__.py,sha256=Vcl8rVlJ7M708iPgqe4XxpZWgJKGux0Y5HgMCymRsHg,1099
47
+ spforge/hyperparameter_tuning/_default_search_spaces.py,sha256=SjwXLpvYIu_JY8uPRHeL5Kgp1aa0slWDz8qsKDaohWQ,8020
48
+ spforge/hyperparameter_tuning/_tuner.py,sha256=M79q3saM6r0UZJsRUUgfdDr-3Qii-F2-wuSAZLFtZDo,19246
49
49
  spforge/performance_transformers/__init__.py,sha256=U6d7_kltbUMLYCGBk4QAFVPJTxXD3etD9qUftV-O3q4,422
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
53
  spforge/ratings/_base.py,sha256=dRMkIGj5-2zKddygaEA4g16WCyXon7v8Xa1ymm7IuoM,14335
54
- spforge/ratings/_player_rating.py,sha256=JSTXdaRw_b8ZoZxgmMnZrYG7gPg8GKawqalLd16SK1M,56066
54
+ spforge/ratings/_player_rating.py,sha256=TI0mEGZKEkf86TzOcP689C1jClYrXncjOnGwtsltMrk,56734
55
55
  spforge/ratings/_team_rating.py,sha256=T0kFiv3ykYSrVGGsVRa8ZxLB0WMnagxqdFDzl9yZ_9g,24813
56
56
  spforge/ratings/enums.py,sha256=s7z_RcZS6Nlgfa_6tasO8_IABZJwywexe7sep9DJBgo,1739
57
57
  spforge/ratings/league_identifier.py,sha256=_KDUKOwoNU6RNFKE5jju4eYFGVNGBdJsv5mhNvMakfc,6019
@@ -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.18.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
74
+ spforge-0.8.20.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
@@ -81,7 +81,7 @@ tests/end_to_end/test_estimator_hyperparameter_tuning.py,sha256=fZCJ9rrED2vT68B9
81
81
  tests/end_to_end/test_league_start_rating_optimizer.py,sha256=Mmct2ixp4c6L7PGym8wZc7E-Csozryt1g4_o6OCc1uI,3141
82
82
  tests/end_to_end/test_lol_player_kills.py,sha256=RJSYUbPrZ-RzSxGggj03yN0JKYeTB1JghVGYFMYia3Y,11891
83
83
  tests/end_to_end/test_nba_player_points.py,sha256=kyzjo7QIcvpteps29Wix6IS_eJG9d1gHLeWtIHpkWMs,9066
84
- tests/end_to_end/test_nba_player_ratings_hyperparameter_tuning.py,sha256=LXRkI_6Ho2kzJVbNAM17QFhx_MP9WdDJXCO9dWgJGNA,6491
84
+ tests/end_to_end/test_nba_player_ratings_hyperparameter_tuning.py,sha256=0lI4Xtg3V-zmo6prgzdNG80yy7JjvFVO-J_OU0pljyc,6346
85
85
  tests/end_to_end/test_nba_prediction_consistency.py,sha256=o3DckJasx_I1ed6MhMYZUo2WSDvQ_p3HtJa9DCWTIYU,9857
86
86
  tests/estimator/test_sklearn_estimator.py,sha256=tVfOP9Wx-tV1b6DcHbGxQHZQzNPA0Iobq8jTcUrk59U,48668
87
87
  tests/feature_generator/test_lag.py,sha256=5Ffrv0V9cwkbkzRMPBe3_c_YNW-W2al-XH_acQIvdeg,19531
@@ -91,10 +91,10 @@ tests/feature_generator/test_rolling_mean_binary.py,sha256=KuIavJ37Pt8icAb50B23l
91
91
  tests/feature_generator/test_rolling_mean_days.py,sha256=EyOvdJDnmgPfe13uQBOkwo7fAteBQx-tnyuGM4ng2T8,18884
92
92
  tests/feature_generator/test_rolling_window.py,sha256=YBJo36OK3ILYeXrH06ylXqviUcCaGYaVQaK5RJzwM7Y,23239
93
93
  tests/hyperparameter_tuning/test_estimator_tuner.py,sha256=iewME41d6LR2aQ0OtohGFtN_ocJUwTeqvs6L0QDmfG4,4413
94
- tests/hyperparameter_tuning/test_rating_tuner.py,sha256=PyCFP3KPc4Iy9E_X9stCVxra14uMgC1tuRwuQ30rO_o,13195
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=Bb_KR61-DbRUSiZDyfdSvZpMrJ0QDGHxGMFBNTq3Igw,70813
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
100
  tests/ratings/test_team_rating_generator.py,sha256=cDnf1zHiYC7pkgydE3MYr8wSTJIq-bPfSqhIRI_4Tic,95357
@@ -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.18.dist-info/METADATA,sha256=54l0UTrew2ot0_4k22hLKL-oXbQ4hlA1_KAXIqf_umw,20048
111
- spforge-0.8.18.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
112
- spforge-0.8.18.dist-info/top_level.txt,sha256=6UW2M5a7WKOeaAi900qQmRKNj5-HZzE8-eUD9Y9LTq0,23
113
- spforge-0.8.18.dist-info/RECORD,,
110
+ spforge-0.8.20.dist-info/METADATA,sha256=27uVILTzrjbdXCa91c0X4d9Fbfrw1bIOyuHFy45Nejw,20048
111
+ spforge-0.8.20.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
112
+ spforge-0.8.20.dist-info/top_level.txt,sha256=6UW2M5a7WKOeaAi900qQmRKNj5-HZzE8-eUD9Y9LTq0,23
113
+ spforge-0.8.20.dist-info/RECORD,,
@@ -96,12 +96,8 @@ def test_nba_player_ratings_hyperparameter_tuning__workflow_completes(
96
96
  "confidence_value_denom",
97
97
  "confidence_max_sum",
98
98
  "use_off_def_split",
99
- "performance_predictor",
100
- "start_team_weight",
101
99
  "start_league_quantile",
102
100
  "start_min_count_for_percentiles",
103
- "start_min_match_count_team_rating",
104
- "start_team_rating_subtract",
105
101
  }
106
102
  assert set(result.best_params.keys()) == expected_params
107
103
 
@@ -454,3 +454,160 @@ def test_param_spec__categorical_requires_choices():
454
454
 
455
455
  with pytest.raises(ValueError, match="requires choices"):
456
456
  spec.suggest(trial, "test_param")
457
+
458
+
459
+ def test_param_ranges__overrides_bounds(
460
+ player_rating_generator, cross_validator, scorer, sample_player_df_pd
461
+ ):
462
+ """Test that param_ranges overrides low/high bounds while preserving param_type."""
463
+ tuner = RatingHyperparameterTuner(
464
+ rating_generator=player_rating_generator,
465
+ cross_validator=cross_validator,
466
+ scorer=scorer,
467
+ direction="minimize",
468
+ param_ranges={
469
+ "confidence_weight": (0.2, 0.3),
470
+ },
471
+ n_trials=3,
472
+ show_progress_bar=False,
473
+ )
474
+
475
+ result = tuner.optimize(sample_player_df_pd)
476
+
477
+ assert "confidence_weight" in result.best_params
478
+ assert 0.2 <= result.best_params["confidence_weight"] <= 0.3
479
+
480
+
481
+ def test_exclude_params__removes_from_search(
482
+ player_rating_generator, cross_validator, scorer, sample_player_df_pd
483
+ ):
484
+ """Test that exclude_params removes parameters from search space."""
485
+ tuner = RatingHyperparameterTuner(
486
+ rating_generator=player_rating_generator,
487
+ cross_validator=cross_validator,
488
+ scorer=scorer,
489
+ direction="minimize",
490
+ exclude_params=["use_off_def_split", "confidence_weight"],
491
+ n_trials=3,
492
+ show_progress_bar=False,
493
+ )
494
+
495
+ result = tuner.optimize(sample_player_df_pd)
496
+
497
+ assert "use_off_def_split" not in result.best_params
498
+ assert "confidence_weight" not in result.best_params
499
+ assert "rating_change_multiplier_offense" in result.best_params
500
+
501
+
502
+ def test_fixed_params__applies_values_without_tuning(
503
+ player_rating_generator, cross_validator, scorer, sample_player_df_pd
504
+ ):
505
+ """Test that fixed_params sets values without including in search space."""
506
+ tuner = RatingHyperparameterTuner(
507
+ rating_generator=player_rating_generator,
508
+ cross_validator=cross_validator,
509
+ scorer=scorer,
510
+ direction="minimize",
511
+ fixed_params={"use_off_def_split": False},
512
+ n_trials=3,
513
+ show_progress_bar=False,
514
+ )
515
+
516
+ result = tuner.optimize(sample_player_df_pd)
517
+
518
+ assert "use_off_def_split" not in result.best_params
519
+
520
+
521
+ def test_param_ranges__unknown_param_raises_error(
522
+ player_rating_generator, cross_validator, scorer, sample_player_df_pd
523
+ ):
524
+ """Test that param_ranges with unknown param raises ValueError."""
525
+ tuner = RatingHyperparameterTuner(
526
+ rating_generator=player_rating_generator,
527
+ cross_validator=cross_validator,
528
+ scorer=scorer,
529
+ direction="minimize",
530
+ param_ranges={"nonexistent_param": (0.0, 1.0)},
531
+ n_trials=3,
532
+ show_progress_bar=False,
533
+ )
534
+
535
+ with pytest.raises(ValueError, match="unknown parameter"):
536
+ tuner.optimize(sample_player_df_pd)
537
+
538
+
539
+ def test_param_ranges__non_numeric_param_raises_error(
540
+ player_rating_generator, cross_validator, scorer, sample_player_df_pd
541
+ ):
542
+ """Test that param_ranges on non-float/int param raises ValueError."""
543
+ tuner = RatingHyperparameterTuner(
544
+ rating_generator=player_rating_generator,
545
+ cross_validator=cross_validator,
546
+ scorer=scorer,
547
+ direction="minimize",
548
+ param_ranges={"use_off_def_split": (0, 1)},
549
+ n_trials=3,
550
+ show_progress_bar=False,
551
+ )
552
+
553
+ with pytest.raises(ValueError, match="can only override float/int"):
554
+ tuner.optimize(sample_player_df_pd)
555
+
556
+
557
+ def test_combined_api__param_ranges_exclude_fixed(
558
+ player_rating_generator, cross_validator, scorer, sample_player_df_pd
559
+ ):
560
+ """Test using param_ranges, exclude_params, and fixed_params together."""
561
+ tuner = RatingHyperparameterTuner(
562
+ rating_generator=player_rating_generator,
563
+ cross_validator=cross_validator,
564
+ scorer=scorer,
565
+ direction="minimize",
566
+ param_ranges={
567
+ "confidence_weight": (0.2, 1.0),
568
+ "rating_change_multiplier_offense": (10.0, 150.0),
569
+ },
570
+ exclude_params=["start_league_quantile"],
571
+ fixed_params={"use_off_def_split": False},
572
+ n_trials=3,
573
+ show_progress_bar=False,
574
+ )
575
+
576
+ result = tuner.optimize(sample_player_df_pd)
577
+
578
+ assert 0.2 <= result.best_params["confidence_weight"] <= 1.0
579
+ assert 10.0 <= result.best_params["rating_change_multiplier_offense"] <= 150.0
580
+ assert "start_league_quantile" not in result.best_params
581
+ assert "use_off_def_split" not in result.best_params
582
+
583
+
584
+ def test_default_search_space__excludes_performance_predictor_and_team_start(
585
+ player_rating_generator,
586
+ ):
587
+ """Test that performance_predictor and team start params are not in default search space."""
588
+ from spforge.hyperparameter_tuning._default_search_spaces import (
589
+ get_default_search_space,
590
+ )
591
+
592
+ defaults = get_default_search_space(player_rating_generator)
593
+
594
+ assert "performance_predictor" not in defaults
595
+ assert "start_team_rating_subtract" not in defaults
596
+ assert "start_team_weight" not in defaults
597
+ assert "start_min_match_count_team_rating" not in defaults
598
+
599
+
600
+ def test_full_player_rating_search_space__includes_all_params():
601
+ """Test that full search space includes performance_predictor and team start params."""
602
+ from spforge.hyperparameter_tuning._default_search_spaces import (
603
+ get_full_player_rating_search_space,
604
+ )
605
+
606
+ full = get_full_player_rating_search_space()
607
+
608
+ assert "performance_predictor" in full
609
+ assert "start_team_rating_subtract" in full
610
+ assert "start_team_weight" in full
611
+ assert "start_min_match_count_team_rating" in full
612
+ assert "rating_change_multiplier_offense" in full
613
+ assert "confidence_weight" in full
@@ -722,6 +722,198 @@ def test_fit_transform_null_performance_handling(base_cn, sample_df):
722
722
  assert len(res) == 4
723
723
 
724
724
 
725
+ def test_fit_transform_null_performance__no_rating_change(base_cn):
726
+ """Players with null performance should have zero rating change, not be treated as 0.0 perf."""
727
+ # Match 1: Both players have performance (P1=0.6, P2=0.4)
728
+ # Match 2: P1 has null performance, P2 has 0.6
729
+ # Match 3: Both players have performance again
730
+ df = pl.DataFrame(
731
+ {
732
+ "pid": ["P1", "P2", "P1", "P2", "P1", "P2"],
733
+ "tid": ["T1", "T2", "T1", "T2", "T1", "T2"],
734
+ "mid": ["M1", "M1", "M2", "M2", "M3", "M3"],
735
+ "dt": [
736
+ "2024-01-01",
737
+ "2024-01-01",
738
+ "2024-01-02",
739
+ "2024-01-02",
740
+ "2024-01-03",
741
+ "2024-01-03",
742
+ ],
743
+ "perf": [0.6, 0.4, None, 0.6, 0.6, 0.4], # P1 has null in M2
744
+ "pw": [1.0, 1.0, 1.0, 1.0, 1.0, 1.0],
745
+ }
746
+ )
747
+
748
+ gen = PlayerRatingGenerator(
749
+ performance_column="perf",
750
+ column_names=base_cn,
751
+ features_out=[RatingKnownFeatures.PLAYER_OFF_RATING],
752
+ )
753
+ result = gen.fit_transform(df)
754
+
755
+ # Get P1's pre-match rating for M2 (after M1) and M3 (after M2 with null perf)
756
+ p1_rating_before_m2 = result.filter(
757
+ (pl.col("pid") == "P1") & (pl.col("mid") == "M2")
758
+ )["player_off_rating_perf"][0]
759
+ p1_rating_before_m3 = result.filter(
760
+ (pl.col("pid") == "P1") & (pl.col("mid") == "M3")
761
+ )["player_off_rating_perf"][0]
762
+
763
+ # Key assertion: P1's rating before M3 should equal rating before M2
764
+ # because null performance in M2 means NO rating change
765
+ assert p1_rating_before_m3 == p1_rating_before_m2, (
766
+ f"P1's rating changed after null performance game! "
767
+ f"Before M2={p1_rating_before_m2}, Before M3={p1_rating_before_m3}"
768
+ )
769
+
770
+ # Also verify null is not treated as 0.0 by comparing with explicit 0.0
771
+ df_with_zero = df.with_columns(
772
+ pl.when((pl.col("pid") == "P1") & (pl.col("mid") == "M2"))
773
+ .then(0.0)
774
+ .otherwise(pl.col("perf"))
775
+ .alias("perf")
776
+ )
777
+
778
+ gen_zero = PlayerRatingGenerator(
779
+ performance_column="perf",
780
+ column_names=base_cn,
781
+ features_out=[RatingKnownFeatures.PLAYER_OFF_RATING],
782
+ )
783
+ result_zero = gen_zero.fit_transform(df_with_zero)
784
+
785
+ p1_rating_before_m3_with_zero = result_zero.filter(
786
+ (pl.col("pid") == "P1") & (pl.col("mid") == "M3")
787
+ )["player_off_rating_perf"][0]
788
+
789
+ # With 0.0 perf, rating should drop (different from null)
790
+ assert p1_rating_before_m3 > p1_rating_before_m3_with_zero, (
791
+ f"Null performance is being treated as 0.0! "
792
+ f"Rating with null={p1_rating_before_m3}, rating with 0.0={p1_rating_before_m3_with_zero}"
793
+ )
794
+
795
+
796
+ def test_fit_transform_null_performance__still_outputs_player_rating(base_cn):
797
+ """Players with null performance should still have their pre-match rating in output."""
798
+ df = pl.DataFrame(
799
+ {
800
+ "pid": ["P1", "P2", "P3", "P4"],
801
+ "tid": ["T1", "T1", "T2", "T2"],
802
+ "mid": ["M1", "M1", "M1", "M1"],
803
+ "dt": ["2024-01-01"] * 4,
804
+ "perf": [0.6, None, 0.4, 0.5], # P2 has null performance
805
+ "pw": [1.0, 1.0, 1.0, 1.0],
806
+ }
807
+ )
808
+
809
+ gen = PlayerRatingGenerator(
810
+ performance_column="perf",
811
+ column_names=base_cn,
812
+ features_out=[RatingKnownFeatures.PLAYER_OFF_RATING],
813
+ )
814
+ result = gen.fit_transform(df)
815
+
816
+ # P2 should still be in output with their pre-match rating
817
+ assert len(result) == 4
818
+ p2_row = result.filter(pl.col("pid") == "P2")
819
+ assert len(p2_row) == 1
820
+ assert "player_off_rating_perf" in result.columns
821
+ # P2's rating should be the start rating (1000.0) since they're new and had no update
822
+ assert p2_row["player_off_rating_perf"][0] == 1000.0
823
+
824
+
825
+ def test_transform_null_performance__no_rating_change(base_cn):
826
+ """In transform (historical), null performance should result in no rating change."""
827
+ # First fit with some data
828
+ fit_df = pl.DataFrame(
829
+ {
830
+ "pid": ["P1", "P2"],
831
+ "tid": ["T1", "T2"],
832
+ "mid": ["M1", "M1"],
833
+ "dt": ["2024-01-01", "2024-01-01"],
834
+ "perf": [0.6, 0.4],
835
+ "pw": [1.0, 1.0],
836
+ }
837
+ )
838
+
839
+ gen = PlayerRatingGenerator(
840
+ performance_column="perf",
841
+ column_names=base_cn,
842
+ features_out=[RatingKnownFeatures.PLAYER_OFF_RATING],
843
+ )
844
+ gen.fit_transform(fit_df)
845
+
846
+ p1_rating_before = gen._player_off_ratings["P1"].rating_value
847
+
848
+ # Now transform with P1 having null performance
849
+ transform_df = pl.DataFrame(
850
+ {
851
+ "pid": ["P1", "P2"],
852
+ "tid": ["T1", "T2"],
853
+ "mid": ["M2", "M2"],
854
+ "dt": ["2024-01-02", "2024-01-02"],
855
+ "perf": [None, 0.6], # P1 has null
856
+ "pw": [1.0, 1.0],
857
+ }
858
+ )
859
+
860
+ gen.transform(transform_df)
861
+
862
+ p1_rating_after = gen._player_off_ratings["P1"].rating_value
863
+
864
+ # P1's rating should not change significantly (only confidence decay, not performance-based)
865
+ # Since null perf means no rating change from performance
866
+ assert abs(p1_rating_after - p1_rating_before) < 0.01, (
867
+ f"P1's rating changed significantly with null performance: "
868
+ f"before={p1_rating_before}, after={p1_rating_after}"
869
+ )
870
+
871
+
872
+ def test_future_transform_null_performance__outputs_projections(base_cn):
873
+ """In future_transform, null performance should still output rating projections."""
874
+ # First fit with some data
875
+ fit_df = pl.DataFrame(
876
+ {
877
+ "pid": ["P1", "P2"],
878
+ "tid": ["T1", "T2"],
879
+ "mid": ["M1", "M1"],
880
+ "dt": ["2024-01-01", "2024-01-01"],
881
+ "perf": [0.6, 0.4],
882
+ "pw": [1.0, 1.0],
883
+ }
884
+ )
885
+
886
+ gen = PlayerRatingGenerator(
887
+ performance_column="perf",
888
+ column_names=base_cn,
889
+ features_out=[RatingKnownFeatures.PLAYER_OFF_RATING],
890
+ )
891
+ gen.fit_transform(fit_df)
892
+
893
+ p1_rating_before = gen._player_off_ratings["P1"].rating_value
894
+
895
+ # Future transform (no performance needed, but if null it shouldn't affect anything)
896
+ future_df = pl.DataFrame(
897
+ {
898
+ "pid": ["P1", "P2"],
899
+ "tid": ["T1", "T2"],
900
+ "mid": ["M2", "M2"],
901
+ "dt": ["2024-01-02", "2024-01-02"],
902
+ "pw": [1.0, 1.0],
903
+ # No perf column - this is a future match
904
+ }
905
+ )
906
+
907
+ result = gen.future_transform(future_df)
908
+
909
+ # Should output projections for all players
910
+ assert len(result) == 2
911
+ assert "player_off_rating_perf" in result.columns
912
+
913
+ # Ratings should NOT be updated (future_transform doesn't update state)
914
+ assert gen._player_off_ratings["P1"].rating_value == p1_rating_before
915
+
916
+
725
917
  # --- transform & future_transform Tests ---
726
918
 
727
919