cuML Python Estimators Developer Guide#
This guide documents the patterns expected for new or updated cuml.Base estimators.
Note: Start with the Quick Start Guide and copyable estimator skeleton. The later sections explain the estimator contract in more detail for less common cases.
Table of Contents#
Recommended Scikit-Learn Documentation#
Read these scikit-learn references first; cuML estimators follow them unless this guide says otherwise:
-
Pay particular attention to:
Scikit-learn’s Docstring Guide
Follow this for array-like objects, shapes, dtypes, and default values.
API Matching Policy#
cuML often implements GPU-accelerated versions of estimators from CPU libraries, especially scikit-learn. Match the source API when it is useful, but do not copy API surface blindly.
Match the original API where possible and reasonable
Use identical parameter names, types, and default values
Keep method signatures and return types consistent
Maintain the same behavior and semantics where possible
Justify and document API deviations
Document all API deviations clearly
Avoid arbitrary deviations from the original API
For example, if the original library uses a parameter named
n_neighbor, we should not arbitrarily change it ton_neighborsin our implementation.Explain necessary deviations with in-code comments
Do not add unused parameters just for parity
Omit source-library parameters that cuML cannot use, unless backwards compatibility requires keeping an existing parameter.
Exact API matching is not required
Consumers who need exact API matching should use
cuml.accelFocus on providing a consistent and intuitive API rather than exact matching
Prioritize performance and GPU-specific optimizations over exact API matching
Exact API matching is not required, but arbitrary deviations are not permitted.
Quick Start Guide#
At a high level, all cuML Estimators must:
Inherit from
cuml.Basefrom cuml.internals.base import Base class MyEstimator(Base): ...
Follow the Scikit-learn estimator developer guidelines
Include the
Base.__init__()arguments available in the new Estimator’s__init__()from cuml.internals.base import Base class MyEstimator(Base): def __init__(self, *, extra_arg=True, verbose=False, output_type=None): super().__init__(verbose=verbose, output_type=output_type) ...
Declare each public array-like attribute the new Estimator will compute as a class variable for automatic array type conversion.
from cuml.internals import ReflectedAttr from cuml.internals.base import Base class MyEstimator(Base): labels_ = ReflectedAttr() def __init__(self): ...
Use the
@mlfuncdecorator on public API methods that return arrays. Use@mlfunc(set_input_type=True)on fit-like methods in combination with the appropriatecuml.internals.validationhelpers. Any inference method that returns an array withn_samplesrows (aligned with the inputX) should use@mlfunc(preserve_index=True)to align the output with the input’s index (if any). Other methods should use plain@mlfunc:from cuml.internals import mlfunc from cuml.internals.base import Base class MyEstimator(Base): @mlfunc(set_input_type=True) def fit(self, X) -> "MyEstimator": ... @mlfunc(preserve_index=True) def predict(self, X): ...
See Estimator Methods for detailed guidance on when to use
@mlfunc.Implement
_get_param_names()including values returned bysuper()._get_param_names()@classmethod def _get_param_names(cls): return super()._get_param_names() + [ "eps", "min_samples", ]
Override estimator tags only when the defaults are wrong. Prefer existing mixins for common capabilities such as preferred input order, sparse support, string input, or NaN support. See Estimator Tags and cuML-Specific Tags for custom tag overrides.
Support pickle round trips both before and after fitting. Add the applicable coverage to
python/cuml/tests/test_pickle.py.
For most estimators, the checklist and skeleton below are enough. The later sections explain the contract and uncommon cases.
Copyable Estimator Skeleton#
Use this as a starting point for dense estimators that follow the standard cuML pattern:
from cuml.internals import mlfunc, ReflectedAttr
from cuml.internals.base import Base
from cuml.internals.validation import check_inputs, check_is_fitted
class MyEstimator(Base):
result_ = ReflectedAttr()
def __init__(self, *, extra_arg=True, verbose=False, output_type=None):
super().__init__(verbose=verbose, output_type=output_type)
self.extra_arg = extra_arg
@classmethod
def _get_param_names(cls):
return [*super()._get_param_names(), "extra_arg"]
@mlfunc(set_input_type=True)
def fit(self, X) -> "MyEstimator":
X = check_inputs(self, X, order="A", reset=True)
# Replace this placeholder with estimator training.
self.result_ = X
return self
@mlfunc(preserve_index=True)
def transform(self, X):
check_is_fitted(self)
X = check_inputs(self, X, order="A")
# Return an array-like object directly; @mlfunc handles conversion.
return X
Fit-like methods should use @mlfunc(set_input_type=True) in combination with
the appropriate validation helpers. Pass reset=True to validation when
fitting. Methods that return scalars may use @mlfunc(convert_output=False) to
fully disable the output conversion code path, though a plain @mlfunc would
work as well (no conversion happens for scalars anyway).
Background#
Array I/O and Output Types in cuML#
cuML estimators should validate public inputs with cuml.internals.validation
helpers such as check_inputs, check_array, check_y, and
check_sample_weight. Prefer check_inputs for estimator methods that
validate X and optional y/sample_weight values. These helpers accept the
standard cuML array-like inputs, apply estimator feature metadata checks, and
normalize data to standard CuPy/NumPy or sparse array containers. Sparse
estimators should configure the validation helpers for their supported sparse
formats or follow the sparse-specific validation utilities used by neighboring
sparse estimators.
Internally, dense array data should usually be processed as the standard arrays
returned by validation. Low-level code that needs a specific memory location
should request it explicitly through validation arguments such as mem_type.
Public output type conversion is handled by @mlfunc and ReflectedAttr.
Users choose output types in three ways:
Set
output_typeon an estimator, for exampleMyEstimator(output_type="numpy").Set a global override with
cuml.set_global_output_type("numpy").Temporarily set a global override with
cuml.using_output_type("numpy").
An explicit global output type such as "numpy" or "cupy" takes precedence
over an estimator’s output_type. The global "input" setting is a legacy
exception: reflected methods still respect an explicit estimator output type,
while descriptor attributes mirror the fit-time input type. When neither a
global nor estimator output type is set, reflected estimator methods normally
mirror the call input type, and descriptor attributes mirror the fit-time input
type.
Accepted output types are:
None: No global or estimator override. Reflected estimator methods infer from their input or fit-time input type."input": Mirror the relevant input type."cupy": Return a CuPy array."numpy": Return a NumPy array."cudf": Return a cuDF Series or DataFrame."pandas": Return a pandas Series or DataFrame.
The internal output type "cuml" may appear inside reflected calls.
User-facing code should not set it.
Ingesting Arrays#
When the input array type is not known, the correct and safest way to validate
estimator inputs is using cuml.internals.validation. For estimator methods
that validate X and optional y or sample_weight, prefer check_inputs;
it handles feature metadata, dtype conversion, array order, sparse support,
length checks, and fit-vs-inference validation consistently with scikit-learn.
Omit y or sample_weight when the method does not validate those inputs.
from cuml.internals import mlfunc
from cuml.internals.validation import check_inputs, check_is_fitted
@mlfunc(set_input_type=True)
def fit(self, X, y):
X, y = check_inputs(
self,
X,
y,
dtype=("float32", "float64"),
order="A",
reset=True,
)
rows, cols = X.shape
dtype = X.dtype
...
@mlfunc(preserve_index=True)
def transform(self, X):
check_is_fitted(self)
X = check_inputs(
self,
X,
dtype=self.result_.dtype,
order="A",
)
...
Use lower-level helpers directly when a method has non-standard inputs:
check_array for a standalone array, check_y for targets, check_cudf for
dataframe-oriented paths, and check_all_finite or check_non_negative for
specialized checks not already covered by the higher-level helpers.
Returning Arrays#
Return CuPy or NumPy arrays directly from reflected methods. Methods that support sparse results may likewise return the corresponding CuPy or SciPy sparse arrays. The reflection machinery will coerce supported arrays and nested containers to the proper output type. Specialized outputs, such as classifier labels that may have non-numeric dtypes, should use the wrappers described below.
Estimator Design#
All cuml.Base estimators follow the scikit-learn estimator
contract plus the
cuML-specific rules below.
Initialization#
All estimators should accept verbose and output_type, and pass them to
super().__init__().
Constructor parameters should be keyword-only unless the matched source API uses positional parameters. This reduces breaking changes when parameters are added or removed:
# For an estimator that matches scikit-learn's API where the eps argument can
# be positional:
def __init__(self, eps=0.5, *, min_samples=5, max_mbytes_per_batch=None,
calc_core_sample_indices=True, verbose=False, output_type=None):
super().__init__(verbose=verbose, output_type=output_type)
self.eps = eps
self.min_samples = min_samples
self.max_mbytes_per_batch = max_mbytes_per_batch
self.calc_core_sample_indices = calc_core_sample_indices
# For an estimator that doesn't match any existing API:
def __init__(self, *, eps=0.5, min_samples=5, max_mbytes_per_batch=None,
calc_core_sample_indices=True, verbose=False, output_type=None):
super().__init__(verbose=verbose, output_type=output_type)
self.eps = eps
self.min_samples = min_samples
self.max_mbytes_per_batch = max_mbytes_per_batch
self.calc_core_sample_indices = calc_core_sample_indices
Store constructor arguments exactly as passed. Do not normalize, validate, or
convert them in __init__; doing so breaks cloning. See scikit-learn’s
instantiation
guidance
for details.
For example, the following __init__ shows what NOT to do:
def __init__(self, my_option="option1"):
if (my_option == "option1"):
self.my_option = 1
else:
self.my_option = 2
Instead, save my_option as-is and derive any normalized value later, usually
in fit() or a private helper.
Implementing _get_param_names()#
To support cloning, implement _get_param_names(). It should return
constructor parameter names, including names from super()._get_param_names().
Base.get_params() reads these attributes and passes them to a new estimator
constructor, so every returned name must be accepted by __init__().
@classmethod
def _get_param_names(cls):
return [
*super()._get_param_names(),
"eps",
"min_samples",
]
Do not omit super()._get_param_names(); it includes base estimator parameters
such as verbose and output_type.
Estimator Array-Like Attributes#
Array-like fitted attributes should use cuml.internals.ReflectedAttr so
user-facing attribute reads respect cuML output-type settings. Values assigned
to a ReflectedAttr must be NumPy or CuPy arrays, their corresponding sparse
array types, ArrayIndexPair objects, or supported nested containers of those
types. Do not assign pandas or cuDF objects directly; validation helpers should
first normalize them to NumPy or CuPy arrays.
Internally, a descriptor behaves like a normal attribute and returns the value that was set. Externally, it lazily converts the value to the requested output type and caches repeated conversions.
Lazy conversion reduces unnecessary memory use, but benchmarks must account for the first attribute read if they need to include conversion cost.
Defining Array-Like Attributes#
Declare descriptor-managed attributes as class variables.
from cuml.internals import ReflectedAttr
from cuml.internals.base import Base
class TestEstimator(Base):
# Class variables outside of any function
my_cuml_array_ = ReflectedAttr()
def __init__(self, ...):
...
Working with ReflectedAttr#
Once a descriptor attribute is defined, use it like a normal attribute inside estimator methods:
import cupy as cp
from cuml.internals import mlfunc, ReflectedAttr
from cuml.internals.base import Base
from cuml.internals.validation import check_inputs
class SampleEstimator(Base):
# Class variables outside of any function
my_array_ = ReflectedAttr()
my_other_array_ = ReflectedAttr()
@mlfunc(set_input_type=True)
def fit(self, X):
# reset=True on check_inputs sets n_features_in_ and feature_names_in_
X = check_inputs(self, X, order="A", reset=True)
# Set descriptor-managed fitted attributes with validated arrays
# When accessed in any `mlfunc`-decorated method, these will have the
# same type they were originally set with.
self.my_array_ = X
self.my_other_array_ = cp.ones((10, 10))
return self
Inside cuML code, descriptor attributes return the stored value. To
intentionally read one with a specific output type, use
cuml.using_output_type():
@mlfunc
def score(self):
# Set the global output type to numpy
with cuml.using_output_type("numpy"):
# Accessing my_other_array_ will return a numpy array and
# the result can be returned directly
return np.sum(self.my_other_array_, axis=0)
This uses the same lazy conversion and caching path as external user reads.
ReflectedAttr External Functionality#
Externally, descriptor attributes lazily convert to the active output type:
import cupy as cp
import numpy as np
my_est = SampleEstimator()
# Call fit() with a numpy array as the input
np_arr = np.ones((10, 1))
my_est.fit(np_arr) # This will load data into attributes
# Externally, descriptors reflect the fit-time input type by default
print(type(my_est.my_array_)) # Output: NumPy (saved from the input of `fit`)
# Calling fit again with cupy arrays, will have a similar effect
my_est.fit(cp.ones((10, 1)))
print(type(my_est.my_array_)) # Output: CuPy
# Setting the `output_type` will change all descriptor properties
# and ignore the input type
my_est.output_type = "cudf"
# Reading any of the attributes will convert the type lazily
print(type(my_est.my_array_)) # Output: cuDF object
# A global output type overrides the estimator output_type attribute
with cuml.using_output_type("cupy"):
print(type(my_est.my_array_)) # Output: cupy
# Once the global output type is restored, we return to the estimator output_type
print(type(my_est.my_array_)) # Output: cuDF. Using a cached value!
Estimator Methods#
cuML uses reflection to convert public array outputs to the user’s expected
type (cupy, numpy, pandas, cudf, etc.). Internal calls stay in an
internal context so intermediate computations avoid unnecessary conversions.
Using the @mlfunc Decorator#
Use @mlfunc on methods that return arrays to the user:
import cupy as cp
from cuml.internals import mlfunc, ReflectedAttr
from cuml.internals.base import Base
from cuml.internals.validation import check_inputs, check_is_fitted
class MyEstimator(Base):
coef_ = ReflectedAttr()
@mlfunc(set_input_type=True)
def fit(self, X):
self.coef_ = check_inputs(self, X, order="A", reset=True)
return self
@mlfunc(preserve_index=True)
def predict(self, X):
check_is_fitted(self)
X = check_inputs(self, X, order="A")
return X + cp.ones(X.shape)
| Decorator Usage | When to Use |
|---|---|
@mlfunc(set_input_type=True) |
Fit-like methods that store _input_type through reflection while validation helpers set or check n_features_in_. |
@mlfunc(preserve_index=True) |
Transform/predict methods that return arrays with n_samples aligned with X |
@mlfunc(array_arg=None) |
Methods with no array input (e.g., KernelDensity.sample()). Uses fit-time input type. |
@mlfunc(model_arg=...) |
Functions or methods where the estimator argument is not the default self; pass its name or position, or None to disable estimator-based inference. |
@mlfunc(column_names="feature_names_in") |
DataFrame-returning methods whose output columns match feature_names_in_. |
@mlfunc(column_names="feature_names_out") |
DataFrame-returning methods whose columns come from get_feature_names_out(). |
Handling Class Labels#
Some methods (like classifier predict methods) need to return class labels,
which may include non-numeric dtypes. These are best handled via a custom
ClassLabels wrapper. This wrapper takes a cupy array of encoded label
indices, and a numpy array of the class labels corresponding to those indices.
The reflection machinery will lazily revert the encoding when it converts the
output to the requested output_type. If the output type doesn’t support
the label dtype (e.g. cupy doesn’t support object types), an informative
error will be raised.
import cupy as cp
from cuml.internals.outputs import mlfunc, ClassLabels
from cuml.internals.base import Base
class MyClassifier(Base):
@mlfunc(preserve_index=True)
def predict(self, X):
# Reflected methods return internal arrays inside this context.
scores = self.decision_function(X)
# Manual processing
indices = (scores >= 0).view(cp.int8)
return ClassLabels(indices, self.classes_)
Score Methods#
Score methods typically return scalars. You may pass in convert_output=False
to avoid the output conversion machinery when it’s known to not be needed.
@mlfunc(convert_output=False)
def score(self, X, y):
predictions = self.predict(X)
return accuracy_score(y, predictions)
Property Accessors#
Use @mlfunc on properties that return arrays:
@property
@mlfunc
def support_(self):
return self._support_vectors
Reflection Decision Rules#
Use these rules when choosing a decorator:
If a public method returns array-like data directly to the user, use
@mlfunc.For fit-like methods, use
@mlfunc(set_input_type=True)along with the appropriate validation helper functions.Inference methods should typically use
@mlfunc(preserve_index=True)to ensure dataframe-like outputs have the same index as the input.If a method has no array input and should use the fit-time input type for output conversion, use
@mlfunc(array_arg=None).If a method returns a scalar or needs to call reflected methods internally without automatic output conversion, use
@mlfunc(convert_output=False).
When testing reflected methods, cover the default input-reflection behavior,
estimator-level output_type, and global cuml.using_output_type(...)
overrides.