spforge 0.8.4__py3-none-any.whl → 0.8.8__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.
- examples/lol/pipeline_transformer_example.py +69 -86
- examples/nba/cross_validation_example.py +4 -11
- examples/nba/feature_engineering_example.py +33 -15
- examples/nba/game_winner_example.py +24 -14
- examples/nba/predictor_transformers_example.py +29 -16
- spforge/__init__.py +1 -0
- spforge/features_generator_pipeline.py +8 -4
- spforge/hyperparameter_tuning/__init__.py +12 -0
- spforge/hyperparameter_tuning/_default_search_spaces.py +159 -1
- spforge/hyperparameter_tuning/_tuner.py +192 -0
- spforge/ratings/__init__.py +4 -0
- spforge/ratings/_player_rating.py +11 -0
- spforge/ratings/league_start_rating_optimizer.py +201 -0
- {spforge-0.8.4.dist-info → spforge-0.8.8.dist-info}/METADATA +12 -19
- {spforge-0.8.4.dist-info → spforge-0.8.8.dist-info}/RECORD +25 -21
- tests/end_to_end/test_estimator_hyperparameter_tuning.py +85 -0
- tests/end_to_end/test_league_start_rating_optimizer.py +117 -0
- tests/end_to_end/test_nba_player_ratings_hyperparameter_tuning.py +5 -0
- tests/hyperparameter_tuning/test_estimator_tuner.py +167 -0
- tests/ratings/test_player_rating_generator.py +27 -0
- tests/scorer/test_score.py +90 -0
- tests/test_feature_generator_pipeline.py +43 -0
- {spforge-0.8.4.dist-info → spforge-0.8.8.dist-info}/WHEEL +0 -0
- {spforge-0.8.4.dist-info → spforge-0.8.8.dist-info}/licenses/LICENSE +0 -0
- {spforge-0.8.4.dist-info → spforge-0.8.8.dist-info}/top_level.txt +0 -0
|
@@ -1,12 +1,133 @@
|
|
|
1
1
|
from spforge.hyperparameter_tuning._tuner import ParamSpec
|
|
2
2
|
from spforge.ratings import PlayerRatingGenerator, TeamRatingGenerator
|
|
3
|
+
from spforge.distributions import (
|
|
4
|
+
NegativeBinomialEstimator,
|
|
5
|
+
NormalDistributionPredictor,
|
|
6
|
+
StudentTDistributionEstimator,
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _is_lightgbm_estimator(obj: object) -> bool:
|
|
11
|
+
mod = (getattr(type(obj), "__module__", "") or "").lower()
|
|
12
|
+
name = type(obj).__name__
|
|
13
|
+
if "lightgbm" in mod:
|
|
14
|
+
return True
|
|
15
|
+
return bool(name.startswith("LGBM"))
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def get_default_lgbm_search_space() -> dict[str, ParamSpec]:
|
|
19
|
+
return {
|
|
20
|
+
"n_estimators": ParamSpec(
|
|
21
|
+
param_type="int",
|
|
22
|
+
low=50,
|
|
23
|
+
high=800,
|
|
24
|
+
log=True,
|
|
25
|
+
),
|
|
26
|
+
"num_leaves": ParamSpec(
|
|
27
|
+
param_type="int",
|
|
28
|
+
low=16,
|
|
29
|
+
high=256,
|
|
30
|
+
log=True,
|
|
31
|
+
),
|
|
32
|
+
"max_depth": ParamSpec(
|
|
33
|
+
param_type="int",
|
|
34
|
+
low=3,
|
|
35
|
+
high=12,
|
|
36
|
+
),
|
|
37
|
+
"min_child_samples": ParamSpec(
|
|
38
|
+
param_type="int",
|
|
39
|
+
low=10,
|
|
40
|
+
high=200,
|
|
41
|
+
log=True,
|
|
42
|
+
),
|
|
43
|
+
"subsample": ParamSpec(
|
|
44
|
+
param_type="float",
|
|
45
|
+
low=0.6,
|
|
46
|
+
high=1.0,
|
|
47
|
+
),
|
|
48
|
+
"subsample_freq": ParamSpec(
|
|
49
|
+
param_type="int",
|
|
50
|
+
low=1,
|
|
51
|
+
high=7,
|
|
52
|
+
),
|
|
53
|
+
"reg_alpha": ParamSpec(
|
|
54
|
+
param_type="float",
|
|
55
|
+
low=1e-8,
|
|
56
|
+
high=10.0,
|
|
57
|
+
log=True,
|
|
58
|
+
),
|
|
59
|
+
"reg_lambda": ParamSpec(
|
|
60
|
+
param_type="float",
|
|
61
|
+
low=1e-8,
|
|
62
|
+
high=10.0,
|
|
63
|
+
log=True,
|
|
64
|
+
),
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def get_default_negative_binomial_search_space() -> dict[str, ParamSpec]:
|
|
69
|
+
return {
|
|
70
|
+
"predicted_r_weight": ParamSpec(
|
|
71
|
+
param_type="float",
|
|
72
|
+
low=0.0,
|
|
73
|
+
high=1.0,
|
|
74
|
+
),
|
|
75
|
+
"r_rolling_mean_window": ParamSpec(
|
|
76
|
+
param_type="int",
|
|
77
|
+
low=10,
|
|
78
|
+
high=120,
|
|
79
|
+
),
|
|
80
|
+
"predicted_r_iterations": ParamSpec(
|
|
81
|
+
param_type="int",
|
|
82
|
+
low=2,
|
|
83
|
+
high=12,
|
|
84
|
+
),
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def get_default_normal_distribution_search_space() -> dict[str, ParamSpec]:
|
|
89
|
+
return {
|
|
90
|
+
"sigma": ParamSpec(
|
|
91
|
+
param_type="float",
|
|
92
|
+
low=0.5,
|
|
93
|
+
high=30.0,
|
|
94
|
+
log=True,
|
|
95
|
+
),
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def get_default_student_t_search_space() -> dict[str, ParamSpec]:
|
|
100
|
+
return {
|
|
101
|
+
"df": ParamSpec(
|
|
102
|
+
param_type="float",
|
|
103
|
+
low=3.0,
|
|
104
|
+
high=30.0,
|
|
105
|
+
log=True,
|
|
106
|
+
),
|
|
107
|
+
"min_sigma": ParamSpec(
|
|
108
|
+
param_type="float",
|
|
109
|
+
low=0.5,
|
|
110
|
+
high=10.0,
|
|
111
|
+
log=True,
|
|
112
|
+
),
|
|
113
|
+
"sigma_bins": ParamSpec(
|
|
114
|
+
param_type="int",
|
|
115
|
+
low=4,
|
|
116
|
+
high=12,
|
|
117
|
+
),
|
|
118
|
+
"min_bin_rows": ParamSpec(
|
|
119
|
+
param_type="int",
|
|
120
|
+
low=10,
|
|
121
|
+
high=100,
|
|
122
|
+
),
|
|
123
|
+
}
|
|
3
124
|
|
|
4
125
|
|
|
5
126
|
def get_default_player_rating_search_space() -> dict[str, ParamSpec]:
|
|
6
127
|
"""
|
|
7
128
|
Default search space for PlayerRatingGenerator.
|
|
8
129
|
|
|
9
|
-
Focuses on
|
|
130
|
+
Focuses on core parameters that have the most impact on performance.
|
|
10
131
|
|
|
11
132
|
Returns:
|
|
12
133
|
Dictionary mapping parameter names to ParamSpec objects
|
|
@@ -46,6 +167,31 @@ def get_default_player_rating_search_space() -> dict[str, ParamSpec]:
|
|
|
46
167
|
param_type="categorical",
|
|
47
168
|
choices=["difference", "mean", "ignore_opponent"],
|
|
48
169
|
),
|
|
170
|
+
"start_league_quantile": ParamSpec(
|
|
171
|
+
param_type="float",
|
|
172
|
+
low=0.05,
|
|
173
|
+
high=0.5,
|
|
174
|
+
),
|
|
175
|
+
"start_min_count_for_percentiles": ParamSpec(
|
|
176
|
+
param_type="int",
|
|
177
|
+
low=40,
|
|
178
|
+
high=500,
|
|
179
|
+
),
|
|
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
|
+
),
|
|
49
195
|
}
|
|
50
196
|
|
|
51
197
|
|
|
@@ -120,3 +266,15 @@ def get_default_search_space(
|
|
|
120
266
|
f"Unsupported rating generator type: {type(rating_generator)}. "
|
|
121
267
|
"Expected PlayerRatingGenerator or TeamRatingGenerator."
|
|
122
268
|
)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def get_default_estimator_search_space(estimator: object) -> dict[str, ParamSpec]:
|
|
272
|
+
if _is_lightgbm_estimator(estimator):
|
|
273
|
+
return get_default_lgbm_search_space()
|
|
274
|
+
if isinstance(estimator, NegativeBinomialEstimator):
|
|
275
|
+
return get_default_negative_binomial_search_space()
|
|
276
|
+
if isinstance(estimator, NormalDistributionPredictor):
|
|
277
|
+
return get_default_normal_distribution_search_space()
|
|
278
|
+
if isinstance(estimator, StudentTDistributionEstimator):
|
|
279
|
+
return get_default_student_t_search_space()
|
|
280
|
+
return {}
|
|
@@ -45,6 +45,8 @@ class ParamSpec:
|
|
|
45
45
|
elif self.param_type == "int":
|
|
46
46
|
if self.low is None or self.high is None:
|
|
47
47
|
raise ValueError(f"int parameter '{name}' requires low and high bounds")
|
|
48
|
+
if self.step is None:
|
|
49
|
+
return trial.suggest_int(name, int(self.low), int(self.high))
|
|
48
50
|
return trial.suggest_int(name, int(self.low), int(self.high), step=self.step)
|
|
49
51
|
elif self.param_type == "categorical":
|
|
50
52
|
if self.choices is None:
|
|
@@ -272,3 +274,193 @@ class RatingHyperparameterTuner:
|
|
|
272
274
|
raise ValueError("Scorer returned invalid values in dict")
|
|
273
275
|
return float(np.mean(values))
|
|
274
276
|
return float(score)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _is_estimator(obj: object) -> bool:
|
|
280
|
+
return hasattr(obj, "get_params") and hasattr(obj, "set_params")
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _get_leaf_estimator_paths(estimator: Any) -> dict[str, Any]:
|
|
284
|
+
if not _is_estimator(estimator):
|
|
285
|
+
raise ValueError("estimator must implement get_params and set_params")
|
|
286
|
+
|
|
287
|
+
params = estimator.get_params(deep=True)
|
|
288
|
+
estimator_keys = [k for k, v in params.items() if _is_estimator(v)]
|
|
289
|
+
|
|
290
|
+
if not estimator_keys:
|
|
291
|
+
return {"": estimator}
|
|
292
|
+
|
|
293
|
+
leaves: list[str] = []
|
|
294
|
+
for key in estimator_keys:
|
|
295
|
+
if not any(other != key and other.startswith(f"{key}__") for other in estimator_keys):
|
|
296
|
+
leaves.append(key)
|
|
297
|
+
|
|
298
|
+
return {key: params[key] for key in sorted(leaves)}
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _build_search_space_for_targets(
|
|
302
|
+
targets: dict[str, dict[str, ParamSpec]],
|
|
303
|
+
) -> dict[str, ParamSpec]:
|
|
304
|
+
search_space: dict[str, ParamSpec] = {}
|
|
305
|
+
for path, params in targets.items():
|
|
306
|
+
for param_name, param_spec in params.items():
|
|
307
|
+
full_name = f"{path}__{param_name}" if path else param_name
|
|
308
|
+
if full_name in search_space:
|
|
309
|
+
raise ValueError(f"Duplicate parameter name detected: {full_name}")
|
|
310
|
+
search_space[full_name] = param_spec
|
|
311
|
+
return search_space
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _enqueue_predicted_r_weight_zero(study: optuna.Study, search_space: dict[str, ParamSpec]):
|
|
315
|
+
zero_params: dict[str, float] = {}
|
|
316
|
+
for name, spec in search_space.items():
|
|
317
|
+
if not name.endswith("predicted_r_weight"):
|
|
318
|
+
continue
|
|
319
|
+
if spec.param_type not in {"float", "int"}:
|
|
320
|
+
continue
|
|
321
|
+
if spec.low is None or spec.high is None:
|
|
322
|
+
continue
|
|
323
|
+
if spec.low <= 0 <= spec.high:
|
|
324
|
+
zero_params[name] = 0.0
|
|
325
|
+
|
|
326
|
+
if zero_params:
|
|
327
|
+
study.enqueue_trial(zero_params)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
class EstimatorHyperparameterTuner:
|
|
331
|
+
"""
|
|
332
|
+
Hyperparameter tuner for sklearn-compatible estimators.
|
|
333
|
+
|
|
334
|
+
Supports nested estimators and can target deepest leaf estimators.
|
|
335
|
+
"""
|
|
336
|
+
|
|
337
|
+
def __init__(
|
|
338
|
+
self,
|
|
339
|
+
estimator: Any,
|
|
340
|
+
cross_validator: MatchKFoldCrossValidator,
|
|
341
|
+
scorer: BaseScorer,
|
|
342
|
+
direction: Literal["minimize", "maximize"],
|
|
343
|
+
param_search_space: dict[str, ParamSpec] | None = None,
|
|
344
|
+
param_targets: dict[str, dict[str, ParamSpec]] | None = None,
|
|
345
|
+
n_trials: int = 50,
|
|
346
|
+
n_jobs: int = 1,
|
|
347
|
+
storage: str | None = None,
|
|
348
|
+
study_name: str | None = None,
|
|
349
|
+
timeout: float | None = None,
|
|
350
|
+
show_progress_bar: bool = True,
|
|
351
|
+
sampler: optuna.samplers.BaseSampler | None = None,
|
|
352
|
+
pruner: optuna.pruners.BasePruner | None = None,
|
|
353
|
+
):
|
|
354
|
+
self.estimator = estimator
|
|
355
|
+
self.cross_validator = cross_validator
|
|
356
|
+
self.scorer = scorer
|
|
357
|
+
self.direction = direction
|
|
358
|
+
self.param_search_space = param_search_space
|
|
359
|
+
self.param_targets = param_targets
|
|
360
|
+
self.n_trials = n_trials
|
|
361
|
+
self.n_jobs = n_jobs
|
|
362
|
+
self.storage = storage
|
|
363
|
+
self.study_name = study_name
|
|
364
|
+
self.timeout = timeout
|
|
365
|
+
self.show_progress_bar = show_progress_bar
|
|
366
|
+
self.sampler = sampler
|
|
367
|
+
self.pruner = pruner
|
|
368
|
+
|
|
369
|
+
if direction not in ["minimize", "maximize"]:
|
|
370
|
+
raise ValueError(f"direction must be 'minimize' or 'maximize', got: {direction}")
|
|
371
|
+
|
|
372
|
+
if storage is not None and study_name is None:
|
|
373
|
+
raise ValueError("study_name is required when using storage")
|
|
374
|
+
|
|
375
|
+
if param_search_space is not None and param_targets is not None:
|
|
376
|
+
raise ValueError("param_search_space and param_targets cannot both be provided")
|
|
377
|
+
|
|
378
|
+
def optimize(self, df: IntoFrameT) -> OptunaResult:
|
|
379
|
+
from spforge.hyperparameter_tuning._default_search_spaces import (
|
|
380
|
+
get_default_estimator_search_space,
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
leaf_estimators = _get_leaf_estimator_paths(self.estimator)
|
|
384
|
+
default_targets = {
|
|
385
|
+
path: get_default_estimator_search_space(est)
|
|
386
|
+
for path, est in leaf_estimators.items()
|
|
387
|
+
}
|
|
388
|
+
default_targets = {path: space for path, space in default_targets.items() if space}
|
|
389
|
+
|
|
390
|
+
if self.param_targets is not None:
|
|
391
|
+
unknown = set(self.param_targets) - set(leaf_estimators)
|
|
392
|
+
if unknown:
|
|
393
|
+
raise ValueError(f"param_targets contains unknown estimator paths: {unknown}")
|
|
394
|
+
targets = self.param_targets
|
|
395
|
+
elif self.param_search_space is not None:
|
|
396
|
+
targets = {path: self.param_search_space for path in leaf_estimators}
|
|
397
|
+
elif default_targets:
|
|
398
|
+
targets = default_targets
|
|
399
|
+
else:
|
|
400
|
+
raise ValueError(
|
|
401
|
+
"param_search_space is required when no default search space is available"
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
search_space = _build_search_space_for_targets(targets)
|
|
405
|
+
if not search_space:
|
|
406
|
+
raise ValueError("Resolved search space is empty")
|
|
407
|
+
|
|
408
|
+
study = optuna.create_study(
|
|
409
|
+
direction=self.direction,
|
|
410
|
+
sampler=self.sampler,
|
|
411
|
+
pruner=self.pruner,
|
|
412
|
+
storage=self.storage,
|
|
413
|
+
study_name=self.study_name,
|
|
414
|
+
load_if_exists=True if self.storage else False,
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
_enqueue_predicted_r_weight_zero(study, search_space)
|
|
418
|
+
|
|
419
|
+
study.optimize(
|
|
420
|
+
lambda trial: self._objective(trial, df, search_space),
|
|
421
|
+
n_trials=self.n_trials,
|
|
422
|
+
n_jobs=self.n_jobs,
|
|
423
|
+
timeout=self.timeout,
|
|
424
|
+
show_progress_bar=self.show_progress_bar,
|
|
425
|
+
)
|
|
426
|
+
|
|
427
|
+
return OptunaResult(
|
|
428
|
+
best_params=study.best_params,
|
|
429
|
+
best_value=study.best_value,
|
|
430
|
+
best_trial=study.best_trial,
|
|
431
|
+
study=study,
|
|
432
|
+
)
|
|
433
|
+
|
|
434
|
+
def _objective(
|
|
435
|
+
self, trial: optuna.Trial, df: IntoFrameT, search_space: dict[str, ParamSpec]
|
|
436
|
+
) -> float:
|
|
437
|
+
try:
|
|
438
|
+
trial_params = self._suggest_params(trial, search_space)
|
|
439
|
+
|
|
440
|
+
copied_estimator = copy.deepcopy(self.estimator)
|
|
441
|
+
copied_estimator.set_params(**trial_params)
|
|
442
|
+
|
|
443
|
+
cv = copy.deepcopy(self.cross_validator)
|
|
444
|
+
cv.estimator = copied_estimator
|
|
445
|
+
|
|
446
|
+
validation_df = cv.generate_validation_df(df)
|
|
447
|
+
score = self.scorer.score(validation_df)
|
|
448
|
+
score_value = RatingHyperparameterTuner._aggregate_score(score)
|
|
449
|
+
|
|
450
|
+
if math.isnan(score_value) or math.isinf(score_value):
|
|
451
|
+
logger.warning(f"Trial {trial.number} returned invalid score: {score_value}")
|
|
452
|
+
return float("inf") if self.direction == "minimize" else float("-inf")
|
|
453
|
+
|
|
454
|
+
return score_value
|
|
455
|
+
|
|
456
|
+
except Exception as e:
|
|
457
|
+
logger.warning(f"Trial {trial.number} failed with error: {e}")
|
|
458
|
+
return float("inf") if self.direction == "minimize" else float("-inf")
|
|
459
|
+
|
|
460
|
+
def _suggest_params(
|
|
461
|
+
self, trial: optuna.Trial, search_space: dict[str, ParamSpec]
|
|
462
|
+
) -> dict[str, Any]:
|
|
463
|
+
params: dict[str, Any] = {}
|
|
464
|
+
for param_name, param_spec in search_space.items():
|
|
465
|
+
params[param_name] = param_spec.suggest(trial, param_name)
|
|
466
|
+
return params
|
spforge/ratings/__init__.py
CHANGED
|
@@ -6,3 +6,7 @@ from .enums import (
|
|
|
6
6
|
RatingUnknownFeatures as RatingUnknownFeatures,
|
|
7
7
|
)
|
|
8
8
|
from .league_identifier import LeagueIdentifier as LeagueIdentifier
|
|
9
|
+
from .league_start_rating_optimizer import (
|
|
10
|
+
LeagueStartRatingOptimizationResult as LeagueStartRatingOptimizationResult,
|
|
11
|
+
LeagueStartRatingOptimizer as LeagueStartRatingOptimizer,
|
|
12
|
+
)
|
|
@@ -129,6 +129,9 @@ class PlayerRatingGenerator(RatingGenerator):
|
|
|
129
129
|
str(RatingKnownFeatures.PLAYER_RATING_DIFFERENCE_PROJECTED)
|
|
130
130
|
)
|
|
131
131
|
self.MEAN_PROJ_COL = self._suffix(str(RatingKnownFeatures.RATING_MEAN_PROJECTED))
|
|
132
|
+
self.PLAYER_DIFF_FROM_TEAM_PROJ_COL = self._suffix(
|
|
133
|
+
str(RatingKnownFeatures.PLAYER_RATING_DIFFERENCE_FROM_TEAM_PROJECTED)
|
|
134
|
+
)
|
|
132
135
|
|
|
133
136
|
self.TEAM_OFF_RATING_PROJ_COL = self._suffix(
|
|
134
137
|
str(RatingKnownFeatures.TEAM_OFF_RATING_PROJECTED)
|
|
@@ -618,6 +621,7 @@ class PlayerRatingGenerator(RatingGenerator):
|
|
|
618
621
|
or self.OPP_RATING_PROJ_COL in cols_to_add
|
|
619
622
|
or self.DIFF_PROJ_COL in cols_to_add
|
|
620
623
|
or self.MEAN_PROJ_COL in cols_to_add
|
|
624
|
+
or self.PLAYER_DIFF_FROM_TEAM_PROJ_COL in cols_to_add
|
|
621
625
|
):
|
|
622
626
|
df = add_team_rating_projected(
|
|
623
627
|
df=df,
|
|
@@ -673,6 +677,13 @@ class PlayerRatingGenerator(RatingGenerator):
|
|
|
673
677
|
)
|
|
674
678
|
)
|
|
675
679
|
|
|
680
|
+
if self.PLAYER_DIFF_FROM_TEAM_PROJ_COL in cols_to_add:
|
|
681
|
+
df = df.with_columns(
|
|
682
|
+
(pl.col(self.PLAYER_OFF_RATING_COL) - pl.col(self.TEAM_OFF_RATING_PROJ_COL)).alias(
|
|
683
|
+
self.PLAYER_DIFF_FROM_TEAM_PROJ_COL
|
|
684
|
+
)
|
|
685
|
+
)
|
|
686
|
+
|
|
676
687
|
if (
|
|
677
688
|
self.TEAM_RATING_COL in cols_to_add
|
|
678
689
|
or self.OPP_RATING_COL in cols_to_add
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import copy
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
import narwhals.stable.v2 as nw
|
|
7
|
+
import polars as pl
|
|
8
|
+
from narwhals.stable.v2.typing import IntoFrameT
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
DEFAULT_START_RATING = 1000.0
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class LeagueStartRatingOptimizationResult:
|
|
16
|
+
league_ratings: dict[str, float]
|
|
17
|
+
iteration_errors: list[dict[str, float]]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class LeagueStartRatingOptimizer:
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
rating_generator: object,
|
|
24
|
+
n_iterations: int = 3,
|
|
25
|
+
learning_rate: float = 0.2,
|
|
26
|
+
min_cross_region_rows: int = 10,
|
|
27
|
+
rating_scale: float | None = None,
|
|
28
|
+
):
|
|
29
|
+
self.rating_generator = rating_generator
|
|
30
|
+
self.n_iterations = int(n_iterations)
|
|
31
|
+
self.learning_rate = float(learning_rate)
|
|
32
|
+
self.min_cross_region_rows = int(min_cross_region_rows)
|
|
33
|
+
self.rating_scale = rating_scale
|
|
34
|
+
|
|
35
|
+
@nw.narwhalify
|
|
36
|
+
def optimize(self, df: IntoFrameT) -> LeagueStartRatingOptimizationResult:
|
|
37
|
+
pl_df = df.to_native() if df.implementation.is_polars() else df.to_polars()
|
|
38
|
+
league_ratings = self._get_league_ratings(self.rating_generator)
|
|
39
|
+
iteration_errors: list[dict[str, float]] = []
|
|
40
|
+
|
|
41
|
+
for _ in range(self.n_iterations):
|
|
42
|
+
gen = copy.deepcopy(self.rating_generator)
|
|
43
|
+
self._set_league_ratings(gen, league_ratings)
|
|
44
|
+
self._ensure_prediction_columns(gen)
|
|
45
|
+
|
|
46
|
+
pred_df = gen.fit_transform(pl_df)
|
|
47
|
+
error_df = self._cross_region_error_df(pl_df, pred_df, gen)
|
|
48
|
+
if error_df.is_empty():
|
|
49
|
+
break
|
|
50
|
+
|
|
51
|
+
error_summary = (
|
|
52
|
+
error_df.group_by(self._league_column_name(gen))
|
|
53
|
+
.agg(
|
|
54
|
+
pl.col("error").mean().alias("mean_error"),
|
|
55
|
+
pl.len().alias("row_count"),
|
|
56
|
+
)
|
|
57
|
+
.to_dicts()
|
|
58
|
+
)
|
|
59
|
+
league_key = self._league_column_name(gen)
|
|
60
|
+
iteration_errors.append({r[league_key]: r["mean_error"] for r in error_summary})
|
|
61
|
+
league_ratings = self._apply_error_updates(
|
|
62
|
+
gen, league_ratings, error_summary, league_key
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
self._set_league_ratings(self.rating_generator, league_ratings)
|
|
66
|
+
return LeagueStartRatingOptimizationResult(
|
|
67
|
+
league_ratings=league_ratings, iteration_errors=iteration_errors
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
def _cross_region_error_df(
|
|
71
|
+
self,
|
|
72
|
+
df: pl.DataFrame,
|
|
73
|
+
pred_df: pl.DataFrame,
|
|
74
|
+
rating_generator: object,
|
|
75
|
+
) -> pl.DataFrame:
|
|
76
|
+
column_names = getattr(rating_generator, "column_names", None)
|
|
77
|
+
if column_names is None:
|
|
78
|
+
raise ValueError("rating_generator must define column_names")
|
|
79
|
+
|
|
80
|
+
match_id = getattr(column_names, "match_id", None)
|
|
81
|
+
team_id = getattr(column_names, "team_id", None)
|
|
82
|
+
league_col = getattr(column_names, "league", None)
|
|
83
|
+
if not match_id or not team_id or not league_col:
|
|
84
|
+
raise ValueError("column_names must include match_id, team_id, and league")
|
|
85
|
+
|
|
86
|
+
pred_col, entity_cols, perf_col = self._prediction_spec(rating_generator)
|
|
87
|
+
base_cols = [match_id, team_id, league_col, perf_col]
|
|
88
|
+
for col in base_cols + entity_cols:
|
|
89
|
+
if col not in df.columns:
|
|
90
|
+
raise ValueError(f"{col} missing from input dataframe")
|
|
91
|
+
|
|
92
|
+
join_cols = [match_id, team_id] + entity_cols
|
|
93
|
+
joined = df.select(base_cols + entity_cols).join(
|
|
94
|
+
pred_df.select(join_cols + [pred_col]),
|
|
95
|
+
on=join_cols,
|
|
96
|
+
how="inner",
|
|
97
|
+
)
|
|
98
|
+
opp_league = self._opponent_mode_league(joined, match_id, team_id, league_col)
|
|
99
|
+
enriched = joined.join(opp_league, on=[match_id, team_id], how="left").with_columns(
|
|
100
|
+
(pl.col(perf_col) - pl.col(pred_col)).alias("error")
|
|
101
|
+
)
|
|
102
|
+
return enriched.filter(pl.col("opp_mode_league").is_not_null()).filter(
|
|
103
|
+
pl.col(league_col) != pl.col("opp_mode_league")
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
def _opponent_mode_league(
|
|
107
|
+
self, df: pl.DataFrame, match_id: str, team_id: str, league_col: str
|
|
108
|
+
) -> pl.DataFrame:
|
|
109
|
+
team_mode = (
|
|
110
|
+
df.group_by([match_id, team_id, league_col])
|
|
111
|
+
.agg(pl.len().alias("__count"))
|
|
112
|
+
.sort(["__count"], descending=True)
|
|
113
|
+
.unique([match_id, team_id])
|
|
114
|
+
.select([match_id, team_id, league_col])
|
|
115
|
+
.rename({league_col: "team_mode_league"})
|
|
116
|
+
)
|
|
117
|
+
opponents = (
|
|
118
|
+
team_mode.join(team_mode, on=match_id, suffix="_opp")
|
|
119
|
+
.filter(pl.col(team_id) != pl.col(f"{team_id}_opp"))
|
|
120
|
+
.group_by([match_id, team_id, "team_mode_league_opp"])
|
|
121
|
+
.agg(pl.len().alias("__count"))
|
|
122
|
+
.sort(["__count"], descending=True)
|
|
123
|
+
.unique([match_id, team_id])
|
|
124
|
+
.select([match_id, team_id, "team_mode_league_opp"])
|
|
125
|
+
.rename({"team_mode_league_opp": "opp_mode_league"})
|
|
126
|
+
)
|
|
127
|
+
return opponents
|
|
128
|
+
|
|
129
|
+
def _prediction_spec(self, rating_generator: object) -> tuple[str, list[str], str]:
|
|
130
|
+
perf_col = getattr(rating_generator, "performance_column", None)
|
|
131
|
+
if not perf_col:
|
|
132
|
+
raise ValueError("rating_generator must define performance_column")
|
|
133
|
+
if hasattr(rating_generator, "PLAYER_PRED_PERF_COL"):
|
|
134
|
+
pred_col = rating_generator.PLAYER_PRED_PERF_COL
|
|
135
|
+
column_names = rating_generator.column_names
|
|
136
|
+
player_id = getattr(column_names, "player_id", None)
|
|
137
|
+
if not player_id:
|
|
138
|
+
raise ValueError("column_names must include player_id for player ratings")
|
|
139
|
+
return pred_col, [player_id], perf_col
|
|
140
|
+
if hasattr(rating_generator, "TEAM_PRED_OFF_PERF_COL"):
|
|
141
|
+
pred_col = rating_generator.TEAM_PRED_OFF_PERF_COL
|
|
142
|
+
return pred_col, [], perf_col
|
|
143
|
+
raise ValueError("rating_generator must expose a predicted performance column")
|
|
144
|
+
|
|
145
|
+
def _ensure_prediction_columns(self, rating_generator: object) -> None:
|
|
146
|
+
pred_cols: list[str] = []
|
|
147
|
+
if hasattr(rating_generator, "PLAYER_PRED_PERF_COL"):
|
|
148
|
+
pred_cols.append(rating_generator.PLAYER_PRED_PERF_COL)
|
|
149
|
+
elif hasattr(rating_generator, "TEAM_PRED_OFF_PERF_COL"):
|
|
150
|
+
pred_cols.append(rating_generator.TEAM_PRED_OFF_PERF_COL)
|
|
151
|
+
|
|
152
|
+
if not pred_cols:
|
|
153
|
+
return
|
|
154
|
+
|
|
155
|
+
existing = list(getattr(rating_generator, "non_predictor_features_out", []) or [])
|
|
156
|
+
for col in pred_cols:
|
|
157
|
+
if col not in existing:
|
|
158
|
+
existing.append(col)
|
|
159
|
+
rating_generator.non_predictor_features_out = existing
|
|
160
|
+
|
|
161
|
+
def _apply_error_updates(
|
|
162
|
+
self,
|
|
163
|
+
rating_generator: object,
|
|
164
|
+
league_ratings: dict[str, float],
|
|
165
|
+
error_summary: list[dict[str, float]],
|
|
166
|
+
league_key: str,
|
|
167
|
+
) -> dict[str, float]:
|
|
168
|
+
scale = self.rating_scale
|
|
169
|
+
if scale is None:
|
|
170
|
+
scale = getattr(rating_generator, "rating_change_multiplier_offense", 1.0)
|
|
171
|
+
|
|
172
|
+
updated = dict(league_ratings)
|
|
173
|
+
for row in error_summary:
|
|
174
|
+
if row["row_count"] < self.min_cross_region_rows:
|
|
175
|
+
continue
|
|
176
|
+
league = row[league_key]
|
|
177
|
+
mean_error = row["mean_error"]
|
|
178
|
+
base_rating = updated.get(league, DEFAULT_START_RATING)
|
|
179
|
+
updated[league] = base_rating + self.learning_rate * mean_error * scale
|
|
180
|
+
return updated
|
|
181
|
+
|
|
182
|
+
def _league_column_name(self, rating_generator: object) -> str:
|
|
183
|
+
column_names = getattr(rating_generator, "column_names", None)
|
|
184
|
+
league_col = getattr(column_names, "league", None)
|
|
185
|
+
if not league_col:
|
|
186
|
+
raise ValueError("column_names must include league for league adjustments")
|
|
187
|
+
return league_col
|
|
188
|
+
|
|
189
|
+
def _get_league_ratings(self, rating_generator: object) -> dict[str, float]:
|
|
190
|
+
start_gen = getattr(rating_generator, "start_rating_generator", None)
|
|
191
|
+
if start_gen is None or not hasattr(start_gen, "league_ratings"):
|
|
192
|
+
raise ValueError("rating_generator must define start_rating_generator.league_ratings")
|
|
193
|
+
return dict(start_gen.league_ratings)
|
|
194
|
+
|
|
195
|
+
def _set_league_ratings(self, rating_generator: object, league_ratings: dict[str, float]) -> None:
|
|
196
|
+
start_gen = getattr(rating_generator, "start_rating_generator", None)
|
|
197
|
+
if start_gen is None or not hasattr(start_gen, "league_ratings"):
|
|
198
|
+
raise ValueError("rating_generator must define start_rating_generator.league_ratings")
|
|
199
|
+
start_gen.league_ratings = dict(league_ratings)
|
|
200
|
+
if hasattr(rating_generator, "start_league_ratings"):
|
|
201
|
+
rating_generator.start_league_ratings = dict(league_ratings)
|