spforge 0.8.29__tar.gz → 0.8.30__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.29/spforge.egg-info → spforge-0.8.30}/PKG-INFO +1 -1
  2. {spforge-0.8.29 → spforge-0.8.30}/pyproject.toml +1 -1
  3. {spforge-0.8.29 → spforge-0.8.30}/spforge/data_structures.py +4 -0
  4. {spforge-0.8.29 → spforge-0.8.30}/spforge/ratings/_player_rating.py +114 -5
  5. {spforge-0.8.29 → spforge-0.8.30/spforge.egg-info}/PKG-INFO +1 -1
  6. {spforge-0.8.29 → spforge-0.8.30}/tests/ratings/test_player_rating_generator.py +111 -0
  7. {spforge-0.8.29 → spforge-0.8.30}/LICENSE +0 -0
  8. {spforge-0.8.29 → spforge-0.8.30}/MANIFEST.in +0 -0
  9. {spforge-0.8.29 → spforge-0.8.30}/README.md +0 -0
  10. {spforge-0.8.29 → spforge-0.8.30}/examples/__init__.py +0 -0
  11. {spforge-0.8.29 → spforge-0.8.30}/examples/game_level_example.py +0 -0
  12. {spforge-0.8.29 → spforge-0.8.30}/examples/lol/__init__.py +0 -0
  13. {spforge-0.8.29 → spforge-0.8.30}/examples/lol/data/__init__.py +0 -0
  14. {spforge-0.8.29 → spforge-0.8.30}/examples/lol/data/subsample_lol_data.parquet +0 -0
  15. {spforge-0.8.29 → spforge-0.8.30}/examples/lol/data/utils.py +0 -0
  16. {spforge-0.8.29 → spforge-0.8.30}/examples/lol/pipeline_transformer_example.py +0 -0
  17. {spforge-0.8.29 → spforge-0.8.30}/examples/nba/__init__.py +0 -0
  18. {spforge-0.8.29 → spforge-0.8.30}/examples/nba/cross_validation_example.py +0 -0
  19. {spforge-0.8.29 → spforge-0.8.30}/examples/nba/data/__init__.py +0 -0
  20. {spforge-0.8.29 → spforge-0.8.30}/examples/nba/data/game_player_subsample.parquet +0 -0
  21. {spforge-0.8.29 → spforge-0.8.30}/examples/nba/data/utils.py +0 -0
  22. {spforge-0.8.29 → spforge-0.8.30}/examples/nba/feature_engineering_example.py +0 -0
  23. {spforge-0.8.29 → spforge-0.8.30}/examples/nba/game_winner_example.py +0 -0
  24. {spforge-0.8.29 → spforge-0.8.30}/examples/nba/predictor_transformers_example.py +0 -0
  25. {spforge-0.8.29 → spforge-0.8.30}/setup.cfg +0 -0
  26. {spforge-0.8.29 → spforge-0.8.30}/spforge/__init__.py +0 -0
  27. {spforge-0.8.29 → spforge-0.8.30}/spforge/autopipeline.py +0 -0
  28. {spforge-0.8.29 → spforge-0.8.30}/spforge/base_feature_generator.py +0 -0
  29. {spforge-0.8.29 → spforge-0.8.30}/spforge/cross_validator/__init__.py +0 -0
  30. {spforge-0.8.29 → spforge-0.8.30}/spforge/cross_validator/_base.py +0 -0
  31. {spforge-0.8.29 → spforge-0.8.30}/spforge/cross_validator/cross_validator.py +0 -0
  32. {spforge-0.8.29 → spforge-0.8.30}/spforge/distributions/__init__.py +0 -0
  33. {spforge-0.8.29 → spforge-0.8.30}/spforge/distributions/_negative_binomial_estimator.py +0 -0
  34. {spforge-0.8.29 → spforge-0.8.30}/spforge/distributions/_normal_distribution_predictor.py +0 -0
  35. {spforge-0.8.29 → spforge-0.8.30}/spforge/distributions/_student_t_distribution_estimator.py +0 -0
  36. {spforge-0.8.29 → spforge-0.8.30}/spforge/estimator/__init__.py +0 -0
  37. {spforge-0.8.29 → spforge-0.8.30}/spforge/estimator/_conditional_estimator.py +0 -0
  38. {spforge-0.8.29 → spforge-0.8.30}/spforge/estimator/_frequency_bucketing_classifier.py +0 -0
  39. {spforge-0.8.29 → spforge-0.8.30}/spforge/estimator/_granularity_estimator.py +0 -0
  40. {spforge-0.8.29 → spforge-0.8.30}/spforge/estimator/_group_by_estimator.py +0 -0
  41. {spforge-0.8.29 → spforge-0.8.30}/spforge/estimator/_ordinal_classifier.py +0 -0
  42. {spforge-0.8.29 → spforge-0.8.30}/spforge/estimator/_sklearn_enhancer_estimator.py +0 -0
  43. {spforge-0.8.29 → spforge-0.8.30}/spforge/feature_generator/__init__.py +0 -0
  44. {spforge-0.8.29 → spforge-0.8.30}/spforge/feature_generator/_base.py +0 -0
  45. {spforge-0.8.29 → spforge-0.8.30}/spforge/feature_generator/_lag.py +0 -0
  46. {spforge-0.8.29 → spforge-0.8.30}/spforge/feature_generator/_net_over_predicted.py +0 -0
  47. {spforge-0.8.29 → spforge-0.8.30}/spforge/feature_generator/_regressor_feature_generator.py +0 -0
  48. {spforge-0.8.29 → spforge-0.8.30}/spforge/feature_generator/_rolling_against_opponent.py +0 -0
  49. {spforge-0.8.29 → spforge-0.8.30}/spforge/feature_generator/_rolling_mean_binary.py +0 -0
  50. {spforge-0.8.29 → spforge-0.8.30}/spforge/feature_generator/_rolling_mean_days.py +0 -0
  51. {spforge-0.8.29 → spforge-0.8.30}/spforge/feature_generator/_rolling_window.py +0 -0
  52. {spforge-0.8.29 → spforge-0.8.30}/spforge/feature_generator/_utils.py +0 -0
  53. {spforge-0.8.29 → spforge-0.8.30}/spforge/features_generator_pipeline.py +0 -0
  54. {spforge-0.8.29 → spforge-0.8.30}/spforge/hyperparameter_tuning/__init__.py +0 -0
  55. {spforge-0.8.29 → spforge-0.8.30}/spforge/hyperparameter_tuning/_default_search_spaces.py +0 -0
  56. {spforge-0.8.29 → spforge-0.8.30}/spforge/hyperparameter_tuning/_tuner.py +0 -0
  57. {spforge-0.8.29 → spforge-0.8.30}/spforge/performance_transformers/__init__.py +0 -0
  58. {spforge-0.8.29 → spforge-0.8.30}/spforge/performance_transformers/_performance_manager.py +0 -0
  59. {spforge-0.8.29 → spforge-0.8.30}/spforge/performance_transformers/_performances_transformers.py +0 -0
  60. {spforge-0.8.29 → spforge-0.8.30}/spforge/ratings/__init__.py +0 -0
  61. {spforge-0.8.29 → spforge-0.8.30}/spforge/ratings/_base.py +0 -0
  62. {spforge-0.8.29 → spforge-0.8.30}/spforge/ratings/_team_rating.py +0 -0
  63. {spforge-0.8.29 → spforge-0.8.30}/spforge/ratings/enums.py +0 -0
  64. {spforge-0.8.29 → spforge-0.8.30}/spforge/ratings/league_identifier.py +0 -0
  65. {spforge-0.8.29 → spforge-0.8.30}/spforge/ratings/league_start_rating_optimizer.py +0 -0
  66. {spforge-0.8.29 → spforge-0.8.30}/spforge/ratings/player_performance_predictor.py +0 -0
  67. {spforge-0.8.29 → spforge-0.8.30}/spforge/ratings/start_rating_generator.py +0 -0
  68. {spforge-0.8.29 → spforge-0.8.30}/spforge/ratings/team_performance_predictor.py +0 -0
  69. {spforge-0.8.29 → spforge-0.8.30}/spforge/ratings/team_start_rating_generator.py +0 -0
  70. {spforge-0.8.29 → spforge-0.8.30}/spforge/ratings/utils.py +0 -0
  71. {spforge-0.8.29 → spforge-0.8.30}/spforge/scorer/__init__.py +0 -0
  72. {spforge-0.8.29 → spforge-0.8.30}/spforge/scorer/_score.py +0 -0
  73. {spforge-0.8.29 → spforge-0.8.30}/spforge/transformers/__init__.py +0 -0
  74. {spforge-0.8.29 → spforge-0.8.30}/spforge/transformers/_base.py +0 -0
  75. {spforge-0.8.29 → spforge-0.8.30}/spforge/transformers/_net_over_predicted.py +0 -0
  76. {spforge-0.8.29 → spforge-0.8.30}/spforge/transformers/_operator.py +0 -0
  77. {spforge-0.8.29 → spforge-0.8.30}/spforge/transformers/_other_transformer.py +0 -0
  78. {spforge-0.8.29 → spforge-0.8.30}/spforge/transformers/_predictor.py +0 -0
  79. {spforge-0.8.29 → spforge-0.8.30}/spforge/transformers/_simple_transformer.py +0 -0
  80. {spforge-0.8.29 → spforge-0.8.30}/spforge/transformers/_team_ratio_predictor.py +0 -0
  81. {spforge-0.8.29 → spforge-0.8.30}/spforge/utils.py +0 -0
  82. {spforge-0.8.29 → spforge-0.8.30}/spforge.egg-info/SOURCES.txt +0 -0
  83. {spforge-0.8.29 → spforge-0.8.30}/spforge.egg-info/dependency_links.txt +0 -0
  84. {spforge-0.8.29 → spforge-0.8.30}/spforge.egg-info/requires.txt +0 -0
  85. {spforge-0.8.29 → spforge-0.8.30}/spforge.egg-info/top_level.txt +0 -0
  86. {spforge-0.8.29 → spforge-0.8.30}/tests/cross_validator/test_cross_validator.py +0 -0
  87. {spforge-0.8.29 → spforge-0.8.30}/tests/distributions/test_distribution.py +0 -0
  88. {spforge-0.8.29 → spforge-0.8.30}/tests/end_to_end/test_estimator_hyperparameter_tuning.py +0 -0
  89. {spforge-0.8.29 → spforge-0.8.30}/tests/end_to_end/test_league_start_rating_optimizer.py +0 -0
  90. {spforge-0.8.29 → spforge-0.8.30}/tests/end_to_end/test_lol_player_kills.py +0 -0
  91. {spforge-0.8.29 → spforge-0.8.30}/tests/end_to_end/test_nba_player_points.py +0 -0
  92. {spforge-0.8.29 → spforge-0.8.30}/tests/end_to_end/test_nba_player_ratings_hyperparameter_tuning.py +0 -0
  93. {spforge-0.8.29 → spforge-0.8.30}/tests/end_to_end/test_nba_prediction_consistency.py +0 -0
  94. {spforge-0.8.29 → spforge-0.8.30}/tests/estimator/test_sklearn_estimator.py +0 -0
  95. {spforge-0.8.29 → spforge-0.8.30}/tests/feature_generator/test_lag.py +0 -0
  96. {spforge-0.8.29 → spforge-0.8.30}/tests/feature_generator/test_regressor_feature_generator.py +0 -0
  97. {spforge-0.8.29 → spforge-0.8.30}/tests/feature_generator/test_rolling_against_opponent.py +0 -0
  98. {spforge-0.8.29 → spforge-0.8.30}/tests/feature_generator/test_rolling_mean_binary.py +0 -0
  99. {spforge-0.8.29 → spforge-0.8.30}/tests/feature_generator/test_rolling_mean_days.py +0 -0
  100. {spforge-0.8.29 → spforge-0.8.30}/tests/feature_generator/test_rolling_window.py +0 -0
  101. {spforge-0.8.29 → spforge-0.8.30}/tests/hyperparameter_tuning/test_estimator_tuner.py +0 -0
  102. {spforge-0.8.29 → spforge-0.8.30}/tests/hyperparameter_tuning/test_rating_tuner.py +0 -0
  103. {spforge-0.8.29 → spforge-0.8.30}/tests/performance_transformers/test_performance_manager.py +0 -0
  104. {spforge-0.8.29 → spforge-0.8.30}/tests/performance_transformers/test_performances_transformers.py +0 -0
  105. {spforge-0.8.29 → spforge-0.8.30}/tests/ratings/test_player_rating_no_mutation.py +0 -0
  106. {spforge-0.8.29 → spforge-0.8.30}/tests/ratings/test_ratings_property.py +0 -0
  107. {spforge-0.8.29 → spforge-0.8.30}/tests/ratings/test_team_rating_generator.py +0 -0
  108. {spforge-0.8.29 → spforge-0.8.30}/tests/ratings/test_utils_scaled_weights.py +0 -0
  109. {spforge-0.8.29 → spforge-0.8.30}/tests/scorer/test_score.py +0 -0
  110. {spforge-0.8.29 → spforge-0.8.30}/tests/scorer/test_score_aggregation_granularity.py +0 -0
  111. {spforge-0.8.29 → spforge-0.8.30}/tests/scorer/test_scorer_name.py +0 -0
  112. {spforge-0.8.29 → spforge-0.8.30}/tests/test_autopipeline.py +0 -0
  113. {spforge-0.8.29 → spforge-0.8.30}/tests/test_autopipeline_context.py +0 -0
  114. {spforge-0.8.29 → spforge-0.8.30}/tests/test_feature_generator_pipeline.py +0 -0
  115. {spforge-0.8.29 → spforge-0.8.30}/tests/transformers/test_estimator_transformer_context.py +0 -0
  116. {spforge-0.8.29 → spforge-0.8.30}/tests/transformers/test_net_over_predicted.py +0 -0
  117. {spforge-0.8.29 → spforge-0.8.30}/tests/transformers/test_other_transformer.py +0 -0
  118. {spforge-0.8.29 → spforge-0.8.30}/tests/transformers/test_predictor_transformer.py +0 -0
  119. {spforge-0.8.29 → spforge-0.8.30}/tests/transformers/test_simple_transformer.py +0 -0
  120. {spforge-0.8.29 → spforge-0.8.30}/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.29
