spforge 0.8.4__py3-none-any.whl → 0.8.7__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.
@@ -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 5-8 core parameters that have the most impact on performance.
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
@@ -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)