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
1 change: 1 addition & 0 deletions ci/test_python_scikit_learn_tests.sh
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
# Support invoking test script outside the script directory
cd "$(dirname "$(realpath "${BASH_SOURCE[0]}")")"/../ || exit 1

export DEPENDENCY_FILE_KEY=test_python_accel_sklearn
source ./ci/test_python_common.sh

rapids-logger "Running scikit-learn tests with cuML acceleration"
Expand Down
1 change: 1 addition & 0 deletions ci/test_python_sklearn_examples.sh
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
cd "$(dirname "$(realpath "${BASH_SOURCE[0]}")")"/../ || exit 1

# Common setup steps shared by Python test jobs
export DEPENDENCY_FILE_KEY=test_python_accel_sklearn
source ./ci/test_python_common.sh

EXITCODE=0
Expand Down
34 changes: 28 additions & 6 deletions dependencies.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,17 @@ files:
- py_version
- test_python
- test_python_xgboost
test_python_accel_sklearn:
output: none
includes:
- cuda_version
- depends_on_cuml
- depends_on_cupy
- depends_on_libcuml
- depends_on_nvforest
- py_version
- test_python
- test_python_accel_sklearn
test_python_dask:
output: none
includes:
Expand Down Expand Up @@ -548,12 +559,6 @@ dependencies:
- umap-learn==0.5.7
- hdbscan==0.8.39
- numpy==1.26
- matrix: {dependencies: "intermediate"}
packages:
- scikit-learn==1.7.2
- umap-learn==0.5.8
- ipython==8.10.0
- hdbscan==0.8.41
Comment thread
jcrist marked this conversation as resolved.
Resolved
- matrix: {dependencies: "nightly"}
packages:
# Pre-release specifier tells pip to accept dev/alpha/beta versions
Expand Down Expand Up @@ -636,6 +641,23 @@ dependencies:
- output_types: conda
packages:
- cuvs==26.6.*,>=0.0.0a0
test_python_accel_sklearn:
# Exact pin the sklearn versions used for running upstream sklearn tests
# in cuml.accel. We exact pin here since our xfail list is tightly coupled
# to the version of sklearn we're running. The code should run with a much
# more flexible set of sklearn dependencies, this pinning is just for CI.
specific:
- output_types: [conda, requirements, pyproject]
matrices:
- matrix: {dependencies: "latest"}
packages:
- scikit-learn==1.8.0
- matrix: {dependencies: "intermediate"}
packages:
- scikit-learn==1.7.2
- matrix: {dependencies: "oldest"}
packages:
- scikit-learn==1.5.0
Comment thread
jcrist marked this conversation as resolved.
Resolved
test_python_dask:
common:
- output_types: [conda, requirements, pyproject]
Expand Down
2 changes: 2 additions & 0 deletions docs/source/cuml-accel/limitations.rst
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,7 @@ LogisticRegression
- If ``warm_start=True``.
- If ``intercept_scaling`` is not ``1``.
- If the deprecated ``multi_class`` parameter is used.
- If a callback is configured with ``set_callbacks``.

ElasticNet
^^^^^^^^^^
Expand Down Expand Up @@ -443,6 +444,7 @@ StandardScaler

- If ``X`` is sparse.
- When run on scikit-learn < 1.8.
- If a callback is configured with ``set_callbacks``.

MinMaxScaler
^^^^^^^^^^^^
Expand Down
49 changes: 44 additions & 5 deletions python/cuml/cuml/accel/estimator_proxy.py
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,20 @@ def _sync_attrs_to_cpu(self) -> None:
f"`{self._cpu_class.__name__}` fitted attributes synced to CPU"
)

def _reset_cpu(self) -> None:
"""Reset `_cpu`, dropping all fitted attributes"""
# XXX: sklearn.clone doesn't copy `_parent_callback_ctx` (but does copy
# other private state like `_skl_callbacks`, `_sklearn_output_config`,
# ...). Since we store the context on the CPU estimator, we want this
# to persist even when resetting the CPU implementation.
cpu = sklearn.clone(self._cpu)
if (
ctx := getattr(self._cpu, "_parent_callback_ctx", None)
) is not None:
cpu._parent_callback_ctx = ctx
self._cpu = cpu
self._synced = False

