Skip to content

Commit d8a3324

Browse files
authored
Merge pull request #46 from rxavier/roadmap/4.1-4.3-4.4-robustness
feat: robustness pass - save/load, pristine thresholds, name-based results (roadmap 4.1, 4.3, 4.4)
2 parents e0bb0ff + 057011d commit d8a3324

5 files changed

Lines changed: 164 additions & 12 deletions

File tree

poniard/estimators/core.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from collections.abc import Callable, Iterable
77
from typing import Sequence
88

9+
import joblib
910
import numpy as np
1011
import pandas as pd
1112
from sklearn.base import ClassifierMixin, RegressorMixin, TransformerMixin, clone
@@ -884,6 +885,36 @@ def get_estimator(
884885
model.fit(X, y)
885886
return model
886887

888+
def save(self, path: str | os.PathLike) -> None:
889+
"""Save the fitted estimator to disk with joblib.
890+
891+
Use `PoniardClassifier.load` / `PoniardRegressor.load` to restore it.
892+
A fitted estimator round-trips `fit` → `save` → `load` → `get_results`
893+
without losing results.
894+
895+
Parameters
896+
----------
897+
path :
898+
Where to write the estimator.
899+
"""
900+
joblib.dump(self, path)
901+
902+
@classmethod
903+
def load(cls, path: str | os.PathLike) -> PoniardBaseEstimator:
904+
"""Load an estimator saved with `save`.
905+
906+
Parameters
907+
----------
908+
path :
909+
Location of the saved estimator.
910+
911+
Returns
912+
-------
913+
PoniardBaseEstimator
914+
The restored estimator.
915+
"""
916+
return joblib.load(path)
917+
887918
def _train_test_split_from_cv(self, X, y):
888919
"""Split data in a 80/20 fashion following the cross-validation strategy defined in the constructor."""
889920
if isinstance(self.cv, (int, Iterable)):

poniard/estimators/results.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,8 +66,10 @@ def _process_results(self) -> None:
6666
]
6767
means = results.apply(lambda x: np.mean(np.stack(x.values), axis=1))
6868
stds = results.apply(lambda x: np.std(np.stack(x.values), axis=1))
69-
means = means[list(means.columns[2:]) + ["fit_time", "score_time"]]
70-
stds = stds[list(stds.columns[2:]) + ["fit_time", "score_time"]]
69+
time_columns = ["fit_time", "score_time"]
70+
metric_columns = [c for c in means.columns if c not in time_columns]
71+
means = means[metric_columns + time_columns]
72+
stds = stds[metric_columns + time_columns]
7173
self._means = means.sort_values(means.columns[0], ascending=False)
7274
self._stds = stds.reindex(self._means.index)
7375

poniard/preprocessing/core.py

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -349,10 +349,16 @@ def _infer_dtypes(self) -> tuple[list, list, list, list]:
349349
categorical_low: list = []
350350
datetime_cols: list = []
351351

352-
if not isinstance(self.cardinality_threshold, int):
353-
self.cardinality_threshold = int(self.cardinality_threshold * X.shape[0])
354-
if not isinstance(self.numeric_threshold, int):
355-
self.numeric_threshold = int(self.numeric_threshold * X.shape[0])
352+
cardinality_threshold = (
353+
self.cardinality_threshold
354+
if isinstance(self.cardinality_threshold, int)
355+
else int(self.cardinality_threshold * X.shape[0])
356+
)
357+
numeric_threshold = (
358+
self.numeric_threshold
359+
if isinstance(self.numeric_threshold, int)
360+
else int(self.numeric_threshold * X.shape[0])
361+
)
356362

357363
if isinstance(X, pd.DataFrame):
358364
for col in X.columns:
@@ -362,15 +368,15 @@ def _infer_dtypes(self) -> tuple[list, list, list, list]:
362368
if pd.api.types.is_datetime64_any_dtype(dtype):
363369
datetime_cols.append(col)
364370
elif pd.api.types.is_numeric_dtype(dtype):
365-
if nunique > self.numeric_threshold:
371+
if nunique > numeric_threshold:
366372
numeric.append(col)
367-
elif nunique > self.cardinality_threshold:
373+
elif nunique > cardinality_threshold:
368374
categorical_high.append(col)
369375
else:
370376
categorical_low.append(col)
371377
else:
372378
# strings, objects, categorical, boolean
373-
if nunique > self.cardinality_threshold:
379+
if nunique > cardinality_threshold:
374380
categorical_high.append(col)
375381
else:
376382
categorical_low.append(col)
@@ -381,14 +387,14 @@ def _infer_dtypes(self) -> tuple[list, list, list, list]:
381387
if np.issubdtype(col.dtype, np.datetime64):
382388
datetime_cols.append(i)
383389
elif np.issubdtype(col.dtype, np.number):
384-
if nunique > self.numeric_threshold:
390+
if nunique > numeric_threshold:
385391
numeric.append(i)
386-
elif nunique > self.cardinality_threshold:
392+
elif nunique > cardinality_threshold:
387393
categorical_high.append(i)
388394
else:
389395
categorical_low.append(i)
390396
else:
391-
if nunique > self.cardinality_threshold:
397+
if nunique > cardinality_threshold:
392398
categorical_high.append(i)
393399
else:
394400
categorical_low.append(i)