3
+ Version: 0.8.30
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.29"
7
+ version = "0.8.30"
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"
@@ -12,6 +12,8 @@ class ColumnNames:
12
12
  position: str | None = None
13
13
  participation_weight: str | None = None
14
14
  projected_participation_weight: str | None = None
15
+ defense_participation_weight: str | None = None
16
+ projected_defense_participation_weight: str | None = None
15
17
  update_match_id: str | None = None
16
18
  parent_team_id: str | None = None
17
19
  team_players_playing_time: str | None = None
@@ -81,6 +83,8 @@ class MatchPerformance:
81
83
  performance_value: float | None
82
84
  participation_weight: float | None
83
85
  projected_participation_weight: float
86
+ defense_participation_weight: float | None = None
87
+ projected_defense_participation_weight: float | None = None
84
88
  team_players_playing_time: dict[str, float] | None = None
85
89
  opponent_players_playing_time: dict[str, float] | None = None
86
90
 
@@ -39,6 +39,8 @@ from spforge.feature_generator._utils import to_polars
39
39
  PLAYER_STATS = "__PLAYER_STATS"
40
40
  _SCALED_PW = "__scaled_participation_weight__"
41
41
  _SCALED_PPW = "__scaled_projected_participation_weight__"
42
+ _SCALED_DPW = "__scaled_defense_participation_weight__"
43
+ _SCALED_PDPW = "__scaled_projected_defense_participation_weight__"
42
44
 