@classmethod
def _reconstruct_from_cpu(cls, cpu, load_on_gpu=True):
"""Reconstruct a proxy estimator from its CPU counterpart.
Expand All @@ -359,7 +373,7 @@ def _reconstruct_from_cpu(cls, cpu, load_on_gpu=True):
self._gpu = None
else:
# Supported on GPU, clear fit attributes from CPU to release host memory
self._cpu = sklearn.clone(self._cpu)
self._reset_cpu()
else:
# Estimator is unfit or should remain on CPU
self._gpu = None
Expand All @@ -372,6 +386,16 @@ def _call_gpu_method(self, method: str, *args: Any, **kwargs: Any) -> Any:
if args and is_sparse(args[0]) and not self._gpu_supports_sparse:
raise UnsupportedOnGPU("Sparse inputs are not supported")

if getattr(self._cpu, "_skl_callbacks", ()) and method in (
"fit",
"fit_transform",
"fit_predict",
"partial_fit",
):
raise UnsupportedOnGPU(
"Estimators with callbacks are not supported"
)

# Determine the function to call. Check for an override on the proxy class,
# falling back to the GPU class method if one exists.
gpu_func = getattr(self, f"_gpu_{method}", None)
Expand Down Expand Up @@ -426,8 +450,7 @@ def _call_method(self, method: str, *args: Any, **kwargs: Any) -> Any:
# Partial fit on already fit models should reuse existing state.
if self._gpu is not None:
# GPU partial_fit will invalidate CPU state, reset on CPU
self._cpu = sklearn.clone(self._cpu)
self._synced = False
self._reset_cpu()
else:
# Reinitialize GPU model for the current hyperparameters
try:
Expand All @@ -440,8 +463,7 @@ def _call_method(self, method: str, *args: Any, **kwargs: Any) -> Any:
self._gpu = None
else:
# New estimator successfully initialized on GPU, reset on CPU
self._cpu = sklearn.clone(self._cpu)
self._synced = False
self._reset_cpu()

if self._gpu is not None:
# The hyperparameters are supported, try calling the method
Expand All @@ -455,6 +477,10 @@ def _call_method(self, method: str, *args: Any, **kwargs: Any) -> Any:
# Unsupported. If it's a `fit` we need to clear
# the GPU state before falling back to CPU.
if is_fit:
if method == "partial_fit":
# For `partial_fit` we want to sync any existing state
# before dropping the GPU model
self._sync_attrs_to_cpu()
self._gpu = None

if reason is not None:
Expand Down Expand Up @@ -560,6 +586,9 @@ def __getattr__(self, name: str) -> Any:
"an issue: https://github.com/rapidsai/cuml/issues."
) from None
raise
elif name in ("_parent_callback_ctx", "_skl_callbacks"):
# sklearn.callback state always goes through CPU estimator
return getattr(self._cpu, name)
elif is_private:
# Don't proxy magic methods or private attributes by default
raise AttributeError(
Expand All @@ -568,10 +597,17 @@ def __getattr__(self, name: str) -> Any:

return getattr(self._cpu, name)

def _gpu_set_callbacks(self, *callbacks):
self._cpu.set_callbacks(*callbacks)
return self._gpu

def __setattr__(self, name: str, value: Any) -> None:
if name in ("_cpu", "_gpu", "_synced"):
# Internal state
object.__setattr__(self, name, value)
elif name == "_parent_callback_ctx":
# sklearn.callback state always goes through CPU estimator
self._cpu._parent_callback_ctx = value
elif name in self._cpu._get_param_names():
# Hyperparameter, set on CPU
setattr(self._cpu, name, value)
Expand All @@ -590,6 +626,9 @@ def __delattr__(self, name: str) -> None:
if name in ("_cpu", "_gpu", "_synced"):
# Internal state. We never call this, just here for parity.
object.__delattr__(self, name)
elif name == "_parent_callback_ctx":
# sklearn.callback state always goes through CPU estimator
del self._cpu._parent_callback_ctx
else:
# No normal workflow deletes attributes (hyperparameters or otherwise)
# from a sklearn estimator. The sklearn tests do sometimes though.
Expand Down
143 changes: 134 additions & 9 deletions python/cuml/cuml_accel_tests/test_estimator_proxy.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@

SKLEARN_16 = Version(sklearn.__version__) >= Version("1.6.0")
SKLEARN_18 = Version(sklearn.__version__) >= Version("1.8.0.dev0")
SKLEARN_19 = Version(sklearn.__version__) >= Version("1.9.0.dev0")

requires_array_api = pytest.mark.skipif(
not SKLEARN_18,
Expand Down Expand Up @@ -157,19 +158,38 @@ def test_repr():
assert str(model) == str(model._cpu)
assert repr(model) == repr(model._cpu)
# smoketest _repr_mimebundle_. It changes per-call, so can't directly compare
assert isinstance(model._repr_mimebundle_(), dict)
# TODO: On some runs this `_repr_mimebundle_` can error with a
# `UnicodeDecodeError`. I suspect a misconfigured `LC_ALL`/`LANG` on the CI
# machine, but :shrug:. All we care about is that things are plumbed
# properly, so ignoring this error here for now. This is repeated twice
# below as well.
# See https://github.com/rapidsai/cuml/issues/8212.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We're seeing a new issue where the _repr_mimebundle_ call (which is fully sklearn code) now fails on one CI run. I suspect a misconfigured CI machine. For now we're skipping the failure mode that occurs here to move on, opened an issue to track: #8212

try:
mimebundle = model._repr_mimebundle_()
except UnicodeDecodeError:
pass
else:
assert isinstance(mimebundle, dict)


def test_repr_mimebundle():
model = LogisticRegression(C=1.5)
unfitted_html_repr = model._repr_mimebundle_()["text/html"]

X, y = make_classification()
model.fit(X, y)
fitted_html_repr = model._repr_mimebundle_()["text/html"]
# TODO: see https://github.com/rapidsai/cuml/issues/8212
try:
html_repr = model._repr_mimebundle_()["text/html"]
except UnicodeDecodeError:
pass
else:
assert "<span>Not fitted</span>" in html_repr

assert "<span>Not fitted</span>" in unfitted_html_repr
assert "<span>Fitted</span>" in fitted_html_repr
model.fit(X, y)
try:
html_repr = model._repr_mimebundle_()["text/html"]
except UnicodeDecodeError:
pass
else:
assert "<span>Fitted</span>" in html_repr


def test_pipeline_repr():
Expand All @@ -180,8 +200,13 @@ def test_pipeline_repr():
native = Pipeline([("cls", model._cpu)])
assert str(pipe) == str(native)
assert repr(pipe) == repr(native)
# smoketest _repr_mimebundle_. It changes per-call, so can't directly compare
assert isinstance(pipe._repr_mimebundle_(), dict)
# TODO: see https://github.com/rapidsai/cuml/issues/8212
try:
mimebundle = pipe._repr_mimebundle_()
except UnicodeDecodeError:
pass
else:
assert isinstance(mimebundle, dict)


def test_dir():
Expand Down Expand Up @@ -979,3 +1004,103 @@ class Sub(Base):
# A subclass instance still identifies as an instance
assert isinstance(sub_model, Base)
assert isinstance(sub_model, ProxyBase)


@pytest.mark.skipif(not SKLEARN_19, reason="scikit-learn >= 1.9 required")
def test_set_callbacks(capsys):
from sklearn.callback import ProgressBar

bar = ProgressBar()

X, y = make_classification()

# Initial fit on GPU
model = LogisticRegression().fit(X, y)
assert model._gpu is not None

# set_callbacks runs on CPU but doesn't trigger a host migration
model.set_callbacks(bar)
assert model._cpu._skl_callbacks
assert model._gpu is not None

# inference still runs on GPU, callbacks only affect fit
y = model.predict(X)
assert model._gpu is not None
assert not hasattr(model._cpu, "n_features_in_")

# refitting with a callback configured runs on CPU
model.fit(X, y)
assert model._gpu is None

# Check the ProgressBar output something
captured = capsys.readouterr()
assert "LogisticRegression" in captured.out

# resetting callbacks allows GPU execution again
model.set_callbacks()
model.fit(X, y)
assert model._gpu is not None


@pytest.mark.skipif(not SKLEARN_19, reason="scikit-learn >= 1.9 required")
def test_set_callbacks_partial_fit(capsys):
from sklearn.callback import ProgressBar

bar = ProgressBar()

X, _ = make_regression(n_samples=200)
X1, X2 = X[:100], X[100:200]

# Initial fit on GPU
model = StandardScaler().partial_fit(X1)
assert model._gpu is not None
assert model._gpu._internal_model.n_samples_seen_ == 100

# set_callbacks runs on CPU but doesn't trigger a host migration
model.set_callbacks(bar)
assert model._cpu._skl_callbacks
assert model._gpu is not None

# partial_fit leads to a host migration and running on CPU
model.partial_fit(X2)
assert model._gpu is None

# Check the ProgressBar output something
captured = capsys.readouterr()
assert "StandardScaler" in captured.out

# host migration didn't drop fitted state
assert model._cpu.n_samples_seen_ == 200


@pytest.mark.skipif(not SKLEARN_19, reason="scikit-learn >= 1.9 required")
def test_set_callbacks_meta_estimator(capsys):
from sklearn.callback import ProgressBar

X, y = make_classification()
scaler = StandardScaler()
model = LogisticRegression()
pipeline = Pipeline([("scaler", scaler), ("model", model)])

# Non-propagated callbacks on a pipeline don't lead to fallback
bar = ProgressBar(0)
pipeline.set_callbacks(bar)
pipeline.fit(X, y)
assert scaler._gpu is not None
assert model._gpu is not None

# Check the ProgressBar output something
captured = capsys.readouterr()
assert "Pipeline" in captured.out

# Propagated callbacks on a pipeline work, but can lead to fallback
bar = ProgressBar(None)
pipeline.set_callbacks(bar)
pipeline.fit(X, y)
assert scaler._gpu is None
assert model._gpu is None

# Check the ProgressBar output something
captured = capsys.readouterr()
assert "StandardScaler" in captured.out
assert "LogisticRegression" in captured.out
Loading