spforge 0.8.8__py3-none-any.whl → 0.8.19__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.
- spforge/autopipeline.py +169 -5
- spforge/estimator/_group_by_estimator.py +11 -3
- spforge/hyperparameter_tuning/__init__.py +2 -0
- spforge/hyperparameter_tuning/_default_search_spaces.py +38 -23
- spforge/hyperparameter_tuning/_tuner.py +55 -2
- spforge/performance_transformers/_performance_manager.py +2 -4
- spforge/ratings/_player_rating.py +131 -28
- spforge/ratings/start_rating_generator.py +1 -1
- spforge/ratings/team_start_rating_generator.py +1 -1
- spforge/ratings/utils.py +16 -6
- spforge/scorer/_score.py +42 -11
- spforge/transformers/_other_transformer.py +38 -8
- {spforge-0.8.8.dist-info → spforge-0.8.19.dist-info}/METADATA +1 -1
- {spforge-0.8.8.dist-info → spforge-0.8.19.dist-info}/RECORD +25 -23
- {spforge-0.8.8.dist-info → spforge-0.8.19.dist-info}/WHEEL +1 -1
- tests/end_to_end/test_nba_player_ratings_hyperparameter_tuning.py +0 -4
- tests/hyperparameter_tuning/test_rating_tuner.py +157 -0
- tests/performance_transformers/test_performance_manager.py +15 -0
- tests/ratings/test_player_rating_generator.py +127 -0
- tests/ratings/test_player_rating_no_mutation.py +214 -0
- tests/ratings/test_utils_scaled_weights.py +136 -0
- tests/scorer/test_score.py +142 -0
- tests/test_autopipeline.py +336 -6
- {spforge-0.8.8.dist-info → spforge-0.8.19.dist-info}/licenses/LICENSE +0 -0
- {spforge-0.8.8.dist-info → spforge-0.8.19.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""Tests to ensure utility functions use scaled participation weights when available."""
|
|
2
|
+
|
|
3
|
+
import polars as pl
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from spforge import ColumnNames
|
|
7
|
+
from spforge.ratings.utils import (
|
|
8
|
+
_SCALED_PPW,
|
|
9
|
+
add_team_rating_projected,
|
|
10
|
+
add_rating_mean_projected,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@pytest.fixture
|
|
15
|
+
def column_names():
|
|
16
|
+
return ColumnNames(
|
|
17
|
+
player_id="pid",
|
|
18
|
+
team_id="tid",
|
|
19
|
+
match_id="mid",
|
|
20
|
+
start_date="dt",
|
|
21
|
+
projected_participation_weight="ppw",
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@pytest.fixture
|
|
26
|
+
def df_with_scaled():
|
|
27
|
+
"""DataFrame with both raw and scaled projected participation weights."""
|
|
28
|
+
return pl.DataFrame({
|
|
29
|
+
"pid": ["A", "B", "C", "D"],
|
|
30
|
+
"tid": ["T1", "T1", "T2", "T2"],
|
|
31
|
+
"mid": ["M1", "M1", "M1", "M1"],
|
|
32
|
+
"dt": ["2024-01-01"] * 4,
|
|
33
|
+
"rating": [1100.0, 900.0, 1050.0, 950.0],
|
|
34
|
+
"ppw": [20.0, 5.0, 10.0, 10.0], # Raw weights (would give wrong answer)
|
|
35
|
+
_SCALED_PPW: [1.0, 0.5, 1.0, 1.0], # Scaled/clipped weights
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@pytest.fixture
|
|
40
|
+
def df_without_scaled():
|
|
41
|
+
"""DataFrame with only raw projected participation weights (no scaled column)."""
|
|
42
|
+
return pl.DataFrame({
|
|
43
|
+
"pid": ["A", "B", "C", "D"],
|
|
44
|
+
"tid": ["T1", "T1", "T2", "T2"],
|
|
45
|
+
"mid": ["M1", "M1", "M1", "M1"],
|
|
46
|
+
"dt": ["2024-01-01"] * 4,
|
|
47
|
+
"rating": [1100.0, 900.0, 1050.0, 950.0],
|
|
48
|
+
"ppw": [0.8, 0.4, 1.0, 1.0], # Already scaled weights
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_add_team_rating_projected_uses_scaled_column(column_names, df_with_scaled):
|
|
53
|
+
"""add_team_rating_projected should use _SCALED_PPW when available."""
|
|
54
|
+
result = add_team_rating_projected(
|
|
55
|
+
df=df_with_scaled,
|
|
56
|
+
column_names=column_names,
|
|
57
|
+
player_rating_col="rating",
|
|
58
|
+
team_rating_out="team_rating",
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
# With scaled weights (1.0, 0.5), T1 team rating = (1100*1.0 + 900*0.5) / (1.0+0.5) = 1450/1.5 = 966.67
|
|
62
|
+
# If it used raw weights (20.0, 5.0), it would be (1100*20 + 900*5) / 25 = 26500/25 = 1060
|
|
63
|
+
t1_rating = result.filter(pl.col("tid") == "T1")["team_rating"][0]
|
|
64
|
+
|
|
65
|
+
expected_with_scaled = (1100.0 * 1.0 + 900.0 * 0.5) / (1.0 + 0.5)
|
|
66
|
+
wrong_with_raw = (1100.0 * 20.0 + 900.0 * 5.0) / (20.0 + 5.0)
|
|
67
|
+
|
|
68
|
+
assert t1_rating == pytest.approx(expected_with_scaled, rel=1e-6)
|
|
69
|
+
assert t1_rating != pytest.approx(wrong_with_raw, rel=1e-6)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_add_team_rating_projected_falls_back_to_raw(column_names, df_without_scaled):
|
|
73
|
+
"""add_team_rating_projected should use raw ppw when _SCALED_PPW is not available."""
|
|
74
|
+
result = add_team_rating_projected(
|
|
75
|
+
df=df_without_scaled,
|
|
76
|
+
column_names=column_names,
|
|
77
|
+
player_rating_col="rating",
|
|
78
|
+
team_rating_out="team_rating",
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
# With raw weights (0.8, 0.4), T1 team rating = (1100*0.8 + 900*0.4) / (0.8+0.4) = 1240/1.2 = 1033.33
|
|
82
|
+
t1_rating = result.filter(pl.col("tid") == "T1")["team_rating"][0]
|
|
83
|
+
|
|
84
|
+
expected = (1100.0 * 0.8 + 900.0 * 0.4) / (0.8 + 0.4)
|
|
85
|
+
assert t1_rating == pytest.approx(expected, rel=1e-6)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_add_rating_mean_projected_uses_scaled_column(column_names, df_with_scaled):
|
|
89
|
+
"""add_rating_mean_projected should use _SCALED_PPW when available."""
|
|
90
|
+
result = add_rating_mean_projected(
|
|
91
|
+
df=df_with_scaled,
|
|
92
|
+
column_names=column_names,
|
|
93
|
+
player_rating_col="rating",
|
|
94
|
+
rating_mean_out="mean_rating",
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
# With scaled weights, mean = (1100*1.0 + 900*0.5 + 1050*1.0 + 950*1.0) / (1.0+0.5+1.0+1.0)
|
|
98
|
+
# = (1100 + 450 + 1050 + 950) / 3.5 = 3550/3.5 = 1014.29
|
|
99
|
+
mean_rating = result["mean_rating"][0]
|
|
100
|
+
|
|
101
|
+
expected_with_scaled = (1100.0*1.0 + 900.0*0.5 + 1050.0*1.0 + 950.0*1.0) / (1.0+0.5+1.0+1.0)
|
|
102
|
+
wrong_with_raw = (1100.0*20.0 + 900.0*5.0 + 1050.0*10.0 + 950.0*10.0) / (20.0+5.0+10.0+10.0)
|
|
103
|
+
|
|
104
|
+
assert mean_rating == pytest.approx(expected_with_scaled, rel=1e-6)
|
|
105
|
+
assert mean_rating != pytest.approx(wrong_with_raw, rel=1e-6)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def test_add_rating_mean_projected_falls_back_to_raw(column_names, df_without_scaled):
|
|
109
|
+
"""add_rating_mean_projected should use raw ppw when _SCALED_PPW is not available."""
|
|
110
|
+
result = add_rating_mean_projected(
|
|
111
|
+
df=df_without_scaled,
|
|
112
|
+
column_names=column_names,
|
|
113
|
+
player_rating_col="rating",
|
|
114
|
+
rating_mean_out="mean_rating",
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
# With raw weights (0.8, 0.4, 1.0, 1.0)
|
|
118
|
+
mean_rating = result["mean_rating"][0]
|
|
119
|
+
|
|
120
|
+
expected = (1100.0*0.8 + 900.0*0.4 + 1050.0*1.0 + 950.0*1.0) / (0.8+0.4+1.0+1.0)
|
|
121
|
+
assert mean_rating == pytest.approx(expected, rel=1e-6)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def test_scaled_weights_not_in_output(column_names, df_with_scaled):
|
|
125
|
+
"""Verify utility functions don't add scaled columns to output unnecessarily."""
|
|
126
|
+
result = add_team_rating_projected(
|
|
127
|
+
df=df_with_scaled,
|
|
128
|
+
column_names=column_names,
|
|
129
|
+
player_rating_col="rating",
|
|
130
|
+
team_rating_out="team_rating",
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
# The scaled column should still be present (it was in input)
|
|
134
|
+
# but no new internal columns should be added
|
|
135
|
+
assert _SCALED_PPW in result.columns
|
|
136
|
+
assert "team_rating" in result.columns
|
tests/scorer/test_score.py
CHANGED
|
@@ -2138,3 +2138,145 @@ def test_scorers_respect_validation_column(scorer_factory, df_factory):
|
|
|
2138
2138
|
score_all = scorer_factory().score(df)
|
|
2139
2139
|
score_valid = scorer_factory().score(df_valid)
|
|
2140
2140
|
assert score_all == score_valid
|
|
2141
|
+
|
|
2142
|
+
|
|
2143
|
+
# ============================================================================
|
|
2144
|
+
# PWMSE evaluation_labels Extension Tests
|
|
2145
|
+
# ============================================================================
|
|
2146
|
+
|
|
2147
|
+
|
|
2148
|
+
@pytest.mark.parametrize("df_type", [pl.DataFrame, pd.DataFrame])
|
|
2149
|
+
def test_pwmse__evaluation_labels_extends_predictions(df_type):
|
|
2150
|
+
"""PWMSE with evaluation_labels as superset extends predictions with small probs."""
|
|
2151
|
+
df = create_dataframe(
|
|
2152
|
+
df_type,
|
|
2153
|
+
{
|
|
2154
|
+
"pred": [
|
|
2155
|
+
[0.3, 0.5, 0.2],
|
|
2156
|
+
[0.2, 0.6, 0.2],
|
|
2157
|
+
],
|
|
2158
|
+
"target": [0, 1],
|
|
2159
|
+
},
|
|
2160
|
+
)
|
|
2161
|
+
|
|
2162
|
+
scorer = PWMSE(
|
|
2163
|
+
pred_column="pred",
|
|
2164
|
+
target="target",
|
|
2165
|
+
labels=[0, 1, 2],
|
|
2166
|
+
evaluation_labels=[-1, 0, 1, 2, 3],
|
|
2167
|
+
)
|
|
2168
|
+
score = scorer.score(df)
|
|
2169
|
+
|
|
2170
|
+
n_eval_labels = 5
|
|
2171
|
+
eps = 1e-5
|
|
2172
|
+
preds_original = np.array([[0.3, 0.5, 0.2], [0.2, 0.6, 0.2]])
|
|
2173
|
+
extended = np.full((2, n_eval_labels), eps, dtype=np.float64)
|
|
2174
|
+
extended[:, 1] = preds_original[:, 0]
|
|
2175
|
+
extended[:, 2] = preds_original[:, 1]
|
|
2176
|
+
extended[:, 3] = preds_original[:, 2]
|
|
2177
|
+
row_sums = extended.sum(axis=1, keepdims=True)
|
|
2178
|
+
preds_renorm = extended / row_sums
|
|
2179
|
+
|
|
2180
|
+
eval_labels = np.array([-1, 0, 1, 2, 3], dtype=np.float64)
|
|
2181
|
+
targets = np.array([0, 1], dtype=np.float64)
|
|
2182
|
+
diffs_sqd = (eval_labels[None, :] - targets[:, None]) ** 2
|
|
2183
|
+
expected = float((diffs_sqd * preds_renorm).sum(axis=1).mean())
|
|
2184
|
+
|
|
2185
|
+
assert abs(score - expected) < 1e-10
|
|
2186
|
+
|
|
2187
|
+
|
|
2188
|
+
@pytest.mark.parametrize("df_type", [pl.DataFrame, pd.DataFrame])
|
|
2189
|
+
def test_pwmse__evaluation_labels_exact_match(df_type):
|
|
2190
|
+
"""PWMSE with evaluation_labels identical to labels (no-op)."""
|
|
2191
|
+
df = create_dataframe(
|
|
2192
|
+
df_type,
|
|
2193
|
+
{
|
|
2194
|
+
"pred": [
|
|
2195
|
+
[0.3, 0.5, 0.2],
|
|
2196
|
+
[0.2, 0.6, 0.2],
|
|
2197
|
+
],
|
|
2198
|
+
"target": [0, 1],
|
|
2199
|
+
},
|
|
2200
|
+
)
|
|
2201
|
+
|
|
2202
|
+
scorer_with_eval = PWMSE(
|
|
2203
|
+
pred_column="pred",
|
|
2204
|
+
target="target",
|
|
2205
|
+
labels=[0, 1, 2],
|
|
2206
|
+
evaluation_labels=[0, 1, 2],
|
|
2207
|
+
)
|
|
2208
|
+
scorer_without_eval = PWMSE(
|
|
2209
|
+
pred_column="pred",
|
|
2210
|
+
target="target",
|
|
2211
|
+
labels=[0, 1, 2],
|
|
2212
|
+
)
|
|
2213
|
+
|
|
2214
|
+
score_with = scorer_with_eval.score(df)
|
|
2215
|
+
score_without = scorer_without_eval.score(df)
|
|
2216
|
+
|
|
2217
|
+
assert abs(score_with - score_without) < 1e-10
|
|
2218
|
+
|
|
2219
|
+
|
|
2220
|
+
@pytest.mark.parametrize("df_type", [pl.DataFrame, pd.DataFrame])
|
|
2221
|
+
def test_pwmse__evaluation_labels_partial_overlap_raises(df_type):
|
|
2222
|
+
"""PWMSE with partial overlap between labels and evaluation_labels raises."""
|
|
2223
|
+
with pytest.raises(ValueError, match="evaluation_labels must be a subset or superset"):
|
|
2224
|
+
PWMSE(
|
|
2225
|
+
pred_column="pred",
|
|
2226
|
+
target="target",
|
|
2227
|
+
labels=[0, 1, 2],
|
|
2228
|
+
evaluation_labels=[1, 2, 3],
|
|
2229
|
+
)
|
|
2230
|
+
|
|
2231
|
+
|
|
2232
|
+
@pytest.mark.parametrize("df_type", [pl.DataFrame, pd.DataFrame])
|
|
2233
|
+
def test_pwmse__evaluation_labels_extends_with_compare_to_naive(df_type):
|
|
2234
|
+
"""PWMSE extension mode works correctly with compare_to_naive."""
|
|
2235
|
+
df = create_dataframe(
|
|
2236
|
+
df_type,
|
|
2237
|
+
{
|
|
2238
|
+
"pred": [
|
|
2239
|
+
[0.8, 0.15, 0.05],
|
|
2240
|
+
[0.1, 0.7, 0.2],
|
|
2241
|
+
[0.05, 0.15, 0.8],
|
|
2242
|
+
[0.3, 0.4, 0.3],
|
|
2243
|
+
],
|
|
2244
|
+
"target": [0, 1, 2, 1],
|
|
2245
|
+
},
|
|
2246
|
+
)
|
|
2247
|
+
|
|
2248
|
+
scorer = PWMSE(
|
|
2249
|
+
pred_column="pred",
|
|
2250
|
+
target="target",
|
|
2251
|
+
labels=[0, 1, 2],
|
|
2252
|
+
evaluation_labels=[-1, 0, 1, 2, 3],
|
|
2253
|
+
compare_to_naive=True,
|
|
2254
|
+
)
|
|
2255
|
+
score = scorer.score(df)
|
|
2256
|
+
|
|
2257
|
+
n_eval_labels = 5
|
|
2258
|
+
eps = 1e-5
|
|
2259
|
+
preds_original = np.array([
|
|
2260
|
+
[0.8, 0.15, 0.05],
|
|
2261
|
+
[0.1, 0.7, 0.2],
|
|
2262
|
+
[0.05, 0.15, 0.8],
|
|
2263
|
+
[0.3, 0.4, 0.3],
|
|
2264
|
+
])
|
|
2265
|
+
extended = np.full((4, n_eval_labels), eps, dtype=np.float64)
|
|
2266
|
+
extended[:, 1] = preds_original[:, 0]
|
|
2267
|
+
extended[:, 2] = preds_original[:, 1]
|
|
2268
|
+
extended[:, 3] = preds_original[:, 2]
|
|
2269
|
+
row_sums = extended.sum(axis=1, keepdims=True)
|
|
2270
|
+
preds_renorm = extended / row_sums
|
|
2271
|
+
|
|
2272
|
+
eval_labels = np.array([-1, 0, 1, 2, 3], dtype=np.float64)
|
|
2273
|
+
targets = np.array([0, 1, 2, 1], dtype=np.float64)
|
|
2274
|
+
diffs_sqd = (eval_labels[None, :] - targets[:, None]) ** 2
|
|
2275
|
+
model_score = float((diffs_sqd * preds_renorm).sum(axis=1).mean())
|
|
2276
|
+
|
|
2277
|
+
naive_probs = np.array([0.0, 0.25, 0.5, 0.25, 0.0])
|
|
2278
|
+
naive_preds = np.tile(naive_probs, (4, 1))
|
|
2279
|
+
naive_score = float((diffs_sqd * naive_preds).sum(axis=1).mean())
|
|
2280
|
+
|
|
2281
|
+
expected = naive_score - model_score
|
|
2282
|
+
assert abs(score - expected) < 1e-10
|
tests/test_autopipeline.py
CHANGED
|
@@ -12,6 +12,7 @@ from sklearn.linear_model import LinearRegression, LogisticRegression
|
|
|
12
12
|
|
|
13
13
|
from spforge import AutoPipeline
|
|
14
14
|
from spforge.estimator import SkLearnEnhancerEstimator
|
|
15
|
+
from spforge.scorer import Filter, Operator
|
|
15
16
|
from spforge.transformers import EstimatorTransformer
|
|
16
17
|
|
|
17
18
|
|
|
@@ -231,6 +232,27 @@ def test_predict_proba(df_clf):
|
|
|
231
232
|
assert np.allclose(proba.sum(axis=1), 1.0, atol=1e-6)
|
|
232
233
|
|
|
233
234
|
|
|
235
|
+
def test_filter_columns_not_passed_to_estimator(frame):
|
|
236
|
+
df_pd = pd.DataFrame(
|
|
237
|
+
{"x": [1.0, 2.0, 3.0, 4.0], "keep": [1, 0, 1, 0], "y": [1.0, 2.0, 3.0, 4.0]}
|
|
238
|
+
)
|
|
239
|
+
df = df_pd if frame == "pd" else pl.from_pandas(df_pd)
|
|
240
|
+
|
|
241
|
+
model = AutoPipeline(
|
|
242
|
+
estimator=CaptureEstimator(),
|
|
243
|
+
estimator_features=["x"],
|
|
244
|
+
filters=[Filter(column_name="keep", value=1, operator=Operator.EQUALS)],
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
X = _select(df, ["x", "keep"])
|
|
248
|
+
y = _col(df, "y")
|
|
249
|
+
model.fit(X, y=y)
|
|
250
|
+
|
|
251
|
+
est = _inner_estimator(model)
|
|
252
|
+
assert "keep" in model.required_features
|
|
253
|
+
assert "keep" not in est.fit_columns
|
|
254
|
+
|
|
255
|
+
|
|
234
256
|
def test_predict_proba_raises_if_not_supported(df_reg):
|
|
235
257
|
model = AutoPipeline(
|
|
236
258
|
estimator=LinearRegression(),
|
|
@@ -306,7 +328,18 @@ def test_infer_categorical_from_feature_names_when_only_numeric_features_given(d
|
|
|
306
328
|
assert any(c.startswith("cat") for c in cap.fit_columns)
|
|
307
329
|
|
|
308
330
|
|
|
309
|
-
def test_granularity_groups_rows_before_estimator_fit_and_predict(
|
|
331
|
+
def test_granularity_groups_rows_before_estimator_fit_and_predict(frame):
|
|
332
|
+
df_pd = pd.DataFrame(
|
|
333
|
+
{
|
|
334
|
+
"gameid": ["g1", "g1", "g2", "g2", "g3", "g3"],
|
|
335
|
+
"num1": [1.0, 2.0, np.nan, 4.0, 5.0, 6.0],
|
|
336
|
+
"num2": [10.0, 20.0, 30.0, 40.0, np.nan, 60.0],
|
|
337
|
+
"cat1": ["a", "b", "a", None, "b", "c"],
|
|
338
|
+
"y": [1.0, 1.0, 2.0, 2.0, 3.0, 3.0],
|
|
339
|
+
}
|
|
340
|
+
)
|
|
341
|
+
df = df_pd if frame == "pd" else pl.from_pandas(df_pd)
|
|
342
|
+
|
|
310
343
|
model = AutoPipeline(
|
|
311
344
|
estimator=CaptureEstimator(),
|
|
312
345
|
estimator_features=["gameid", "num1", "num2", "cat1"],
|
|
@@ -317,16 +350,16 @@ def test_granularity_groups_rows_before_estimator_fit_and_predict(df_reg):
|
|
|
317
350
|
remainder="drop",
|
|
318
351
|
)
|
|
319
352
|
|
|
320
|
-
X = _select(
|
|
321
|
-
y = _col(
|
|
353
|
+
X = _select(df, ["gameid", "num1", "num2", "cat1"])
|
|
354
|
+
y = _col(df, "y")
|
|
322
355
|
model.fit(X, y=y)
|
|
323
356
|
|
|
324
357
|
inner = _inner_estimator(model)
|
|
325
358
|
|
|
326
|
-
if isinstance(
|
|
327
|
-
n_groups =
|
|
359
|
+
if isinstance(df, pl.DataFrame):
|
|
360
|
+
n_groups = df.select(pl.col("gameid").n_unique()).item()
|
|
328
361
|
else:
|
|
329
|
-
n_groups =
|
|
362
|
+
n_groups = df["gameid"].nunique()
|
|
330
363
|
|
|
331
364
|
assert inner.fit_shape[0] == n_groups
|
|
332
365
|
|
|
@@ -551,3 +584,300 @@ def test_autopipeline_is_picklable_after_fit():
|
|
|
551
584
|
model.fit(df, y)
|
|
552
585
|
|
|
553
586
|
pickle.dumps(model)
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
# --- Feature Importances Tests ---
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
def test_feature_importances__tree_model():
|
|
593
|
+
from sklearn.ensemble import RandomForestRegressor
|
|
594
|
+
|
|
595
|
+
df = pd.DataFrame(
|
|
596
|
+
{
|
|
597
|
+
"num1": [1.0, 2.0, 3.0, 4.0, 5.0, 6.0],
|
|
598
|
+
"num2": [10.0, 20.0, 30.0, 40.0, 50.0, 60.0],
|
|
599
|
+
"cat1": ["a", "b", "a", "b", "a", "b"],
|
|
600
|
+
}
|
|
601
|
+
)
|
|
602
|
+
y = pd.Series([1.0, 2.0, 3.0, 4.0, 5.0, 6.0], name="y")
|
|
603
|
+
|
|
604
|
+
model = AutoPipeline(
|
|
605
|
+
estimator=RandomForestRegressor(n_estimators=5, random_state=42),
|
|
606
|
+
estimator_features=["num1", "num2", "cat1"],
|
|
607
|
+
categorical_handling="ordinal",
|
|
608
|
+
)
|
|
609
|
+
model.fit(df, y)
|
|
610
|
+
|
|
611
|
+
importances = model.feature_importances_
|
|
612
|
+
|
|
613
|
+
assert isinstance(importances, pd.DataFrame)
|
|
614
|
+
assert list(importances.columns) == ["feature", "importance"]
|
|
615
|
+
assert len(importances) == 3
|
|
616
|
+
assert set(importances["feature"].tolist()) == {"num1", "num2", "cat1"}
|
|
617
|
+
assert all(importances["importance"] >= 0)
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
def test_feature_importances__linear_model():
|
|
621
|
+
df = pd.DataFrame(
|
|
622
|
+
{
|
|
623
|
+
"num1": [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0],
|
|
624
|
+
"num2": [10.0, 20.0, 30.0, 40.0, 50.0, 60.0, 70.0, 80.0],
|
|
625
|
+
}
|
|
626
|
+
)
|
|
627
|
+
y = pd.Series([0, 1, 0, 1, 0, 1, 0, 1], name="y")
|
|
628
|
+
|
|
629
|
+
model = AutoPipeline(
|
|
630
|
+
estimator=LogisticRegression(max_iter=1000),
|
|
631
|
+
estimator_features=["num1", "num2"],
|
|
632
|
+
scale_features=True,
|
|
633
|
+
)
|
|
634
|
+
model.fit(df, y)
|
|
635
|
+
|
|
636
|
+
importances = model.feature_importances_
|
|
637
|
+
|
|
638
|
+
assert isinstance(importances, pd.DataFrame)
|
|
639
|
+
assert list(importances.columns) == ["feature", "importance"]
|
|
640
|
+
assert len(importances) == 2
|
|
641
|
+
assert set(importances["feature"].tolist()) == {"num1", "num2"}
|
|
642
|
+
assert all(importances["importance"] >= 0)
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
def test_feature_importances__not_fitted_raises():
|
|
646
|
+
model = AutoPipeline(
|
|
647
|
+
estimator=LinearRegression(),
|
|
648
|
+
estimator_features=["x"],
|
|
649
|
+
)
|
|
650
|
+
|
|
651
|
+
with pytest.raises(RuntimeError, match="Pipeline not fitted"):
|
|
652
|
+
_ = model.feature_importances_
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
def test_feature_importances__unsupported_estimator_raises():
|
|
656
|
+
df = pd.DataFrame({"x": [1.0, 2.0, 3.0, 4.0]})
|
|
657
|
+
y = pd.Series([1.0, 2.0, 3.0, 4.0], name="y")
|
|
658
|
+
|
|
659
|
+
model = AutoPipeline(
|
|
660
|
+
estimator=DummyRegressor(),
|
|
661
|
+
estimator_features=["x"],
|
|
662
|
+
)
|
|
663
|
+
model.fit(df, y)
|
|
664
|
+
|
|
665
|
+
with pytest.raises(RuntimeError, match="does not support feature importances"):
|
|
666
|
+
_ = model.feature_importances_
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
def test_feature_importances__with_sklearn_enhancer():
|
|
670
|
+
from sklearn.ensemble import RandomForestRegressor
|
|
671
|
+
|
|
672
|
+
df = pd.DataFrame(
|
|
673
|
+
{
|
|
674
|
+
"num1": [1.0, 2.0, 3.0, 4.0, 5.0, 6.0],
|
|
675
|
+
"num2": [10.0, 20.0, 30.0, 40.0, 50.0, 60.0],
|
|
676
|
+
"start_date": ["2022-01-01", "2022-01-02", "2022-01-03", "2022-01-04", "2022-01-05", "2022-01-06"],
|
|
677
|
+
}
|
|
678
|
+
)
|
|
679
|
+
y = pd.Series([1.0, 2.0, 3.0, 4.0, 5.0, 6.0], name="y")
|
|
680
|
+
|
|
681
|
+
inner = RandomForestRegressor(n_estimators=5, random_state=42)
|
|
682
|
+
enhancer = SkLearnEnhancerEstimator(
|
|
683
|
+
estimator=inner,
|
|
684
|
+
date_column="start_date",
|
|
685
|
+
day_weight_epsilon=0.1,
|
|
686
|
+
)
|
|
687
|
+
|
|
688
|
+
model = AutoPipeline(
|
|
689
|
+
estimator=enhancer,
|
|
690
|
+
estimator_features=["num1", "num2"],
|
|
691
|
+
)
|
|
692
|
+
model.fit(df, y)
|
|
693
|
+
|
|
694
|
+
importances = model.feature_importances_
|
|
695
|
+
|
|
696
|
+
assert isinstance(importances, pd.DataFrame)
|
|
697
|
+
assert list(importances.columns) == ["feature", "importance"]
|
|
698
|
+
assert len(importances) == 2
|
|
699
|
+
assert set(importances["feature"].tolist()) == {"num1", "num2"}
|
|
700
|
+
|
|
701
|
+
|
|
702
|
+
def test_feature_importances__onehot_features():
|
|
703
|
+
from sklearn.ensemble import RandomForestRegressor
|
|
704
|
+
|
|
705
|
+
df = pd.DataFrame(
|
|
706
|
+
{
|
|
707
|
+
"num1": [1.0, 2.0, 3.0, 4.0, 5.0, 6.0],
|
|
708
|
+
"cat1": ["a", "b", "c", "a", "b", "c"],
|
|
709
|
+
}
|
|
710
|
+
)
|
|
711
|
+
y = pd.Series([1.0, 2.0, 3.0, 4.0, 5.0, 6.0], name="y")
|
|
712
|
+
|
|
713
|
+
model = AutoPipeline(
|
|
714
|
+
estimator=RandomForestRegressor(n_estimators=5, random_state=42),
|
|
715
|
+
estimator_features=["num1", "cat1"],
|
|
716
|
+
categorical_handling="onehot",
|
|
717
|
+
)
|
|
718
|
+
model.fit(df, y)
|
|
719
|
+
|
|
720
|
+
importances = model.feature_importances_
|
|
721
|
+
|
|
722
|
+
assert isinstance(importances, pd.DataFrame)
|
|
723
|
+
assert list(importances.columns) == ["feature", "importance"]
|
|
724
|
+
# Should have expanded features: num1 + cat1_a, cat1_b, cat1_c
|
|
725
|
+
assert len(importances) == 4
|
|
726
|
+
assert "num1" in importances["feature"].tolist()
|
|
727
|
+
assert any("cat1_" in f for f in importances["feature"].tolist())
|
|
728
|
+
|
|
729
|
+
|
|
730
|
+
def test_feature_importance_names__granularity_uses_deep_feature_names():
|
|
731
|
+
from sklearn.ensemble import RandomForestRegressor
|
|
732
|
+
|
|
733
|
+
df = pd.DataFrame(
|
|
734
|
+
{
|
|
735
|
+
"gameid": ["g1", "g1", "g2", "g2"],
|
|
736
|
+
"num1": [1.0, 2.0, 3.0, 4.0],
|
|
737
|
+
"num2": [10.0, 20.0, 30.0, 40.0],
|
|
738
|
+
"y": [1.0, 1.0, 2.0, 2.0],
|
|
739
|
+
}
|
|
740
|
+
)
|
|
741
|
+
y = df["y"]
|
|
742
|
+
|
|
743
|
+
model = AutoPipeline(
|
|
744
|
+
estimator=RandomForestRegressor(n_estimators=5, random_state=42),
|
|
745
|
+
estimator_features=["gameid", "num1", "num2"],
|
|
746
|
+
predictor_transformers=[AddConstantPredictionTransformer(col_name="const_pred")],
|
|
747
|
+
granularity=["gameid"],
|
|
748
|
+
categorical_features=["gameid"],
|
|
749
|
+
categorical_handling="ordinal",
|
|
750
|
+
remainder="drop",
|
|
751
|
+
)
|
|
752
|
+
model.fit(df, y)
|
|
753
|
+
|
|
754
|
+
names = model.feature_importance_names
|
|
755
|
+
|
|
756
|
+
inner = _inner_estimator(model)
|
|
757
|
+
assert list(names.keys()) == list(inner.feature_names_in_)
|
|
758
|
+
assert "gameid" not in names
|
|
759
|
+
assert "const_pred" in names
|
|
760
|
+
|
|
761
|
+
|
|
762
|
+
@pytest.mark.parametrize("frame", ["pd", "pl"])
|
|
763
|
+
def test_granularity_with_aggregation_weight__features_weighted(frame):
|
|
764
|
+
df_pd = pd.DataFrame(
|
|
765
|
+
{
|
|
766
|
+
"gameid": ["g1", "g1", "g2", "g2"],
|
|
767
|
+
"num1": [10.0, 30.0, 20.0, 40.0],
|
|
768
|
+
"weight": [0.25, 0.75, 0.5, 0.5],
|
|
769
|
+
"y": [1.0, 1.0, 2.0, 2.0],
|
|
770
|
+
}
|
|
771
|
+
)
|
|
772
|
+
df = df_pd if frame == "pd" else pl.from_pandas(df_pd)
|
|
773
|
+
|
|
774
|
+
cap = CaptureEstimator()
|
|
775
|
+
model = AutoPipeline(
|
|
776
|
+
estimator=cap,
|
|
777
|
+
estimator_features=["num1"],
|
|
778
|
+
granularity=["gameid"],
|
|
779
|
+
aggregation_weight="weight",
|
|
780
|
+
remainder="drop",
|
|
781
|
+
)
|
|
782
|
+
|
|
783
|
+
X = _select(df, ["gameid", "num1", "weight"])
|
|
784
|
+
y = _col(df, "y")
|
|
785
|
+
model.fit(X, y=y)
|
|
786
|
+
|
|
787
|
+
inner = _inner_estimator(model)
|
|
788
|
+
assert inner.fit_shape[0] == 2
|
|
789
|
+
|
|
790
|
+
preds = model.predict(X)
|
|
791
|
+
assert preds.shape[0] == len(X)
|
|
792
|
+
|
|
793
|
+
|
|
794
|
+
@pytest.mark.parametrize("frame", ["pd", "pl"])
|
|
795
|
+
def test_granularity_aggregation_weight__weighted_mean_correct(frame):
|
|
796
|
+
df_pd = pd.DataFrame(
|
|
797
|
+
{
|
|
798
|
+
"gameid": ["g1", "g1"],
|
|
799
|
+
"num1": [10.0, 30.0],
|
|
800
|
+
"weight": [0.25, 0.75],
|
|
801
|
+
"y": [1.0, 1.0],
|
|
802
|
+
}
|
|
803
|
+
)
|
|
804
|
+
df = df_pd if frame == "pd" else pl.from_pandas(df_pd)
|
|
805
|
+
|
|
806
|
+
from spforge.transformers._other_transformer import GroupByReducer
|
|
807
|
+
|
|
808
|
+
reducer = GroupByReducer(granularity=["gameid"], aggregation_weight="weight")
|
|
809
|
+
transformed = reducer.fit_transform(df)
|
|
810
|
+
|
|
811
|
+
if frame == "pl":
|
|
812
|
+
num1_val = transformed["num1"].to_list()[0]
|
|
813
|
+
else:
|
|
814
|
+
num1_val = transformed["num1"].iloc[0]
|
|
815
|
+
|
|
816
|
+
expected = (10.0 * 0.25 + 30.0 * 0.75) / (0.25 + 0.75)
|
|
817
|
+
assert abs(num1_val - expected) < 1e-6
|
|
818
|
+
|
|
819
|
+
|
|
820
|
+
@pytest.mark.parametrize("frame", ["pd", "pl"])
|
|
821
|
+
def test_reduce_y_raises_when_target_not_uniform_per_group(frame):
|
|
822
|
+
df_pd = pd.DataFrame(
|
|
823
|
+
{
|
|
824
|
+
"gameid": ["g1", "g1"],
|
|
825
|
+
"num1": [10.0, 30.0],
|
|
826
|
+
}
|
|
827
|
+
)
|
|
828
|
+
df = df_pd if frame == "pd" else pl.from_pandas(df_pd)
|
|
829
|
+
|
|
830
|
+
from spforge.transformers._other_transformer import GroupByReducer
|
|
831
|
+
|
|
832
|
+
reducer = GroupByReducer(granularity=["gameid"])
|
|
833
|
+
|
|
834
|
+
y = np.array([1.0, 2.0])
|
|
835
|
+
with pytest.raises(ValueError, match="Target.*must be uniform"):
|
|
836
|
+
reducer.reduce_y(df, y)
|
|
837
|
+
|
|
838
|
+
|
|
839
|
+
@pytest.mark.parametrize("frame", ["pd", "pl"])
|
|
840
|
+
def test_reduce_y_works_when_target_uniform_per_group(frame):
|
|
841
|
+
df_pd = pd.DataFrame(
|
|
842
|
+
{
|
|
843
|
+
"gameid": ["g1", "g1", "g2", "g2"],
|
|
844
|
+
"num1": [10.0, 30.0, 20.0, 40.0],
|
|
845
|
+
}
|
|
846
|
+
)
|
|
847
|
+
df = df_pd if frame == "pd" else pl.from_pandas(df_pd)
|
|
848
|
+
|
|
849
|
+
from spforge.transformers._other_transformer import GroupByReducer
|
|
850
|
+
|
|
851
|
+
reducer = GroupByReducer(granularity=["gameid"])
|
|
852
|
+
|
|
853
|
+
y = np.array([1.0, 1.0, 2.0, 2.0])
|
|
854
|
+
y_out, _ = reducer.reduce_y(df, y)
|
|
855
|
+
|
|
856
|
+
assert len(y_out) == 2
|
|
857
|
+
assert set(y_out) == {1.0, 2.0}
|
|
858
|
+
|
|
859
|
+
|
|
860
|
+
@pytest.mark.parametrize("frame", ["pd", "pl"])
|
|
861
|
+
def test_aggregation_weight_sums_weight_column(frame):
|
|
862
|
+
df_pd = pd.DataFrame(
|
|
863
|
+
{
|
|
864
|
+
"gameid": ["g1", "g1"],
|
|
865
|
+
"num1": [10.0, 30.0],
|
|
866
|
+
"weight": [0.25, 0.75],
|
|
867
|
+
"y": [1.0, 1.0],
|
|
868
|
+
}
|
|
869
|
+
)
|
|
870
|
+
df = df_pd if frame == "pd" else pl.from_pandas(df_pd)
|
|
871
|
+
|
|
872
|
+
from spforge.transformers._other_transformer import GroupByReducer
|
|
873
|
+
|
|
874
|
+
reducer = GroupByReducer(granularity=["gameid"], aggregation_weight="weight")
|
|
875
|
+
transformed = reducer.fit_transform(df)
|
|
876
|
+
|
|
877
|
+
if frame == "pl":
|
|
878
|
+
weight_val = transformed["weight"].to_list()[0]
|
|
879
|
+
else:
|
|
880
|
+
weight_val = transformed["weight"].iloc[0]
|
|
881
|
+
|
|
882
|
+
expected = 0.25 + 0.75
|
|
883
|
+
assert abs(weight_val - expected) < 1e-6
|
|
File without changes
|
|
File without changes
|