43
45
 
44
46
  class PlayerRatingGenerator(RatingGenerator):
@@ -186,6 +188,8 @@ class PlayerRatingGenerator(RatingGenerator):
186
188
  self.auto_scale_participation_weights = bool(auto_scale_participation_weights)
187
189
  self._participation_weight_max: float | None = None
188
190
  self._projected_participation_weight_max: float | None = None
191
+ self._defense_participation_weight_max: float | None = None
192
+ self._projected_defense_participation_weight_max: float | None = None
189
193
 
190
194
  self._player_off_ratings: dict[str, PlayerRating] = {}
191
195
  self._player_def_ratings: dict[str, PlayerRating] = {}
@@ -233,8 +237,11 @@ class PlayerRatingGenerator(RatingGenerator):
233
237
  eps = 1e-6
234
238
  return min_val < -eps or max_val > (1.0 + eps)
235
239
 
236
- if _out_of_bounds(cn.participation_weight) or _out_of_bounds(
237
- cn.projected_participation_weight
240
+ if (
241
+ _out_of_bounds(cn.participation_weight)
242
+ or _out_of_bounds(cn.projected_participation_weight)
243
+ or _out_of_bounds(cn.defense_participation_weight)
244
+ or _out_of_bounds(cn.projected_defense_participation_weight)
238
245
  ):
239
246
  self.scale_participation_weights = True
240
247
  logging.warning(
@@ -289,6 +296,25 @@ class PlayerRatingGenerator(RatingGenerator):
289
296
  elif self._participation_weight_max is not None:
290
297
  self._projected_participation_weight_max = self._participation_weight_max
291
298
 
299
+ if cn.defense_participation_weight and cn.defense_participation_weight in df.columns:
300
+ q_val = pl_df[cn.defense_participation_weight].quantile(0.99, "linear")
301
+ if q_val is not None:
302
+ self._defense_participation_weight_max = float(q_val)
303
+ elif self._participation_weight_max is not None:
304
+ self._defense_participation_weight_max = self._participation_weight_max
305
+
306
+ if (
307
+ cn.projected_defense_participation_weight
308
+ and cn.projected_defense_participation_weight in df.columns
309
+ ):
310
+ q_val = pl_df[cn.projected_defense_participation_weight].quantile(0.99, "linear")
311
+ if q_val is not None:
312
+ self._projected_defense_participation_weight_max = float(q_val)
313
+ elif self._defense_participation_weight_max is not None:
314
+ self._projected_defense_participation_weight_max = self._defense_participation_weight_max
315
+ elif self._projected_participation_weight_max is not None:
316
+ self._projected_defense_participation_weight_max = self._projected_participation_weight_max
317
+
292
318
  def _scale_participation_weight_columns(self, df: pl.DataFrame) -> pl.DataFrame:
293
319
  """Create internal scaled participation weight columns without mutating originals."""
294
320
  if not self.scale_participation_weights:
@@ -321,6 +347,32 @@ class PlayerRatingGenerator(RatingGenerator):
321
347
  .alias(_SCALED_PPW)
322
348
  )
323
349
 
350
+ if (
351
+ cn.defense_participation_weight
352
+ and cn.defense_participation_weight in df.columns
353
+ and self._defense_participation_weight_max is not None
354
+ and self._defense_participation_weight_max > 0
355
+ ):
356
+ denom = float(self._defense_participation_weight_max)
357
+ df = df.with_columns(
358
+ (pl.col(cn.defense_participation_weight) / denom)
359
+ .clip(0.0, 1.0)
360
+ .alias(_SCALED_DPW)
361
+ )
362
+
363
+ if (
364
+ cn.projected_defense_participation_weight
365
+ and cn.projected_defense_participation_weight in df.columns
366
+ and self._projected_defense_participation_weight_max is not None
367
+ and self._projected_defense_participation_weight_max > 0
368
+ ):
369
+ denom = float(self._projected_defense_participation_weight_max)
370
+ df = df.with_columns(
371
+ (pl.col(cn.projected_defense_participation_weight) / denom)
372
+ .clip(0.0, 1.0)
373
+ .alias(_SCALED_PDPW)
374
+ )
375
+
324
376
  return df
325
377
 
326
378
  def _get_participation_weight_col(self) -> str:
@@ -339,7 +391,9 @@ class PlayerRatingGenerator(RatingGenerator):
339
391
 
340
392
  def _remove_internal_scaled_columns(self, df: pl.DataFrame) -> pl.DataFrame:
341
393
  """Remove internal scaled columns before returning."""
342
- cols_to_drop = [c for c in [_SCALED_PW, _SCALED_PPW] if c in df.columns]
394
+ cols_to_drop = [
395
+ c for c in [_SCALED_PW, _SCALED_PPW, _SCALED_DPW, _SCALED_PDPW] if c in df.columns
396
+ ]
343
397
  if cols_to_drop:
344
398
  df = df.drop(cols_to_drop)
345
399
  return df
@@ -554,7 +608,7 @@ class PlayerRatingGenerator(RatingGenerator):
554
608
  def_change = (
555
609
  (def_perf - float(pred_def))
556
610
  * mult_def
557
- * float(pre_player.match_performance.participation_weight)
611
+ * float(pre_player.match_performance.defense_participation_weight)
558
612
  )
559
613
 
560
614
  if math.isnan(off_change) or math.isnan(def_change):
@@ -648,7 +702,7 @@ class PlayerRatingGenerator(RatingGenerator):
648
702
  def_change = (
649
703
  (def_perf - float(pred_def))
650
704
  * mult_def
651
- * float(pre_player.match_performance.participation_weight)
705
+ * float(pre_player.match_performance.defense_participation_weight)
652
706
  )
653
707
 
654
708
  if math.isnan(off_change) or math.isnan(def_change):
@@ -922,6 +976,19 @@ class PlayerRatingGenerator(RatingGenerator):
922
976
  if _SCALED_PPW in df.columns:
923
977
  player_stat_cols.append(_SCALED_PPW)
924
978
 
979
+ if cn.defense_participation_weight and cn.defense_participation_weight in df.columns:
980
+ player_stat_cols.append(cn.defense_participation_weight)
981
+ if _SCALED_DPW in df.columns:
982
+ player_stat_cols.append(_SCALED_DPW)
983
+
984
+ if (
985
+ cn.projected_defense_participation_weight
986
+ and cn.projected_defense_participation_weight in df.columns
987
+ ):
988
+ player_stat_cols.append(cn.projected_defense_participation_weight)
989
+ if _SCALED_PDPW in df.columns:
990
+ player_stat_cols.append(_SCALED_PDPW)
991
+
925
992
  if cn.position and cn.position in df.columns:
926
993
  player_stat_cols.append(cn.position)
927
994
 
@@ -1041,6 +1108,28 @@ class PlayerRatingGenerator(RatingGenerator):
1041
1108
  projected_participation_weight = participation_weight
1042
1109
  projected_participation_weights.append(projected_participation_weight)
1043
1110
 
1111
+ # Use scaled defense participation weight if available, otherwise default to participation_weight
1112
+ if _SCALED_DPW in team_player:
1113
+ defense_participation_weight = team_player.get(_SCALED_DPW, participation_weight)
1114
+ elif cn.defense_participation_weight:
1115
+ defense_participation_weight = team_player.get(
1116
+ cn.defense_participation_weight, participation_weight
1117
+ )
1118
+ else:
1119
+ defense_participation_weight = participation_weight
1120
+
1121
+ # Use scaled projected defense participation weight if available
1122
+ if _SCALED_PDPW in team_player:
1123
+ projected_defense_participation_weight = team_player.get(
1124
+ _SCALED_PDPW, defense_participation_weight
1125
+ )
1126
+ elif cn.projected_defense_participation_weight:
1127
+ projected_defense_participation_weight = team_player.get(
1128
+ cn.projected_defense_participation_weight, defense_participation_weight
1129
+ )
1130
+ else:
1131
+ projected_defense_participation_weight = defense_participation_weight
1132
+
1044
1133
  perf_val = (
1045
1134
  float(team_player[self.performance_column])
1046
1135
  if (
@@ -1061,6 +1150,8 @@ class PlayerRatingGenerator(RatingGenerator):
1061
1150
  performance_value=perf_val,
1062
1151
  projected_participation_weight=projected_participation_weight,
1063
1152
  participation_weight=participation_weight,
1153
+ defense_participation_weight=defense_participation_weight,
1154
+ projected_defense_participation_weight=projected_defense_participation_weight,
1064
1155
  team_players_playing_time=team_playing_time,
1065
1156
  opponent_players_playing_time=opponent_playing_time,
1066
1157
  )
@@ -1296,6 +1387,22 @@ class PlayerRatingGenerator(RatingGenerator):
1296
1387
  ppw = pw
1297
1388
  proj_w.append(float(ppw))
1298
1389
 
1390
+ # Use scaled defense participation weight if available
1391
+ if _SCALED_DPW in tp:
1392
+ dpw = tp.get(_SCALED_DPW, pw)
1393
+ elif cn.defense_participation_weight:
1394
+ dpw = tp.get(cn.defense_participation_weight, pw)
1395
+ else:
1396
+ dpw = pw
1397
+
1398
+ # Use scaled projected defense participation weight if available
1399
+ if _SCALED_PDPW in tp:
1400
+ pdpw = tp.get(_SCALED_PDPW, dpw)
1401
+ elif cn.projected_defense_participation_weight:
1402
+ pdpw = tp.get(cn.projected_defense_participation_weight, dpw)
1403
+ else:
1404
+ pdpw = dpw
1405
+
1299
1406
  team_playing_time = self._get_players_playing_time(
1300
1407
  tp, cn.team_players_playing_time
1301
1408
  )
@@ -1307,6 +1414,8 @@ class PlayerRatingGenerator(RatingGenerator):
1307
1414
  performance_value=get_perf_value(tp),
1308
1415
  projected_participation_weight=ppw,
1309
1416
  participation_weight=pw,
1417
+ defense_participation_weight=dpw,
1418
+ projected_defense_participation_weight=pdpw,
1310
1419
  team_players_playing_time=team_playing_time,
1311
1420
  opponent_players_playing_time=opponent_playing_time,
1312
1421
  )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: spforge
3
- Version: 0.8.29
3
+ Version: 0.8.30
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
@@ -2710,3 +2710,114 @@ def test_ignore_opponent_predictor_reference_rating_set_correctly(base_cn):
2710
2710
  assert gen5._performance_predictor._reference_rating == 1200.0, (
2711
2711
  f"Expected hardcoded start rating 1200.0 to take precedence, got {gen5._performance_predictor._reference_rating}"
2712
2712
  )
2713
+
2714
+
2715
+ def test_separate_offense_defense_participation_weights(base_cn):
2716
+ """Test that offense and defense use separate participation weights.
2717
+
2718
+ When participation_weight represents offensive activity (e.g., shots attempted),
2719
+ using it for both offense and defense updates creates bias. This test verifies
2720
+ that defense_participation_weight is used for defensive rating updates.
2721
+ """
2722
+ from dataclasses import replace
2723
+
2724
+ cn = replace(
2725
+ base_cn,
2726
+ participation_weight="shots_attempted",
2727
+ defense_participation_weight="minutes",
2728
+ )
2729
+
2730
+ # Create a scenario where a high-volume shooter (many shots) faces a low-volume shooter
2731
+ # The high-volume shooter should have larger offensive updates but equal defensive updates
2732
+ df = pl.DataFrame(
2733
+ {
2734
+ "pid": ["P1", "P2", "P3", "P4"],
2735
+ "tid": ["T1", "T1", "T2", "T2"],
2736
+ "mid": ["M1", "M1", "M1", "M1"],
2737
+ "dt": ["2024-01-01"] * 4,
2738
+ "perf": [0.6, 0.4, 0.5, 0.5], # Varying performance values
2739
+ "shots_attempted": [10.0, 10.0, 10.0, 10.0], # Same offensive activity
2740
+ "minutes": [30.0, 30.0, 30.0, 30.0], # Same defensive activity
2741
+ }
2742
+ )
2743
+
2744
+ gen = PlayerRatingGenerator(
2745
+ performance_column="perf",
2746
+ column_names=cn,
2747
+ auto_scale_performance=True,
2748
+ rating_change_multiplier_offense=50,
2749
+ rating_change_multiplier_defense=50,
2750
+ )
2751
+
2752
+ result = gen.fit_transform(df)
2753
+
2754
+ # Verify that the defense_participation_weight column is present in the data
2755
+ assert "minutes" in df.columns
2756
+
2757
+ # All players performed equally (0.5) with equal participation weights,
2758
+ # so ratings should be symmetric
2759
+ assert "P1" in gen._player_off_ratings
2760
+ assert "P1" in gen._player_def_ratings
2761
+
2762
+ # Now test with different participation weights for offense vs defense
2763
+ df2 = pl.DataFrame(
2764
+ {
2765
+ "pid": ["P1", "P2", "P3", "P4"],
2766
+ "tid": ["T1", "T1", "T2", "T2"],
2767
+ "mid": ["M2", "M2", "M2", "M2"],
2768
+ "dt": ["2024-01-02"] * 4,
2769
+ "perf": [0.6, 0.4, 0.5, 0.5],
2770
+ "shots_attempted": [20.0, 5.0, 10.0, 10.0], # P1 shoots much more
2771
+ "minutes": [30.0, 30.0, 30.0, 30.0], # But all play same minutes
2772
+ }
2773
+ )
2774
+
2775
+ result2 = gen.fit_transform(df2)
2776
+
2777
+ # P1 should have larger offensive rating changes due to high shots_attempted
2778
+ # but equal defensive rating changes due to equal minutes played
2779
+ p1_off = gen._player_off_ratings["P1"]
2780
+ p2_off = gen._player_off_ratings["P2"]
2781
+ p1_def = gen._player_def_ratings["P1"]
2782
+ p2_def = gen._player_def_ratings["P2"]
2783
+
2784
+ # Both players have same games_played count for defense
2785
+ assert p1_def.games_played == p2_def.games_played
2786
+
2787
+ # Verify that ratings were updated
2788
+ assert p1_off.games_played > 0
2789
+ assert p2_off.games_played > 0
2790
+
2791
+
2792
+ @pytest.mark.parametrize("library", ["polars", "pandas"])
2793
+ def test_defense_participation_weight_backwards_compatibility(base_cn, library):
2794
+ """Test that when defense_participation_weight is not set, it defaults to participation_weight."""
2795
+ import pandas as pd
2796
+
2797
+ df_data = {
2798
+ "pid": ["P1", "P2", "P3", "P4"],
2799
+ "tid": ["T1", "T1", "T2", "T2"],
2800
+ "mid": ["M1", "M1", "M1", "M1"],
2801
+ "dt": ["2024-01-01"] * 4,
2802
+ "perf": [0.6, 0.4, 0.5, 0.5],
2803
+ "pw": [1.0, 0.5, 0.8, 0.8],
2804
+ }
2805
+
2806
+ if library == "polars":
2807
+ df = pl.DataFrame(df_data)
2808
+ else:
2809
+ df = pd.DataFrame(df_data)
2810
+
2811
+ # When defense_participation_weight is None, it should default to participation_weight
2812
+ gen = PlayerRatingGenerator(
2813
+ performance_column="perf",
2814
+ column_names=base_cn,
2815
+ auto_scale_performance=True,
2816
+ )
2817
+
2818
+ result = gen.fit_transform(df)
2819
+
2820
+ # Should work without errors
2821
+ assert result is not None
2822
+ assert len(gen._player_off_ratings) > 0
2823
+ assert len(gen._player_def_ratings) > 0
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes