Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,22 @@ clf.fit(X, y) # cross-validate all estimators
clf.get_results() # comparison table
```

## Exporting a model (leaving Poniard)

`get_estimator` is the supported way to leave Poniard. It returns a plain
scikit-learn `Pipeline` (or a bare estimator with
`include_preprocessor=False`) with **no poniard references** — you can save it,
deploy it, or keep working on it without Poniard installed:

```python
model = clf.get_estimator("LogisticRegression", retrain=True, X=X, y=y)
# model is a fitted sklearn.pipeline.Pipeline you fully own
```

Without `retrain=True`, the returned pipeline is an unfitted clone you can
inspect. Use it to extract any estimator from the comparison — defaults,
hyperparameter-optimized ones after `tune_estimator`, or ensemble members.

## Plotting

Plotting is a separate module (requires `pip install poniard[plot]`):
Expand Down
22 changes: 15 additions & 7 deletions poniard/estimators/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -844,27 +844,35 @@ def get_estimator(
y: pd.DataFrame | np.ndarray | list | None = None,
retrain: bool = False,
) -> Pipeline | ClassifierMixin | RegressorMixin:
"""Obtain an estimator in `pipelines` by name. This is useful for extracting default
estimators or hyperparmeter-optimized estimators (after using
`PoniardBaseEstimator.tune_estimator`).
"""Export an estimator as a plain scikit-learn object you own.

This is the supported way to leave Poniard: the returned object is a
plain `sklearn.pipeline.Pipeline` (or a bare estimator when
``include_preprocessor=False``) with no poniard references, so you can
save it, deploy it, or continue working on it without Poniard installed.
Use it to extract default estimators or hyperparameter-optimized
estimators (after using `PoniardBaseEstimator.tune_estimator`).

Parameters
----------
estimator_name :
Estimator name.
include_preprocessor :
Whether to return a pipeline with a preprocessor or just the estimator. Default True.
Whether to return a pipeline with a preprocessor or just the
estimator. Default True.
X :
Features. Required if retrain is True.
y :
Target. Required if retrain is True.
retrain :
Whether to retrain with full data. Default False.
Whether to retrain the clone with full data. Pass X and y to get a
fitted pipeline ready to predict. Default False returns an
unfitted clone.

Returns
-------
ClassifierMixin
Estimator.
sklearn.pipeline.Pipeline | ClassifierMixin | RegressorMixin
A plain scikit-learn pipeline or estimator with no poniard references.
"""
model = self.pipelines[estimator_name]
if not include_preprocessor:
Expand Down
116 changes: 116 additions & 0 deletions tests/test_get_estimator.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import os
import pickle
import subprocess
import sys
import textwrap

import numpy as np
import pandas as pd
from sklearn.linear_model import LogisticRegression
from sklearn.pipeline import Pipeline

from poniard import PoniardClassifier


def _data():
n = 50
X = pd.DataFrame(
{
"a": np.random.normal(size=n),
"b": np.random.normal(size=n),
"c": np.random.choice(["x", "y"], size=n),
}
)
y = pd.Series(np.random.choice([0, 1], size=n))
return X, y


def _fitted_clf():
X, y = _data()
clf = PoniardClassifier(
estimators=[LogisticRegression()], cv=2, random_state=0
)
clf.setup(X, y)
clf.fit(X, y)
return clf, X, y


def test_get_estimator_returns_plain_sklearn_pipeline():
clf, X, y = _fitted_clf()
est = clf.get_estimator("LogisticRegression", retrain=True, X=X, y=y)
assert isinstance(est, Pipeline)
assert type(est).__module__.startswith("sklearn")


def test_get_estimator_without_preprocessor_is_bare_estimator():
n = 50
X = pd.DataFrame(np.random.normal(size=(n, 2)), columns=["a", "b"])
y = pd.Series(np.random.choice([0, 1], size=n))
clf = PoniardClassifier(
estimators=[LogisticRegression()], cv=2, random_state=0
)
clf.setup(X, y)
clf.fit(X, y)
est = clf.get_estimator(
"LogisticRegression",
include_preprocessor=False,
retrain=True,
X=X,
y=y,
)
assert isinstance(est, LogisticRegression)


def test_get_estimator_retrain_requires_X_and_y():
clf, _, _ = _fitted_clf()
with np.testing.assert_raises_regex(ValueError, "X and y"):
clf.get_estimator("LogisticRegression", retrain=True)


def test_get_estimator_pickles_without_poniard(tmp_path):
"""A get_estimator() pipeline must pickle and load in a subprocess where
poniard cannot be imported: the real definition of 'you can delete poniard
when you're done'."""
clf, X, y = _fitted_clf()
est = clf.get_estimator("LogisticRegression", retrain=True, X=X, y=y)

model_path = tmp_path / "model.pkl"
X_path = tmp_path / "X.pkl"
with open(model_path, "wb") as f:
pickle.dump(est, f)
with open(X_path, "wb") as f:
pickle.dump(X, f)

code = textwrap.dedent(
"""
import builtins
import pickle
import sys

_real_import = builtins.__import__

def _blocked(name, *args, **kwargs):
if name == "poniard" or name.startswith("poniard."):
raise ModuleNotFoundError("poniard is blocked")
return _real_import(name, *args, **kwargs)

builtins.__import__ = _blocked

from sklearn.pipeline import Pipeline

with open(sys.argv[1], "rb") as f:
est = pickle.load(f)
assert isinstance(est, Pipeline), type(est)
with open(sys.argv[2], "rb") as f:
X = pickle.load(f)
pred = est.predict(X)
assert len(pred) == len(X)
"""
)
env = {k: v for k, v in os.environ.items() if k != "PYTHONPATH"}
subprocess.run(
[sys.executable, "-c", code, str(model_path), str(X_path)],
check=True,
env=env,
cwd=tmp_path,
)
Loading