tests/test_meaningful.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,23 @@ def test_numeric_threshold_as_float(self):
184184
preprocessor.build(X=X, y=y, task="classification")
185185
assert "many_ints" in preprocessor.feature_types["numeric"]
186186

187+
def test_float_thresholds_stay_pristine_after_build(self):
188+
"""build() must not convert the float thresholds to ints in place, so a
189+
second build() on different data uses the same constructor values."""
190+
preprocessor = PoniardPreprocessor(
191+
numeric_threshold=0.5,
192+
cardinality_threshold=0.5,
193+
)
194+
X1 = pd.DataFrame({"a": np.arange(20)})
195+
y = np.zeros(20, dtype=int)
196+
preprocessor.build(X=X1, y=y, task="classification")
197+
assert preprocessor.numeric_threshold == 0.5
198+
assert preprocessor.cardinality_threshold == 0.5
199+
X2 = pd.DataFrame({"a": np.arange(100)})
200+
preprocessor.build(X=X2, y=y, task="classification")
201+
assert preprocessor.numeric_threshold == 0.5
202+
assert preprocessor.cardinality_threshold == 0.5
203+
187204

188205
# ===========================================================================
189206
# 2. Preprocessing tests

tests/test_save_load.py

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import numpy as np
2+
import pandas as pd
3+
from sklearn.linear_model import LinearRegression, LogisticRegression
4+
5+
from poniard import PoniardClassifier, PoniardRegressor
6+
7+
8+
def _clf_data():
9+
n = 60
10+
X = pd.DataFrame(
11+
{
12+
"a": np.random.normal(size=n),
13+
"b": np.random.normal(size=n),
14+
"c": np.random.choice(["x", "y", "z"], size=n),
15+
}
16+
)
17+
y = pd.Series(np.random.choice([0, 1], size=n))
18+
return X, y
19+
20+
21+
def _reg_data():
22+
X = pd.DataFrame(np.random.normal(size=(60, 3)), columns=["a", "b", "c"])
23+
y = pd.Series(np.random.normal(size=60))
24+
return X, y
25+
26+
27+
def test_save_load_round_trip_classifier(tmp_path):
28+
X, y = _clf_data()
29+
clf = PoniardClassifier(
30+
estimators=[LogisticRegression()], cv=2, random_state=0
31+
)
32+
clf.setup(X, y)
33+
clf.fit(X, y)
34+
results_before = clf.get_results()
35+
36+
path = tmp_path / "clf.joblib"
37+
clf.save(path)
38+
loaded = PoniardClassifier.load(path)
39+
40+
assert isinstance(loaded, PoniardClassifier)
41+
pd.testing.assert_frame_equal(loaded.get_results(), results_before)
42+
assert loaded._experiment_results.keys() == clf._experiment_results.keys()
43+
44+
45+
def test_save_load_round_trip_regressor(tmp_path):
46+
X, y = _reg_data()
47+
reg = PoniardRegressor(
48+
estimators=[LinearRegression()], cv=2, random_state=0
49+
)
50+
reg.setup(X, y)
51+
reg.fit(X, y)
52+
results_before = reg.get_results()
53+
54+
path = tmp_path / "reg.joblib"
55+
reg.save(path)
56+
loaded = PoniardRegressor.load(path)
57+
58+
assert isinstance(loaded, PoniardRegressor)
59+
pd.testing.assert_frame_equal(loaded.get_results(), results_before)
60+
61+
62+
def test_loaded_estimator_can_export_pipeline(tmp_path):
63+
X, y = _clf_data()
64+
clf = PoniardClassifier(
65+
estimators=[LogisticRegression()], cv=2, random_state=0
66+
)
67+
clf.setup(X, y)
68+
clf.fit(X, y)
69+
path = tmp_path / "clf.joblib"
70+
clf.save(path)
71+
loaded = PoniardClassifier.load(path)
72+
model = loaded.get_estimator(
73+
"LogisticRegression", retrain=True, X=X, y=y
74+
)
75+
assert len(model.predict(X)) == len(X)
76+
77+
78+
def test_save_load_after_tuning(tmp_path):
79+
X, y = _clf_data()
80+
clf = PoniardClassifier(
81+
estimators=[LogisticRegression()], cv=2, random_state=0
82+
)
83+
clf.setup(X, y)
84+
clf.fit(X, y)
85+
clf.tune_estimator(
86+
"LogisticRegression",
87+
X,
88+
y,
89+
grid={"LogisticRegression__C": [0.1, 1.0]},
90+
)
91+
clf.fit(X, y)
92+
path = tmp_path / "tuned.joblib"
93+
clf.save(path)
94+
loaded = PoniardClassifier.load(path)
95+
assert "LogisticRegression_tuned" in loaded.pipelines
96+
assert loaded.get_results().shape[0] == 3

0 commit comments

Comments
 (0)