From d63e222969b73d6374a57fb0c628d3671f270cfd Mon Sep 17 00:00:00 2001 From: Dante Gama Dessavre Date: Mon, 7 Oct 2024 17:40:59 -0500 Subject: [PATCH 01/22] ENH Make get_param_names a class method to match Scikit-learn --- python/cuml/cuml/_thirdparty/sklearn/preprocessing/_data.py | 3 ++- .../cuml/_thirdparty/sklearn/preprocessing/_discretization.py | 3 ++- .../cuml/cuml/_thirdparty/sklearn/preprocessing/_imputation.py | 3 ++- python/cuml/cuml/dask/cluster/dbscan.py | 3 ++- python/cuml/cuml/dask/cluster/kmeans.py | 3 ++- python/cuml/cuml/dask/decomposition/pca.py | 3 ++- python/cuml/cuml/dask/decomposition/tsvd.py | 3 ++- python/cuml/cuml/dask/linear_model/linear_regression.py | 3 ++- python/cuml/cuml/dask/linear_model/ridge.py | 3 ++- python/cuml/cuml/decomposition/incremental_pca.py | 3 ++- python/cuml/cuml/feature_extraction/_tfidf.py | 3 ++- python/cuml/cuml/linear_model/lasso.py | 3 ++- python/cuml/cuml/multiclass/multiclass.py | 3 ++- python/cuml/cuml/naive_bayes/naive_bayes.py | 3 ++- python/cuml/cuml/neighbors/kernel_density.py | 3 ++- python/cuml/cuml/preprocessing/LabelEncoder.py | 3 ++- python/cuml/cuml/preprocessing/TargetEncoder.py | 3 ++- python/cuml/cuml/preprocessing/encoders.py | 3 ++- python/cuml/cuml/preprocessing/label.py | 3 ++- python/cuml/cuml/svm/linear_svc.py | 3 ++- python/cuml/cuml/svm/linear_svr.py | 3 ++- 21 files changed, 42 insertions(+), 21 deletions(-) diff --git a/python/cuml/cuml/_thirdparty/sklearn/preprocessing/_data.py b/python/cuml/cuml/_thirdparty/sklearn/preprocessing/_data.py index afe8f8742e..b510ff97ac 100644 --- a/python/cuml/cuml/_thirdparty/sklearn/preprocessing/_data.py +++ b/python/cuml/cuml/_thirdparty/sklearn/preprocessing/_data.py @@ -326,7 +326,8 @@ def _reset(self): del self.data_max_ del self.data_range_ - def get_param_names(self): + @classmethod + def get_param_names(cls): return super().get_param_names() + [ "feature_range", "copy" diff --git a/python/cuml/cuml/_thirdparty/sklearn/preprocessing/_discretization.py b/python/cuml/cuml/_thirdparty/sklearn/preprocessing/_discretization.py index 02762f6585..5bafc40677 100644 --- a/python/cuml/cuml/_thirdparty/sklearn/preprocessing/_discretization.py +++ b/python/cuml/cuml/_thirdparty/sklearn/preprocessing/_discretization.py @@ -158,7 +158,8 @@ def __init__(self, n_bins=5, *, encode='onehot', strategy='quantile'): self.encode = encode self.strategy = strategy - def get_param_names(self): + @classmethod + def get_param_names(cls): return super().get_param_names() + [ "n_bins", "encode", diff --git a/python/cuml/cuml/_thirdparty/sklearn/preprocessing/_imputation.py b/python/cuml/cuml/_thirdparty/sklearn/preprocessing/_imputation.py index 3fe160beac..8074ee4b54 100644 --- a/python/cuml/cuml/_thirdparty/sklearn/preprocessing/_imputation.py +++ b/python/cuml/cuml/_thirdparty/sklearn/preprocessing/_imputation.py @@ -243,7 +243,8 @@ def __init__(self, *, missing_values=np.nan, strategy="mean", self.fill_value = fill_value self.copy = copy - def get_param_names(self): + @classmethod + def get_param_names(cls): return super().get_param_names() + [ "strategy", "fill_value", diff --git a/python/cuml/cuml/dask/cluster/dbscan.py b/python/cuml/cuml/dask/cluster/dbscan.py index 51c22abca6..367de9971f 100644 --- a/python/cuml/cuml/dask/cluster/dbscan.py +++ b/python/cuml/cuml/dask/cluster/dbscan.py @@ -160,5 +160,6 @@ def fit_predict(self, X, out_dtype="int32"): self.fit(X, out_dtype) return self.get_combined_model().labels_ - def get_param_names(self): + @classmethod + def get_param_names(cls): return list(self.kwargs.keys()) diff --git a/python/cuml/cuml/dask/cluster/kmeans.py b/python/cuml/cuml/dask/cluster/kmeans.py index b014dc4fb2..282aa8095f 100644 --- a/python/cuml/cuml/dask/cluster/kmeans.py +++ b/python/cuml/cuml/dask/cluster/kmeans.py @@ -302,5 +302,6 @@ def score(self, X, sample_weight=None): cp.asarray(self.client.compute(scores, sync=True)) * -1.0 ) - def get_param_names(self): + @classmethod + def get_param_names(cls): return list(self.kwargs.keys()) diff --git a/python/cuml/cuml/dask/decomposition/pca.py b/python/cuml/cuml/dask/decomposition/pca.py index 896eb58606..02285a9822 100644 --- a/python/cuml/cuml/dask/decomposition/pca.py +++ b/python/cuml/cuml/dask/decomposition/pca.py @@ -215,7 +215,8 @@ def inverse_transform(self, X, delayed=True): """ return self._inverse_transform(X, n_dims=2, delayed=delayed) - def get_param_names(self): + @classmethod + def get_param_names(cls): return list(self.kwargs.keys()) @staticmethod diff --git a/python/cuml/cuml/dask/decomposition/tsvd.py b/python/cuml/cuml/dask/decomposition/tsvd.py index d76d08bda2..3ebe80039d 100644 --- a/python/cuml/cuml/dask/decomposition/tsvd.py +++ b/python/cuml/cuml/dask/decomposition/tsvd.py @@ -177,7 +177,8 @@ def inverse_transform(self, X, delayed=True): """ return self._inverse_transform(X, n_dims=2, delayed=delayed) - def get_param_names(self): + @classmethod + def get_param_names(cls): return list(self.kwargs.keys()) @staticmethod diff --git a/python/cuml/cuml/dask/linear_model/linear_regression.py b/python/cuml/cuml/dask/linear_model/linear_regression.py index f8c5dfd7b0..26d702b3bb 100644 --- a/python/cuml/cuml/dask/linear_model/linear_regression.py +++ b/python/cuml/cuml/dask/linear_model/linear_regression.py @@ -106,7 +106,8 @@ def predict(self, X, delayed=True): """ return self._predict(X, delayed=delayed) - def get_param_names(self): + @classmethod + def get_param_names(cls): return list(self.kwargs.keys()) @staticmethod diff --git a/python/cuml/cuml/dask/linear_model/ridge.py b/python/cuml/cuml/dask/linear_model/ridge.py index 32db990979..93be31477d 100644 --- a/python/cuml/cuml/dask/linear_model/ridge.py +++ b/python/cuml/cuml/dask/linear_model/ridge.py @@ -114,7 +114,8 @@ def predict(self, X, delayed=True): """ return self._predict(X, delayed=delayed) - def get_param_names(self): + @classmethod + def get_param_names(cls): return list(self.kwargs.keys()) @staticmethod diff --git a/python/cuml/cuml/decomposition/incremental_pca.py b/python/cuml/cuml/decomposition/incremental_pca.py index 0288f0e9a8..acd5eb3702 100644 --- a/python/cuml/cuml/decomposition/incremental_pca.py +++ b/python/cuml/cuml/decomposition/incremental_pca.py @@ -449,7 +449,8 @@ def transform(self, X, convert_dtype=False) -> CumlArray: else: return super().transform(X) - def get_param_names(self): + @classmethod + def get_param_names(cls): # Skip super() since we dont pass any extra parameters in __init__ return Base.get_param_names(self) + self._hyperparams diff --git a/python/cuml/cuml/feature_extraction/_tfidf.py b/python/cuml/cuml/feature_extraction/_tfidf.py index 18b358a461..b1c4a65ed7 100644 --- a/python/cuml/cuml/feature_extraction/_tfidf.py +++ b/python/cuml/cuml/feature_extraction/_tfidf.py @@ -301,7 +301,8 @@ def idf_(self, value): (value, 0), shape=(n_features, n_features), dtype=cp.float32 ) - def get_param_names(self): + @classmethod + def get_param_names(cls): return super().get_param_names() + [ "norm", "use_idf", diff --git a/python/cuml/cuml/linear_model/lasso.py b/python/cuml/cuml/linear_model/lasso.py index 21a7add877..c81272a875 100644 --- a/python/cuml/cuml/linear_model/lasso.py +++ b/python/cuml/cuml/linear_model/lasso.py @@ -165,5 +165,6 @@ def __init__( verbose=verbose, ) - def get_param_names(self): + @classmethod + def get_param_names(cls): return list(set(super().get_param_names()) - {"l1_ratio"}) diff --git a/python/cuml/cuml/multiclass/multiclass.py b/python/cuml/cuml/multiclass/multiclass.py index 58c4151094..45671a275b 100644 --- a/python/cuml/cuml/multiclass/multiclass.py +++ b/python/cuml/cuml/multiclass/multiclass.py @@ -192,7 +192,8 @@ def decision_function(self, X) -> CumlArray: with cuml.internals.exit_internal_api(): return self.multiclass_estimator.decision_function(X) - def get_param_names(self): + @classmethod + def get_param_names(cls): return super().get_param_names() + ["estimator", "strategy"] diff --git a/python/cuml/cuml/naive_bayes/naive_bayes.py b/python/cuml/cuml/naive_bayes/naive_bayes.py index 89a6d55e5e..b393090ea6 100644 --- a/python/cuml/cuml/naive_bayes/naive_bayes.py +++ b/python/cuml/cuml/naive_bayes/naive_bayes.py @@ -724,7 +724,8 @@ def _joint_log_likelihood(self, X): return cp.array(joint_log_likelihood).T - def get_param_names(self): + @classmethod + def get_param_names(cls): return super().get_param_names() + ["priors", "var_smoothing"] diff --git a/python/cuml/cuml/neighbors/kernel_density.py b/python/cuml/cuml/neighbors/kernel_density.py index 9c85df321a..ceb88e91b2 100644 --- a/python/cuml/cuml/neighbors/kernel_density.py +++ b/python/cuml/cuml/neighbors/kernel_density.py @@ -225,7 +225,8 @@ def __init__( if kernel not in VALID_KERNELS: raise ValueError("invalid kernel: '{0}'".format(kernel)) - def get_param_names(self): + @classmethod + def get_param_names(cls): return super().get_param_names() + [ "bandwidth", "kernel", diff --git a/python/cuml/cuml/preprocessing/LabelEncoder.py b/python/cuml/cuml/preprocessing/LabelEncoder.py index ca20950ee1..ba6c1975ab 100644 --- a/python/cuml/cuml/preprocessing/LabelEncoder.py +++ b/python/cuml/cuml/preprocessing/LabelEncoder.py @@ -284,7 +284,8 @@ def inverse_transform(self, y: cudf.Series) -> cudf.Series: return res - def get_param_names(self): + @classmethod + def get_param_names(cls): return super().get_param_names() + [ "handle_unknown", ] diff --git a/python/cuml/cuml/preprocessing/TargetEncoder.py b/python/cuml/cuml/preprocessing/TargetEncoder.py index 43162b8805..d0df2fbf66 100644 --- a/python/cuml/cuml/preprocessing/TargetEncoder.py +++ b/python/cuml/cuml/preprocessing/TargetEncoder.py @@ -508,7 +508,8 @@ def _get_output_type(self, x): return "numpy" return "cupy" - def get_param_names(self): + @classmethod + def get_param_names(cls): return [ "n_folds", "smooth", diff --git a/python/cuml/cuml/preprocessing/encoders.py b/python/cuml/cuml/preprocessing/encoders.py index 01264572e7..7874aaf4dc 100644 --- a/python/cuml/cuml/preprocessing/encoders.py +++ b/python/cuml/cuml/preprocessing/encoders.py @@ -602,7 +602,8 @@ def get_feature_names(self, input_features=None): return np.array(feature_names, dtype=object) - def get_param_names(self): + @classmethod + def get_param_names(cls): return super().get_param_names() + [ "categories", "drop", diff --git a/python/cuml/cuml/preprocessing/label.py b/python/cuml/cuml/preprocessing/label.py index c34a9d9249..742383188d 100644 --- a/python/cuml/cuml/preprocessing/label.py +++ b/python/cuml/cuml/preprocessing/label.py @@ -287,7 +287,8 @@ def inverse_transform(self, y, threshold=None) -> CumlArray: return invert_labels(y_mapped, self.classes_) - def get_param_names(self): + @classmethod + def get_param_names(cls): return super().get_param_names() + [ "neg_label", "pos_label", diff --git a/python/cuml/cuml/svm/linear_svc.py b/python/cuml/cuml/svm/linear_svc.py index ed606464ba..5b66732a4f 100644 --- a/python/cuml/cuml/svm/linear_svc.py +++ b/python/cuml/cuml/svm/linear_svc.py @@ -173,7 +173,8 @@ def loss(self, loss: str): ) self.__loss = loss - def get_param_names(self): + @classmethod + def get_param_names(cls): return list( { "handle", diff --git a/python/cuml/cuml/svm/linear_svr.py b/python/cuml/cuml/svm/linear_svr.py index 76bba4b1e1..87c48c060b 100644 --- a/python/cuml/cuml/svm/linear_svr.py +++ b/python/cuml/cuml/svm/linear_svr.py @@ -152,7 +152,8 @@ def loss(self, loss: str): ) self.__loss = loss - def get_param_names(self): + @classmethod + def get_param_names(cls): return list( { "handle", From 4a1a7bc163345b43c6a305928d05e395395b1098 Mon Sep 17 00:00:00 2001 From: Dante Gama Dessavre Date: Mon, 7 Oct 2024 19:58:48 -0500 Subject: [PATCH 02/22] ENH Make get_param_names a class method in cython files too --- python/cuml/cuml/cluster/agglomerative.pyx | 3 ++- python/cuml/cuml/cluster/dbscan.pyx | 3 ++- python/cuml/cuml/cluster/hdbscan/hdbscan.pyx | 3 ++- python/cuml/cuml/cluster/kmeans.pyx | 3 ++- python/cuml/cuml/decomposition/pca.pyx | 3 ++- python/cuml/cuml/decomposition/tsvd.pyx | 3 ++- python/cuml/cuml/ensemble/randomforest_common.pyx | 3 ++- python/cuml/cuml/experimental/linear_model/lars.pyx | 3 ++- python/cuml/cuml/internals/base.pyx | 3 ++- python/cuml/cuml/kernel_ridge/kernel_ridge.pyx | 3 ++- python/cuml/cuml/linear_model/elastic_net.pyx | 3 ++- python/cuml/cuml/linear_model/linear_regression.pyx | 3 ++- python/cuml/cuml/linear_model/logistic_regression.pyx | 3 ++- python/cuml/cuml/linear_model/mbsgd_classifier.pyx | 3 ++- python/cuml/cuml/linear_model/mbsgd_regressor.pyx | 3 ++- python/cuml/cuml/linear_model/ridge.pyx | 3 ++- python/cuml/cuml/manifold/t_sne.pyx | 3 ++- python/cuml/cuml/manifold/umap.pyx | 3 ++- python/cuml/cuml/neighbors/kneighbors_classifier.pyx | 3 ++- python/cuml/cuml/neighbors/kneighbors_regressor.pyx | 3 ++- python/cuml/cuml/neighbors/nearest_neighbors.pyx | 3 ++- python/cuml/cuml/random_projection/random_projection.pyx | 3 ++- python/cuml/cuml/solvers/cd.pyx | 3 ++- python/cuml/cuml/solvers/qn.pyx | 3 ++- python/cuml/cuml/solvers/sgd.pyx | 3 ++- python/cuml/cuml/svm/linear.pyx | 3 ++- python/cuml/cuml/svm/svc.pyx | 3 ++- python/cuml/cuml/svm/svm_base.pyx | 3 ++- python/cuml/cuml/tsa/arima.pyx | 3 ++- python/cuml/cuml/tsa/holtwinters.pyx | 3 ++- 30 files changed, 60 insertions(+), 30 deletions(-) diff --git a/python/cuml/cuml/cluster/agglomerative.pyx b/python/cuml/cuml/cluster/agglomerative.pyx index 34150d3f6b..cc2fd1021d 100644 --- a/python/cuml/cuml/cluster/agglomerative.pyx +++ b/python/cuml/cuml/cluster/agglomerative.pyx @@ -279,7 +279,8 @@ class AgglomerativeClustering(Base, ClusterMixin, CMajorInputTagMixin): """ return self.fit(X).labels_ - def get_param_names(self): + @classmethod + def get_param_names(cls): return super().get_param_names() + [ "n_clusters", "affinity", diff --git a/python/cuml/cuml/cluster/dbscan.pyx b/python/cuml/cuml/cluster/dbscan.pyx index fff1eef3f9..139be36829 100644 --- a/python/cuml/cuml/cluster/dbscan.pyx +++ b/python/cuml/cuml/cluster/dbscan.pyx @@ -466,7 +466,8 @@ class DBSCAN(UniversalBase, self.fit(X, out_dtype, sample_weight) return self.labels_ - def get_param_names(self): + @classmethod + def get_param_names(cls): return super().get_param_names() + [ "eps", "min_samples", diff --git a/python/cuml/cuml/cluster/hdbscan/hdbscan.pyx b/python/cuml/cuml/cluster/hdbscan/hdbscan.pyx index f7691c1684..4f838feb15 100644 --- a/python/cuml/cuml/cluster/hdbscan/hdbscan.pyx +++ b/python/cuml/cuml/cluster/hdbscan/hdbscan.pyx @@ -1112,7 +1112,8 @@ class HDBSCAN(UniversalBase, ClusterMixin, CMajorInputTagMixin): self._cpu_to_gpu_interop_prepped = True - def get_param_names(self): + @classmethod + def get_param_names(cls): return super().get_param_names() + [ "metric", "min_cluster_size", diff --git a/python/cuml/cuml/cluster/kmeans.pyx b/python/cuml/cuml/cluster/kmeans.pyx index 3d6be3abf2..4fb115d589 100644 --- a/python/cuml/cuml/cluster/kmeans.pyx +++ b/python/cuml/cuml/cluster/kmeans.pyx @@ -694,7 +694,8 @@ class KMeans(UniversalBase, self.fit(X, sample_weight=sample_weight) return self.transform(X, convert_dtype=convert_dtype) - def get_param_names(self): + @classmethod + def get_param_names(cls): return super().get_param_names() + \ ['n_init', 'oversampling_factor', 'max_samples_per_batch', 'init', 'max_iter', 'n_clusters', 'random_state', diff --git a/python/cuml/cuml/decomposition/pca.pyx b/python/cuml/cuml/decomposition/pca.pyx index 7c0279c41d..08f8f2ec6e 100644 --- a/python/cuml/cuml/decomposition/pca.pyx +++ b/python/cuml/cuml/decomposition/pca.pyx @@ -725,7 +725,8 @@ class PCA(UniversalBase, return t_input_data - def get_param_names(self): + @classmethod + def get_param_names(cls): return super().get_param_names() + \ ["copy", "iterated_power", "n_components", "svd_solver", "tol", "whiten", "random_state"] diff --git a/python/cuml/cuml/decomposition/tsvd.pyx b/python/cuml/cuml/decomposition/tsvd.pyx index 0a71aa0f77..dd8dfe0282 100644 --- a/python/cuml/cuml/decomposition/tsvd.pyx +++ b/python/cuml/cuml/decomposition/tsvd.pyx @@ -482,7 +482,8 @@ class TruncatedSVD(UniversalBase, return t_input_data - def get_param_names(self): + @classmethod + def get_param_names(cls): return super().get_param_names() + \ ["algorithm", "n_components", "n_iter", "random_state", "tol"] diff --git a/python/cuml/cuml/ensemble/randomforest_common.pyx b/python/cuml/cuml/ensemble/randomforest_common.pyx index 61513e8e2d..a6dd918879 100644 --- a/python/cuml/cuml/ensemble/randomforest_common.pyx +++ b/python/cuml/cuml/ensemble/randomforest_common.pyx @@ -382,7 +382,8 @@ class BaseRandomForestModel(Base): preds = tl_to_fil_model.predict(X) return preds - def get_param_names(self): + @classmethod + def get_param_names(cls): return super().get_param_names() + BaseRandomForestModel._param_names def set_params(self, **params): diff --git a/python/cuml/cuml/experimental/linear_model/lars.pyx b/python/cuml/cuml/experimental/linear_model/lars.pyx index 2552d98f43..6a81b0bc23 100644 --- a/python/cuml/cuml/experimental/linear_model/lars.pyx +++ b/python/cuml/cuml/experimental/linear_model/lars.pyx @@ -397,7 +397,8 @@ class Lars(Base, RegressorMixin): return preds - def get_param_names(self): + @classmethod + def get_param_names(cls): return super().get_param_names() + \ ['copy_X', 'fit_intercept', 'fit_path', 'n_nonzero_coefs', 'normalize', 'precompute', 'eps'] diff --git a/python/cuml/cuml/internals/base.pyx b/python/cuml/cuml/internals/base.pyx index c00ed17f98..9c13410f57 100644 --- a/python/cuml/cuml/internals/base.pyx +++ b/python/cuml/cuml/internals/base.pyx @@ -182,7 +182,8 @@ class Base(TagsMixin, self._check_output_type(data) # inference logic goes here - def get_param_names(self): + @classmethod + def get_param_names(cls): # return a list of hyperparam names supported by this algo # stream and handle example: diff --git a/python/cuml/cuml/kernel_ridge/kernel_ridge.pyx b/python/cuml/cuml/kernel_ridge/kernel_ridge.pyx index db635861a0..98d1bd98de 100644 --- a/python/cuml/cuml/kernel_ridge/kernel_ridge.pyx +++ b/python/cuml/cuml/kernel_ridge/kernel_ridge.pyx @@ -226,7 +226,8 @@ class KernelRidge(Base, RegressorMixin): self.coef0 = coef0 self.kernel_params = kernel_params - def get_param_names(self): + @classmethod + def get_param_names(cls): return super().get_param_names() + [ "alpha", "kernel", diff --git a/python/cuml/cuml/linear_model/elastic_net.pyx b/python/cuml/cuml/linear_model/elastic_net.pyx index b16987be24..325e3e6de3 100644 --- a/python/cuml/cuml/linear_model/elastic_net.pyx +++ b/python/cuml/cuml/linear_model/elastic_net.pyx @@ -272,7 +272,8 @@ class ElasticNet(UniversalBase, self.solver_model.set_params(**params) return self - def get_param_names(self): + @classmethod + def get_param_names(cls): return super().get_param_names() + [ "alpha", "l1_ratio", diff --git a/python/cuml/cuml/linear_model/linear_regression.pyx b/python/cuml/cuml/linear_model/linear_regression.pyx index 4d2cb94cf9..ba38ba1711 100644 --- a/python/cuml/cuml/linear_model/linear_regression.pyx +++ b/python/cuml/cuml/linear_model/linear_regression.pyx @@ -491,7 +491,8 @@ class LinearRegression(LinearPredictMixin, # `predict` method return super()._predict(X, convert_dtype=convert_dtype) - def get_param_names(self): + @classmethod + def get_param_names(cls): return super().get_param_names() + \ ['algorithm', 'fit_intercept', 'copy_X', 'normalize'] diff --git a/python/cuml/cuml/linear_model/logistic_regression.pyx b/python/cuml/cuml/linear_model/logistic_regression.pyx index 164821a5bd..1d08d2f102 100644 --- a/python/cuml/cuml/linear_model/logistic_regression.pyx +++ b/python/cuml/cuml/linear_model/logistic_regression.pyx @@ -534,7 +534,8 @@ class LogisticRegression(UniversalBase, def intercept_(self, value): self.solver_model.intercept_ = value - def get_param_names(self): + @classmethod + def get_param_names(cls): return super().get_param_names() + [ "penalty", "tol", diff --git a/python/cuml/cuml/linear_model/mbsgd_classifier.pyx b/python/cuml/cuml/linear_model/mbsgd_classifier.pyx index 4748478440..6f13288f8d 100644 --- a/python/cuml/cuml/linear_model/mbsgd_classifier.pyx +++ b/python/cuml/cuml/linear_model/mbsgd_classifier.pyx @@ -213,7 +213,8 @@ class MBSGDClassifier(Base, self.solver_model.set_params(**params) return self - def get_param_names(self): + @classmethod + def get_param_names(cls): return super().get_param_names() + [ "loss", "penalty", diff --git a/python/cuml/cuml/linear_model/mbsgd_regressor.pyx b/python/cuml/cuml/linear_model/mbsgd_regressor.pyx index 8603fdf576..1a2a4b52b8 100644 --- a/python/cuml/cuml/linear_model/mbsgd_regressor.pyx +++ b/python/cuml/cuml/linear_model/mbsgd_regressor.pyx @@ -207,7 +207,8 @@ class MBSGDRegressor(Base, self.solver_model.set_params(**params) return self - def get_param_names(self): + @classmethod + def get_param_names(cls): return super().get_param_names() + [ "loss", "penalty", diff --git a/python/cuml/cuml/linear_model/ridge.pyx b/python/cuml/cuml/linear_model/ridge.pyx index 7da8698cf0..92a94ddf9e 100644 --- a/python/cuml/cuml/linear_model/ridge.pyx +++ b/python/cuml/cuml/linear_model/ridge.pyx @@ -356,7 +356,8 @@ class Ridge(UniversalBase, raise TypeError(msg.format(params['solver'])) return self - def get_param_names(self): + @classmethod + def get_param_names(cls): return super().get_param_names() + \ ['solver', 'fit_intercept', 'normalize', 'alpha'] diff --git a/python/cuml/cuml/manifold/t_sne.pyx b/python/cuml/cuml/manifold/t_sne.pyx index d230ee8467..7d608167a1 100644 --- a/python/cuml/cuml/manifold/t_sne.pyx +++ b/python/cuml/cuml/manifold/t_sne.pyx @@ -690,7 +690,8 @@ class TSNE(UniversalBase, self.__dict__.update(state) return state - def get_param_names(self): + @classmethod + def get_param_names(cls): return super().get_param_names() + [ "n_components", "perplexity", diff --git a/python/cuml/cuml/manifold/umap.pyx b/python/cuml/cuml/manifold/umap.pyx index 260b32ee6b..dc5f037467 100644 --- a/python/cuml/cuml/manifold/umap.pyx +++ b/python/cuml/cuml/manifold/umap.pyx @@ -909,7 +909,8 @@ class UMAP(UniversalBase, super().gpu_to_cpu() - def get_param_names(self): + @classmethod + def get_param_names(cls): return super().get_param_names() + [ "n_neighbors", "n_components", diff --git a/python/cuml/cuml/neighbors/kneighbors_classifier.pyx b/python/cuml/cuml/neighbors/kneighbors_classifier.pyx index 989d4747aa..03d9b7bb73 100644 --- a/python/cuml/cuml/neighbors/kneighbors_classifier.pyx +++ b/python/cuml/cuml/neighbors/kneighbors_classifier.pyx @@ -298,5 +298,6 @@ class KNeighborsClassifier(ClassifierMixin, return final_classes[0] \ if len(final_classes) == 1 else tuple(final_classes) - def get_param_names(self): + @classmethod + def get_param_names(cls): return super().get_param_names() + ["weights"] diff --git a/python/cuml/cuml/neighbors/kneighbors_regressor.pyx b/python/cuml/cuml/neighbors/kneighbors_regressor.pyx index 011c6b6d25..4142226f18 100644 --- a/python/cuml/cuml/neighbors/kneighbors_regressor.pyx +++ b/python/cuml/cuml/neighbors/kneighbors_regressor.pyx @@ -234,5 +234,6 @@ class KNeighborsRegressor(RegressorMixin, return results - def get_param_names(self): + @classmethod + def get_param_names(cls): return super().get_param_names() + ["weights"] diff --git a/python/cuml/cuml/neighbors/nearest_neighbors.pyx b/python/cuml/cuml/neighbors/nearest_neighbors.pyx index c838189ded..0c0ebb09a3 100644 --- a/python/cuml/cuml/neighbors/nearest_neighbors.pyx +++ b/python/cuml/cuml/neighbors/nearest_neighbors.pyx @@ -420,7 +420,8 @@ class NearestNeighbors(UniversalBase, self.n_indices = 1 return self - def get_param_names(self): + @classmethod + def get_param_names(cls): return super().get_param_names() + \ ["n_neighbors", "algorithm", "metric", "p", "metric_params", "algo_params", "n_jobs"] diff --git a/python/cuml/cuml/random_projection/random_projection.pyx b/python/cuml/cuml/random_projection/random_projection.pyx index dda2ab380f..a7d98fdc81 100644 --- a/python/cuml/cuml/random_projection/random_projection.pyx +++ b/python/cuml/cuml/random_projection/random_projection.pyx @@ -445,7 +445,8 @@ class GaussianRandomProjection(Base, dense_output=True, random_state=random_state) - def get_param_names(self): + @classmethod + def get_param_names(cls): return Base.get_param_names(self) + [ "n_components", "eps", diff --git a/python/cuml/cuml/solvers/cd.pyx b/python/cuml/cuml/solvers/cd.pyx index f54b8ddc29..a6874f7025 100644 --- a/python/cuml/cuml/solvers/cd.pyx +++ b/python/cuml/cuml/solvers/cd.pyx @@ -359,7 +359,8 @@ class CD(Base, return preds - def get_param_names(self): + @classmethod + def get_param_names(cls): return super().get_param_names() + [ "loss", "alpha", diff --git a/python/cuml/cuml/solvers/qn.pyx b/python/cuml/cuml/solvers/qn.pyx index 104bcfe76a..5b558a6770 100644 --- a/python/cuml/cuml/solvers/qn.pyx +++ b/python/cuml/cuml/solvers/qn.pyx @@ -182,7 +182,8 @@ IF GPUBUILD == 1: def _setparam(self, key, val): self.params[key] = val - def get_param_names(self): + @classmethod + def get_param_names(cls): return self.get_param_defaults().keys() def __str__(self): diff --git a/python/cuml/cuml/solvers/sgd.pyx b/python/cuml/cuml/solvers/sgd.pyx index b81563736c..6e10fab9a8 100644 --- a/python/cuml/cuml/solvers/sgd.pyx +++ b/python/cuml/cuml/solvers/sgd.pyx @@ -502,7 +502,8 @@ class SGD(Base, return preds - def get_param_names(self): + @classmethod + def get_param_names(cls): return super().get_param_names() + [ "loss", "penalty", diff --git a/python/cuml/cuml/svm/linear.pyx b/python/cuml/cuml/svm/linear.pyx index 470e867f60..957b142200 100644 --- a/python/cuml/cuml/svm/linear.pyx +++ b/python/cuml/cuml/svm/linear.pyx @@ -151,7 +151,8 @@ cdef class LSVMPWrapper_: if key in allowed_keys: setattr(self, key, val) - def get_param_names(self): + @classmethod + def get_param_names(cls): cdef LinearSVMParams ps return ps.keys() diff --git a/python/cuml/cuml/svm/svc.pyx b/python/cuml/cuml/svm/svc.pyx index 566aa4b762..d62d4729b0 100644 --- a/python/cuml/cuml/svm/svc.pyx +++ b/python/cuml/cuml/svm/svc.pyx @@ -702,7 +702,8 @@ class SVC(SVMBase, else: return super().predict(X, False) - def get_param_names(self): + @classmethod + def get_param_names(cls): params = super().get_param_names() + \ ["probability", "random_state", "class_weight", "multiclass_strategy"] diff --git a/python/cuml/cuml/svm/svm_base.pyx b/python/cuml/cuml/svm/svm_base.pyx index 1a478fbc9c..24f8b20fda 100644 --- a/python/cuml/cuml/svm/svm_base.pyx +++ b/python/cuml/cuml/svm/svm_base.pyx @@ -661,7 +661,8 @@ class SVMBase(Base, return preds - def get_param_names(self): + @classmethod + def get_param_names(cls): return super().get_param_names() + [ "C", "kernel", diff --git a/python/cuml/cuml/tsa/arima.pyx b/python/cuml/cuml/tsa/arima.pyx index 89fecfe32d..e93f871cb2 100644 --- a/python/cuml/cuml/tsa/arima.pyx +++ b/python/cuml/cuml/tsa/arima.pyx @@ -577,7 +577,8 @@ class ARIMA(Base): ) setattr(self, "{}_".format(param_name), array) - def get_param_names(self): + @classmethod + def get_param_names(cls): raise NotImplementedError def get_param_names(self): diff --git a/python/cuml/cuml/tsa/holtwinters.pyx b/python/cuml/cuml/tsa/holtwinters.pyx index 79a0266996..165467d5eb 100644 --- a/python/cuml/cuml/tsa/holtwinters.pyx +++ b/python/cuml/cuml/tsa/holtwinters.pyx @@ -576,7 +576,8 @@ class ExponentialSmoothing(Base): else: raise ValueError("Fit() the model to get season values") - def get_param_names(self): + @classmethod + def get_param_names(cls): return super().get_param_names() + [ "endog", "seasonal", From 9eb02557f2e6f4794c2c3d2979a8bfc85951f49b Mon Sep 17 00:00:00 2001 From: Dante Gama Dessavre Date: Mon, 7 Oct 2024 20:18:32 -0500 Subject: [PATCH 03/22] FIX remove changes to dask estimators --- python/cuml/cuml/cluster/hdbscan/hdbscan.pyx | 2 +- python/cuml/cuml/dask/cluster/dbscan.py | 7 +++---- python/cuml/cuml/dask/cluster/kmeans.py | 3 +-- python/cuml/cuml/dask/decomposition/pca.py | 5 ++--- python/cuml/cuml/dask/decomposition/tsvd.py | 5 ++--- python/cuml/cuml/dask/linear_model/linear_regression.py | 5 ++--- python/cuml/cuml/dask/linear_model/ridge.py | 5 ++--- python/cuml/cuml/decomposition/incremental_pca.py | 2 +- python/cuml/cuml/feature_extraction/_tfidf.py | 2 +- python/cuml/cuml/internals/base.pyx | 2 +- python/cuml/cuml/linear_model/elastic_net.pyx | 2 +- python/cuml/cuml/linear_model/lasso.py | 2 +- python/cuml/cuml/neighbors/kneighbors_classifier.pyx | 2 +- python/cuml/cuml/neighbors/kneighbors_regressor.pyx | 2 +- python/cuml/cuml/svm/linear.pyx | 2 +- python/cuml/cuml/svm/linear_svc.py | 2 +- python/cuml/cuml/svm/linear_svr.py | 2 +- python/cuml/cuml/svm/svm_base.pyx | 2 +- python/cuml/cuml/tsa/holtwinters.pyx | 2 +- 19 files changed, 25 insertions(+), 31 deletions(-) diff --git a/python/cuml/cuml/cluster/hdbscan/hdbscan.pyx b/python/cuml/cuml/cluster/hdbscan/hdbscan.pyx index 4f838feb15..0bc3d21e45 100644 --- a/python/cuml/cuml/cluster/hdbscan/hdbscan.pyx +++ b/python/cuml/cuml/cluster/hdbscan/hdbscan.pyx @@ -1,4 +1,4 @@ -# Copyright (c) 2021-2023, NVIDIA CORPORATION. +# Copyright (c) 2021-2024, NVIDIA CORPORATION. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/python/cuml/cuml/dask/cluster/dbscan.py b/python/cuml/cuml/dask/cluster/dbscan.py index 367de9971f..8538253bf3 100644 --- a/python/cuml/cuml/dask/cluster/dbscan.py +++ b/python/cuml/cuml/dask/cluster/dbscan.py @@ -1,4 +1,4 @@ -# Copyright (c) 2020-2023, NVIDIA CORPORATION. +# Copyright (c) 2020-2024, NVIDIA CORPORATION. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -160,6 +160,5 @@ def fit_predict(self, X, out_dtype="int32"): self.fit(X, out_dtype) return self.get_combined_model().labels_ - @classmethod - def get_param_names(cls): - return list(self.kwargs.keys()) + def get_param_names(self): + return list(cls.kwargs.keys()) diff --git a/python/cuml/cuml/dask/cluster/kmeans.py b/python/cuml/cuml/dask/cluster/kmeans.py index 282aa8095f..b014dc4fb2 100644 --- a/python/cuml/cuml/dask/cluster/kmeans.py +++ b/python/cuml/cuml/dask/cluster/kmeans.py @@ -302,6 +302,5 @@ def score(self, X, sample_weight=None): cp.asarray(self.client.compute(scores, sync=True)) * -1.0 ) - @classmethod - def get_param_names(cls): + def get_param_names(self): return list(self.kwargs.keys()) diff --git a/python/cuml/cuml/dask/decomposition/pca.py b/python/cuml/cuml/dask/decomposition/pca.py index 02285a9822..e82c39895c 100644 --- a/python/cuml/cuml/dask/decomposition/pca.py +++ b/python/cuml/cuml/dask/decomposition/pca.py @@ -1,4 +1,4 @@ -# Copyright (c) 2019-2023, NVIDIA CORPORATION. +# Copyright (c) 2019-2024, NVIDIA CORPORATION. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -215,8 +215,7 @@ def inverse_transform(self, X, delayed=True): """ return self._inverse_transform(X, n_dims=2, delayed=delayed) - @classmethod - def get_param_names(cls): + def get_param_names(self): return list(self.kwargs.keys()) @staticmethod diff --git a/python/cuml/cuml/dask/decomposition/tsvd.py b/python/cuml/cuml/dask/decomposition/tsvd.py index 3ebe80039d..696062d97b 100644 --- a/python/cuml/cuml/dask/decomposition/tsvd.py +++ b/python/cuml/cuml/dask/decomposition/tsvd.py @@ -1,4 +1,4 @@ -# Copyright (c) 2019-2023, NVIDIA CORPORATION. +# Copyright (c) 2019-2024, NVIDIA CORPORATION. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -177,8 +177,7 @@ def inverse_transform(self, X, delayed=True): """ return self._inverse_transform(X, n_dims=2, delayed=delayed) - @classmethod - def get_param_names(cls): + def get_param_names(self): return list(self.kwargs.keys()) @staticmethod diff --git a/python/cuml/cuml/dask/linear_model/linear_regression.py b/python/cuml/cuml/dask/linear_model/linear_regression.py index 26d702b3bb..aaa797e96c 100644 --- a/python/cuml/cuml/dask/linear_model/linear_regression.py +++ b/python/cuml/cuml/dask/linear_model/linear_regression.py @@ -1,4 +1,4 @@ -# Copyright (c) 2019-2023, NVIDIA CORPORATION. +# Copyright (c) 2019-2024, NVIDIA CORPORATION. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -106,8 +106,7 @@ def predict(self, X, delayed=True): """ return self._predict(X, delayed=delayed) - @classmethod - def get_param_names(cls): + def get_param_names(self): return list(self.kwargs.keys()) @staticmethod diff --git a/python/cuml/cuml/dask/linear_model/ridge.py b/python/cuml/cuml/dask/linear_model/ridge.py index 93be31477d..18ddad3c52 100644 --- a/python/cuml/cuml/dask/linear_model/ridge.py +++ b/python/cuml/cuml/dask/linear_model/ridge.py @@ -1,4 +1,4 @@ -# Copyright (c) 2019-2023, NVIDIA CORPORATION. +# Copyright (c) 2019-2024, NVIDIA CORPORATION. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -114,8 +114,7 @@ def predict(self, X, delayed=True): """ return self._predict(X, delayed=delayed) - @classmethod - def get_param_names(cls): + def get_param_names(self): return list(self.kwargs.keys()) @staticmethod diff --git a/python/cuml/cuml/decomposition/incremental_pca.py b/python/cuml/cuml/decomposition/incremental_pca.py index acd5eb3702..ea77e1edd9 100644 --- a/python/cuml/cuml/decomposition/incremental_pca.py +++ b/python/cuml/cuml/decomposition/incremental_pca.py @@ -452,7 +452,7 @@ def transform(self, X, convert_dtype=False) -> CumlArray: @classmethod def get_param_names(cls): # Skip super() since we dont pass any extra parameters in __init__ - return Base.get_param_names(self) + self._hyperparams + return Base.get_param_names() + self._hyperparams def _validate_sparse_input(X): diff --git a/python/cuml/cuml/feature_extraction/_tfidf.py b/python/cuml/cuml/feature_extraction/_tfidf.py index b1c4a65ed7..c4d7a5fd1b 100644 --- a/python/cuml/cuml/feature_extraction/_tfidf.py +++ b/python/cuml/cuml/feature_extraction/_tfidf.py @@ -1,5 +1,5 @@ # -# Copyright (c) 2019-2023, NVIDIA CORPORATION. +# Copyright (c) 2019-2024, NVIDIA CORPORATION. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/python/cuml/cuml/internals/base.pyx b/python/cuml/cuml/internals/base.pyx index 9c13410f57..ad6f761799 100644 --- a/python/cuml/cuml/internals/base.pyx +++ b/python/cuml/cuml/internals/base.pyx @@ -1,5 +1,5 @@ # -# Copyright (c) 2019-2023, NVIDIA CORPORATION. +# Copyright (c) 2019-2024, NVIDIA CORPORATION. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/python/cuml/cuml/linear_model/elastic_net.pyx b/python/cuml/cuml/linear_model/elastic_net.pyx index 325e3e6de3..cae04051e1 100644 --- a/python/cuml/cuml/linear_model/elastic_net.pyx +++ b/python/cuml/cuml/linear_model/elastic_net.pyx @@ -1,5 +1,5 @@ # -# Copyright (c) 2019-2022, NVIDIA CORPORATION. +# Copyright (c) 2019-2024, NVIDIA CORPORATION. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/python/cuml/cuml/linear_model/lasso.py b/python/cuml/cuml/linear_model/lasso.py index c81272a875..9b0aafa5ef 100644 --- a/python/cuml/cuml/linear_model/lasso.py +++ b/python/cuml/cuml/linear_model/lasso.py @@ -1,5 +1,5 @@ # -# Copyright (c) 2019-2023, NVIDIA CORPORATION. +# Copyright (c) 2019-2024, NVIDIA CORPORATION. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/python/cuml/cuml/neighbors/kneighbors_classifier.pyx b/python/cuml/cuml/neighbors/kneighbors_classifier.pyx index 03d9b7bb73..0751bf4c5c 100644 --- a/python/cuml/cuml/neighbors/kneighbors_classifier.pyx +++ b/python/cuml/cuml/neighbors/kneighbors_classifier.pyx @@ -1,5 +1,5 @@ # -# Copyright (c) 2019-2023, NVIDIA CORPORATION. +# Copyright (c) 2019-2024, NVIDIA CORPORATION. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/python/cuml/cuml/neighbors/kneighbors_regressor.pyx b/python/cuml/cuml/neighbors/kneighbors_regressor.pyx index 4142226f18..c9ec8f1b93 100644 --- a/python/cuml/cuml/neighbors/kneighbors_regressor.pyx +++ b/python/cuml/cuml/neighbors/kneighbors_regressor.pyx @@ -1,5 +1,5 @@ # -# Copyright (c) 2019-2023, NVIDIA CORPORATION. +# Copyright (c) 2019-2024, NVIDIA CORPORATION. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/python/cuml/cuml/svm/linear.pyx b/python/cuml/cuml/svm/linear.pyx index 957b142200..e52ef100a4 100644 --- a/python/cuml/cuml/svm/linear.pyx +++ b/python/cuml/cuml/svm/linear.pyx @@ -1,4 +1,4 @@ -# Copyright (c) 2021-2023, NVIDIA CORPORATION. +# Copyright (c) 2021-2024, NVIDIA CORPORATION. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/python/cuml/cuml/svm/linear_svc.py b/python/cuml/cuml/svm/linear_svc.py index 5b66732a4f..bb53ecb633 100644 --- a/python/cuml/cuml/svm/linear_svc.py +++ b/python/cuml/cuml/svm/linear_svc.py @@ -1,4 +1,4 @@ -# Copyright (c) 2021-2023, NVIDIA CORPORATION. +# Copyright (c) 2021-2024, NVIDIA CORPORATION. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/python/cuml/cuml/svm/linear_svr.py b/python/cuml/cuml/svm/linear_svr.py index 87c48c060b..93c2c13389 100644 --- a/python/cuml/cuml/svm/linear_svr.py +++ b/python/cuml/cuml/svm/linear_svr.py @@ -1,4 +1,4 @@ -# Copyright (c) 2021-2023, NVIDIA CORPORATION. +# Copyright (c) 2021-2024, NVIDIA CORPORATION. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/python/cuml/cuml/svm/svm_base.pyx b/python/cuml/cuml/svm/svm_base.pyx index 24f8b20fda..bd4f5c5681 100644 --- a/python/cuml/cuml/svm/svm_base.pyx +++ b/python/cuml/cuml/svm/svm_base.pyx @@ -1,4 +1,4 @@ -# Copyright (c) 2019-2023, NVIDIA CORPORATION. +# Copyright (c) 2019-2024, NVIDIA CORPORATION. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/python/cuml/cuml/tsa/holtwinters.pyx b/python/cuml/cuml/tsa/holtwinters.pyx index 165467d5eb..f22f363051 100644 --- a/python/cuml/cuml/tsa/holtwinters.pyx +++ b/python/cuml/cuml/tsa/holtwinters.pyx @@ -1,4 +1,4 @@ -# Copyright (c) 2019-2023, NVIDIA CORPORATION. +# Copyright (c) 2019-2024, NVIDIA CORPORATION. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. From abf81bd147d7abe3dda9dede0278bb85dda5f63d Mon Sep 17 00:00:00 2001 From: Dante Gama Dessavre Date: Mon, 7 Oct 2024 20:53:14 -0500 Subject: [PATCH 04/22] Style fixes --- python/cuml/cuml/dask/cluster/dbscan.py | 4 ++-- python/cuml/cuml/dask/decomposition/pca.py | 2 +- python/cuml/cuml/dask/decomposition/tsvd.py | 2 +- python/cuml/cuml/dask/linear_model/linear_regression.py | 2 +- python/cuml/cuml/dask/linear_model/ridge.py | 2 +- python/cuml/cuml/decomposition/incremental_pca.py | 8 ++++++-- 6 files changed, 12 insertions(+), 8 deletions(-) diff --git a/python/cuml/cuml/dask/cluster/dbscan.py b/python/cuml/cuml/dask/cluster/dbscan.py index 8538253bf3..51c22abca6 100644 --- a/python/cuml/cuml/dask/cluster/dbscan.py +++ b/python/cuml/cuml/dask/cluster/dbscan.py @@ -1,4 +1,4 @@ -# Copyright (c) 2020-2024, NVIDIA CORPORATION. +# Copyright (c) 2020-2023, NVIDIA CORPORATION. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -161,4 +161,4 @@ def fit_predict(self, X, out_dtype="int32"): return self.get_combined_model().labels_ def get_param_names(self): - return list(cls.kwargs.keys()) + return list(self.kwargs.keys()) diff --git a/python/cuml/cuml/dask/decomposition/pca.py b/python/cuml/cuml/dask/decomposition/pca.py index e82c39895c..896eb58606 100644 --- a/python/cuml/cuml/dask/decomposition/pca.py +++ b/python/cuml/cuml/dask/decomposition/pca.py @@ -1,4 +1,4 @@ -# Copyright (c) 2019-2024, NVIDIA CORPORATION. +# Copyright (c) 2019-2023, NVIDIA CORPORATION. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/python/cuml/cuml/dask/decomposition/tsvd.py b/python/cuml/cuml/dask/decomposition/tsvd.py index 696062d97b..d76d08bda2 100644 --- a/python/cuml/cuml/dask/decomposition/tsvd.py +++ b/python/cuml/cuml/dask/decomposition/tsvd.py @@ -1,4 +1,4 @@ -# Copyright (c) 2019-2024, NVIDIA CORPORATION. +# Copyright (c) 2019-2023, NVIDIA CORPORATION. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/python/cuml/cuml/dask/linear_model/linear_regression.py b/python/cuml/cuml/dask/linear_model/linear_regression.py index aaa797e96c..f8c5dfd7b0 100644 --- a/python/cuml/cuml/dask/linear_model/linear_regression.py +++ b/python/cuml/cuml/dask/linear_model/linear_regression.py @@ -1,4 +1,4 @@ -# Copyright (c) 2019-2024, NVIDIA CORPORATION. +# Copyright (c) 2019-2023, NVIDIA CORPORATION. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/python/cuml/cuml/dask/linear_model/ridge.py b/python/cuml/cuml/dask/linear_model/ridge.py index 18ddad3c52..32db990979 100644 --- a/python/cuml/cuml/dask/linear_model/ridge.py +++ b/python/cuml/cuml/dask/linear_model/ridge.py @@ -1,4 +1,4 @@ -# Copyright (c) 2019-2024, NVIDIA CORPORATION. +# Copyright (c) 2019-2023, NVIDIA CORPORATION. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/python/cuml/cuml/decomposition/incremental_pca.py b/python/cuml/cuml/decomposition/incremental_pca.py index ea77e1edd9..70663ceeb8 100644 --- a/python/cuml/cuml/decomposition/incremental_pca.py +++ b/python/cuml/cuml/decomposition/incremental_pca.py @@ -216,7 +216,6 @@ def __init__( output_type=output_type, ) self.batch_size = batch_size - self._hyperparams = ["n_components", "whiten", "copy", "batch_size"] self._sparse_model = True def fit(self, X, y=None, convert_dtype=True) -> "IncrementalPCA": @@ -452,7 +451,12 @@ def transform(self, X, convert_dtype=False) -> CumlArray: @classmethod def get_param_names(cls): # Skip super() since we dont pass any extra parameters in __init__ - return Base.get_param_names() + self._hyperparams + return Base.get_param_names() + [ + "n_components", + "whiten", + "copy", + "batch_size", + ] def _validate_sparse_input(X): From f43e580351e1a8c4eed08d271ed04817293dad8b Mon Sep 17 00:00:00 2001 From: Dante Gama Dessavre Date: Mon, 7 Oct 2024 21:28:58 -0500 Subject: [PATCH 05/22] FIX self to cls in qn.pyx --- python/cuml/cuml/solvers/qn.pyx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/cuml/cuml/solvers/qn.pyx b/python/cuml/cuml/solvers/qn.pyx index 5b558a6770..6d04242ee4 100644 --- a/python/cuml/cuml/solvers/qn.pyx +++ b/python/cuml/cuml/solvers/qn.pyx @@ -184,7 +184,7 @@ IF GPUBUILD == 1: @classmethod def get_param_names(cls): - return self.get_param_defaults().keys() + return cls.get_param_defaults().keys() def __str__(self): return type(self).__name__ + str(self.params) From c9021649a598c7981aa52605ad694cda844055ff Mon Sep 17 00:00:00 2001 From: Dante Gama Dessavre Date: Mon, 7 Oct 2024 21:56:13 -0500 Subject: [PATCH 06/22] FIX final typo fix hopefully --- python/cuml/cuml/random_projection/random_projection.pyx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/cuml/cuml/random_projection/random_projection.pyx b/python/cuml/cuml/random_projection/random_projection.pyx index bf1a06f4cb..18c61b606e 100644 --- a/python/cuml/cuml/random_projection/random_projection.pyx +++ b/python/cuml/cuml/random_projection/random_projection.pyx @@ -447,7 +447,7 @@ class GaussianRandomProjection(Base, @classmethod def get_param_names(cls): - return Base.get_param_names(self) + [ + return Base.get_param_names() + [ "n_components", "eps", "random_state" From d46c31d6a1367fb2e9dc49f494855ffaa3868b49 Mon Sep 17 00:00:00 2001 From: divyegala Date: Wed, 6 Nov 2024 15:56:24 -0800 Subject: [PATCH 07/22] passing tests --- .../sklearn/preprocessing/_data.py | 34 +++++++++++-------- .../sklearn/preprocessing/_discretization.py | 4 +-- .../sklearn/preprocessing/_imputation.py | 9 ++--- python/cuml/cuml/cluster/agglomerative.pyx | 4 +-- python/cuml/cuml/cluster/dbscan.pyx | 4 +-- python/cuml/cuml/cluster/hdbscan/hdbscan.pyx | 4 +-- python/cuml/cuml/cluster/kmeans.pyx | 4 +-- python/cuml/cuml/dask/cluster/dbscan.py | 4 +-- python/cuml/cuml/dask/cluster/kmeans.py | 2 +- python/cuml/cuml/dask/decomposition/pca.py | 4 +-- python/cuml/cuml/dask/decomposition/tsvd.py | 4 +-- .../dask/linear_model/linear_regression.py | 4 +-- python/cuml/cuml/dask/linear_model/ridge.py | 4 +-- .../cuml/decomposition/incremental_pca.py | 4 +-- python/cuml/cuml/decomposition/pca.pyx | 4 +-- python/cuml/cuml/decomposition/tsvd.pyx | 4 +-- .../cuml/ensemble/randomforest_common.pyx | 4 +-- .../cuml/experimental/linear_model/lars.pyx | 4 +-- python/cuml/cuml/feature_extraction/_tfidf.py | 4 +-- python/cuml/cuml/internals/base.pyx | 13 +++---- .../cuml/cuml/kernel_ridge/kernel_ridge.pyx | 4 +-- python/cuml/cuml/linear_model/elastic_net.pyx | 4 +-- python/cuml/cuml/linear_model/lasso.py | 4 +-- .../cuml/linear_model/linear_regression.pyx | 4 +-- .../cuml/linear_model/logistic_regression.pyx | 4 +-- .../cuml/linear_model/mbsgd_classifier.pyx | 4 +-- .../cuml/linear_model/mbsgd_regressor.pyx | 4 +-- python/cuml/cuml/linear_model/ridge.pyx | 4 +-- python/cuml/cuml/manifold/t_sne.pyx | 4 +-- python/cuml/cuml/manifold/umap.pyx | 4 +-- python/cuml/cuml/multiclass/multiclass.py | 14 ++++---- python/cuml/cuml/naive_bayes/naive_bayes.py | 19 ++++++----- python/cuml/cuml/neighbors/kernel_density.py | 4 +-- .../cuml/neighbors/kneighbors_classifier.pyx | 4 +-- .../cuml/neighbors/kneighbors_regressor.pyx | 4 +-- .../cuml/cuml/neighbors/nearest_neighbors.pyx | 4 +-- .../cuml/cuml/preprocessing/LabelEncoder.py | 4 +-- .../cuml/cuml/preprocessing/TargetEncoder.py | 4 +-- python/cuml/cuml/preprocessing/encoders.py | 9 ++--- python/cuml/cuml/preprocessing/label.py | 4 +-- .../random_projection/random_projection.pyx | 9 ++--- python/cuml/cuml/solvers/cd.pyx | 4 +-- python/cuml/cuml/solvers/qn.pyx | 9 ++--- python/cuml/cuml/solvers/sgd.pyx | 4 +-- python/cuml/cuml/svm/linear.pyx | 12 +++---- python/cuml/cuml/svm/linear_svc.py | 4 +-- python/cuml/cuml/svm/linear_svr.py | 4 +-- python/cuml/cuml/svm/svc.pyx | 4 +-- python/cuml/cuml/svm/svm_base.pyx | 4 +-- .../tests/explainer/test_explainer_common.py | 6 ++-- python/cuml/cuml/tests/test_base.py | 12 +++---- python/cuml/cuml/tsa/arima.pyx | 11 +++--- python/cuml/cuml/tsa/holtwinters.pyx | 4 +-- wiki/python/ESTIMATOR_GUIDE.md | 20 ++++++----- 54 files changed, 177 insertions(+), 162 deletions(-) diff --git a/python/cuml/cuml/_thirdparty/sklearn/preprocessing/_data.py b/python/cuml/cuml/_thirdparty/sklearn/preprocessing/_data.py index b510ff97ac..9d4cb89bba 100644 --- a/python/cuml/cuml/_thirdparty/sklearn/preprocessing/_data.py +++ b/python/cuml/cuml/_thirdparty/sklearn/preprocessing/_data.py @@ -327,8 +327,8 @@ def _reset(self): del self.data_range_ @classmethod - def get_param_names(cls): - return super().get_param_names() + [ + def _get_param_names(cls): + return super()._get_param_names() + [ "feature_range", "copy" ] @@ -652,8 +652,9 @@ def _reset(self): del self.mean_ del self.var_ - def get_param_names(self): - return super().get_param_names() + [ + @classmethod + def _get_param_names(cls): + return super()._get_param_names() + [ "with_mean", "with_std", "copy" @@ -956,8 +957,9 @@ def _reset(self): del self.n_samples_seen_ del self.max_abs_ - def get_param_names(self): - return super().get_param_names() + [ + @classmethod + def _get_param_names(cls): + return super()._get_param_names() + [ "copy" ] @@ -1206,8 +1208,9 @@ def __init__(self, *, with_centering=True, with_scaling=True, self.quantile_range = quantile_range self.copy = copy - def get_param_names(self): - return super().get_param_names() + [ + @classmethod + def _get_param_names(cls): + return super()._get_param_names() + [ "with_centering", "with_scaling", "quantile_range", @@ -1479,8 +1482,9 @@ def __init__(self, degree=2, *, interaction_only=False, include_bias=True, self.include_bias = include_bias self.order = order - def get_param_names(self): - return super().get_param_names() + [ + @classmethod + def _get_param_names(cls): + return super()._get_param_names() + [ "degree", "interaction_only", "include_bias", @@ -2274,8 +2278,9 @@ def __init__(self, *, n_quantiles=1000, output_distribution='uniform', self.random_state = random_state self.copy = copy - def get_param_names(self): - return super().get_param_names() + [ + @classmethod + def _get_param_names(cls): + return super()._get_param_names() + [ "n_quantiles", "output_distribution", "ignore_implicit_zeros", @@ -2798,8 +2803,9 @@ def __init__(self, method='yeo-johnson', *, standardize=True, copy=True): self.standardize = standardize self.copy = copy - def get_param_names(self): - return super().get_param_names() + [ + @classmethod + def _get_param_names(cls): + return super()._get_param_names() + [ "method", "standardize", "copy" diff --git a/python/cuml/cuml/_thirdparty/sklearn/preprocessing/_discretization.py b/python/cuml/cuml/_thirdparty/sklearn/preprocessing/_discretization.py index 5bafc40677..6bf6390fcf 100644 --- a/python/cuml/cuml/_thirdparty/sklearn/preprocessing/_discretization.py +++ b/python/cuml/cuml/_thirdparty/sklearn/preprocessing/_discretization.py @@ -159,8 +159,8 @@ def __init__(self, n_bins=5, *, encode='onehot', strategy='quantile'): self.strategy = strategy @classmethod - def get_param_names(cls): - return super().get_param_names() + [ + def _get_param_names(cls): + return super()._get_param_names() + [ "n_bins", "encode", "strategy" diff --git a/python/cuml/cuml/_thirdparty/sklearn/preprocessing/_imputation.py b/python/cuml/cuml/_thirdparty/sklearn/preprocessing/_imputation.py index 8074ee4b54..2be0c81ff0 100644 --- a/python/cuml/cuml/_thirdparty/sklearn/preprocessing/_imputation.py +++ b/python/cuml/cuml/_thirdparty/sklearn/preprocessing/_imputation.py @@ -244,8 +244,8 @@ def __init__(self, *, missing_values=np.nan, strategy="mean", self.copy = copy @classmethod - def get_param_names(cls): - return super().get_param_names() + [ + def _get_param_names(cls): + return super()._get_param_names() + [ "strategy", "fill_value", "verbose", @@ -545,8 +545,9 @@ def __init__(self, *, missing_values=np.nan, features="missing-only", self.sparse = sparse self.error_on_new = error_on_new - def get_param_names(self): - return super().get_param_names() + [ + @classmethod + def _get_param_names(cls): + return super()._get_param_names() + [ "missing_values", "features", "sparse", diff --git a/python/cuml/cuml/cluster/agglomerative.pyx b/python/cuml/cuml/cluster/agglomerative.pyx index cc2fd1021d..790db73362 100644 --- a/python/cuml/cuml/cluster/agglomerative.pyx +++ b/python/cuml/cuml/cluster/agglomerative.pyx @@ -280,8 +280,8 @@ class AgglomerativeClustering(Base, ClusterMixin, CMajorInputTagMixin): return self.fit(X).labels_ @classmethod - def get_param_names(cls): - return super().get_param_names() + [ + def _get_param_names(cls): + return super()._get_param_names() + [ "n_clusters", "affinity", "metric", diff --git a/python/cuml/cuml/cluster/dbscan.pyx b/python/cuml/cuml/cluster/dbscan.pyx index 139be36829..9480ccccf6 100644 --- a/python/cuml/cuml/cluster/dbscan.pyx +++ b/python/cuml/cuml/cluster/dbscan.pyx @@ -467,8 +467,8 @@ class DBSCAN(UniversalBase, return self.labels_ @classmethod - def get_param_names(cls): - return super().get_param_names() + [ + def _get_param_names(cls): + return super()._get_param_names() + [ "eps", "min_samples", "max_mbytes_per_batch", diff --git a/python/cuml/cuml/cluster/hdbscan/hdbscan.pyx b/python/cuml/cuml/cluster/hdbscan/hdbscan.pyx index e27f54cc85..0078b145c7 100644 --- a/python/cuml/cuml/cluster/hdbscan/hdbscan.pyx +++ b/python/cuml/cuml/cluster/hdbscan/hdbscan.pyx @@ -1113,8 +1113,8 @@ class HDBSCAN(UniversalBase, ClusterMixin, CMajorInputTagMixin): self._cpu_to_gpu_interop_prepped = True @classmethod - def get_param_names(cls): - return super().get_param_names() + [ + def _get_param_names(cls): + return super()._get_param_names() + [ "metric", "min_cluster_size", "max_cluster_size", diff --git a/python/cuml/cuml/cluster/kmeans.pyx b/python/cuml/cuml/cluster/kmeans.pyx index a2de8da9a3..9ba1cb710a 100644 --- a/python/cuml/cuml/cluster/kmeans.pyx +++ b/python/cuml/cuml/cluster/kmeans.pyx @@ -697,8 +697,8 @@ class KMeans(UniversalBase, return self.transform(X, convert_dtype=convert_dtype) @classmethod - def get_param_names(cls): - return super().get_param_names() + \ + def _get_param_names(cls): + return super()._get_param_names() + \ ['n_init', 'oversampling_factor', 'max_samples_per_batch', 'init', 'max_iter', 'n_clusters', 'random_state', 'tol', "convert_dtype"] diff --git a/python/cuml/cuml/dask/cluster/dbscan.py b/python/cuml/cuml/dask/cluster/dbscan.py index 51c22abca6..b71e34682a 100644 --- a/python/cuml/cuml/dask/cluster/dbscan.py +++ b/python/cuml/cuml/dask/cluster/dbscan.py @@ -1,4 +1,4 @@ -# Copyright (c) 2020-2023, NVIDIA CORPORATION. +# Copyright (c) 2020-2024, NVIDIA CORPORATION. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -160,5 +160,5 @@ def fit_predict(self, X, out_dtype="int32"): self.fit(X, out_dtype) return self.get_combined_model().labels_ - def get_param_names(self): + def _get_param_names(self): return list(self.kwargs.keys()) diff --git a/python/cuml/cuml/dask/cluster/kmeans.py b/python/cuml/cuml/dask/cluster/kmeans.py index b014dc4fb2..3dfeced41f 100644 --- a/python/cuml/cuml/dask/cluster/kmeans.py +++ b/python/cuml/cuml/dask/cluster/kmeans.py @@ -302,5 +302,5 @@ def score(self, X, sample_weight=None): cp.asarray(self.client.compute(scores, sync=True)) * -1.0 ) - def get_param_names(self): + def _get_param_names(self): return list(self.kwargs.keys()) diff --git a/python/cuml/cuml/dask/decomposition/pca.py b/python/cuml/cuml/dask/decomposition/pca.py index 896eb58606..8cebb2764f 100644 --- a/python/cuml/cuml/dask/decomposition/pca.py +++ b/python/cuml/cuml/dask/decomposition/pca.py @@ -1,4 +1,4 @@ -# Copyright (c) 2019-2023, NVIDIA CORPORATION. +# Copyright (c) 2019-2024, NVIDIA CORPORATION. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -215,7 +215,7 @@ def inverse_transform(self, X, delayed=True): """ return self._inverse_transform(X, n_dims=2, delayed=delayed) - def get_param_names(self): + def _get_param_names(self): return list(self.kwargs.keys()) @staticmethod diff --git a/python/cuml/cuml/dask/decomposition/tsvd.py b/python/cuml/cuml/dask/decomposition/tsvd.py index d76d08bda2..67392b7555 100644 --- a/python/cuml/cuml/dask/decomposition/tsvd.py +++ b/python/cuml/cuml/dask/decomposition/tsvd.py @@ -1,4 +1,4 @@ -# Copyright (c) 2019-2023, NVIDIA CORPORATION. +# Copyright (c) 2019-2024, NVIDIA CORPORATION. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -177,7 +177,7 @@ def inverse_transform(self, X, delayed=True): """ return self._inverse_transform(X, n_dims=2, delayed=delayed) - def get_param_names(self): + def _get_param_names(self): return list(self.kwargs.keys()) @staticmethod diff --git a/python/cuml/cuml/dask/linear_model/linear_regression.py b/python/cuml/cuml/dask/linear_model/linear_regression.py index f8c5dfd7b0..98ffba672d 100644 --- a/python/cuml/cuml/dask/linear_model/linear_regression.py +++ b/python/cuml/cuml/dask/linear_model/linear_regression.py @@ -1,4 +1,4 @@ -# Copyright (c) 2019-2023, NVIDIA CORPORATION. +# Copyright (c) 2019-2024, NVIDIA CORPORATION. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -106,7 +106,7 @@ def predict(self, X, delayed=True): """ return self._predict(X, delayed=delayed) - def get_param_names(self): + def _get_param_names(self): return list(self.kwargs.keys()) @staticmethod diff --git a/python/cuml/cuml/dask/linear_model/ridge.py b/python/cuml/cuml/dask/linear_model/ridge.py index 32db990979..2830f3ce38 100644 --- a/python/cuml/cuml/dask/linear_model/ridge.py +++ b/python/cuml/cuml/dask/linear_model/ridge.py @@ -1,4 +1,4 @@ -# Copyright (c) 2019-2023, NVIDIA CORPORATION. +# Copyright (c) 2019-2024, NVIDIA CORPORATION. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -114,7 +114,7 @@ def predict(self, X, delayed=True): """ return self._predict(X, delayed=delayed) - def get_param_names(self): + def _get_param_names(self): return list(self.kwargs.keys()) @staticmethod diff --git a/python/cuml/cuml/decomposition/incremental_pca.py b/python/cuml/cuml/decomposition/incremental_pca.py index 70663ceeb8..925219d0bc 100644 --- a/python/cuml/cuml/decomposition/incremental_pca.py +++ b/python/cuml/cuml/decomposition/incremental_pca.py @@ -449,9 +449,9 @@ def transform(self, X, convert_dtype=False) -> CumlArray: return super().transform(X) @classmethod - def get_param_names(cls): + def _get_param_names(cls): # Skip super() since we dont pass any extra parameters in __init__ - return Base.get_param_names() + [ + return Base._get_param_names() + [ "n_components", "whiten", "copy", diff --git a/python/cuml/cuml/decomposition/pca.pyx b/python/cuml/cuml/decomposition/pca.pyx index 08f8f2ec6e..9433f724b9 100644 --- a/python/cuml/cuml/decomposition/pca.pyx +++ b/python/cuml/cuml/decomposition/pca.pyx @@ -726,8 +726,8 @@ class PCA(UniversalBase, return t_input_data @classmethod - def get_param_names(cls): - return super().get_param_names() + \ + def _get_param_names(cls): + return super()._get_param_names() + \ ["copy", "iterated_power", "n_components", "svd_solver", "tol", "whiten", "random_state"] diff --git a/python/cuml/cuml/decomposition/tsvd.pyx b/python/cuml/cuml/decomposition/tsvd.pyx index dd8dfe0282..55caa84c9b 100644 --- a/python/cuml/cuml/decomposition/tsvd.pyx +++ b/python/cuml/cuml/decomposition/tsvd.pyx @@ -483,8 +483,8 @@ class TruncatedSVD(UniversalBase, return t_input_data @classmethod - def get_param_names(cls): - return super().get_param_names() + \ + def _get_param_names(cls): + return super()._get_param_names() + \ ["algorithm", "n_components", "n_iter", "random_state", "tol"] def get_attr_names(self): diff --git a/python/cuml/cuml/ensemble/randomforest_common.pyx b/python/cuml/cuml/ensemble/randomforest_common.pyx index a6dd918879..38c15eaca2 100644 --- a/python/cuml/cuml/ensemble/randomforest_common.pyx +++ b/python/cuml/cuml/ensemble/randomforest_common.pyx @@ -383,8 +383,8 @@ class BaseRandomForestModel(Base): return preds @classmethod - def get_param_names(cls): - return super().get_param_names() + BaseRandomForestModel._param_names + def _get_param_names(cls): + return super()._get_param_names() + BaseRandomForestModel._param_names def set_params(self, **params): self.treelite_serialized_model = None diff --git a/python/cuml/cuml/experimental/linear_model/lars.pyx b/python/cuml/cuml/experimental/linear_model/lars.pyx index 6a81b0bc23..4a836740c7 100644 --- a/python/cuml/cuml/experimental/linear_model/lars.pyx +++ b/python/cuml/cuml/experimental/linear_model/lars.pyx @@ -398,7 +398,7 @@ class Lars(Base, RegressorMixin): return preds @classmethod - def get_param_names(cls): - return super().get_param_names() + \ + def _get_param_names(cls): + return super()._get_param_names() + \ ['copy_X', 'fit_intercept', 'fit_path', 'n_nonzero_coefs', 'normalize', 'precompute', 'eps'] diff --git a/python/cuml/cuml/feature_extraction/_tfidf.py b/python/cuml/cuml/feature_extraction/_tfidf.py index c4d7a5fd1b..2cf5974119 100644 --- a/python/cuml/cuml/feature_extraction/_tfidf.py +++ b/python/cuml/cuml/feature_extraction/_tfidf.py @@ -302,8 +302,8 @@ def idf_(self, value): ) @classmethod - def get_param_names(cls): - return super().get_param_names() + [ + def _get_param_names(cls): + return super()._get_param_names() + [ "norm", "use_idf", "smooth_idf", diff --git a/python/cuml/cuml/internals/base.pyx b/python/cuml/cuml/internals/base.pyx index ad6f761799..9813acbba4 100644 --- a/python/cuml/cuml/internals/base.pyx +++ b/python/cuml/cuml/internals/base.pyx @@ -183,7 +183,7 @@ class Base(TagsMixin, # inference logic goes here @classmethod - def get_param_names(cls): + def _get_param_names(cls): # return a list of hyperparam names supported by this algo # stream and handle example: @@ -271,7 +271,8 @@ class Base(TagsMixin, output += ' ' return output - def get_param_names(self): + @classmethod + def _get_param_names(cls): """ Returns a list of hyperparameter names owned by this class. It is expected that every child class overrides this method and appends its @@ -283,12 +284,12 @@ class Base(TagsMixin, def get_params(self, deep=True): """ Returns a dict of all params owned by this class. If the child class - has appropriately overridden the `get_param_names` method and does not + has appropriately overridden the `_get_param_names` method and does not need anything other than what is there in this method, then it doesn't have to override this method """ params = dict() - variables = self.get_param_names() + variables = self._get_param_names() for key in variables: var_value = getattr(self, key, None) params[key] = var_value @@ -298,12 +299,12 @@ class Base(TagsMixin, """ Accepts a dict of params and updates the corresponding ones owned by this class. If the child class has appropriately overridden the - `get_param_names` method and does not need anything other than what is, + `_get_param_names` method and does not need anything other than what is, there in this method, then it doesn't have to override this method """ if not params: return self - variables = self.get_param_names() + variables = self._get_param_names() for key, value in params.items(): if key not in variables: raise ValueError("Bad param '%s' passed to set_params" % key) diff --git a/python/cuml/cuml/kernel_ridge/kernel_ridge.pyx b/python/cuml/cuml/kernel_ridge/kernel_ridge.pyx index 98d1bd98de..6063f27c99 100644 --- a/python/cuml/cuml/kernel_ridge/kernel_ridge.pyx +++ b/python/cuml/cuml/kernel_ridge/kernel_ridge.pyx @@ -227,8 +227,8 @@ class KernelRidge(Base, RegressorMixin): self.kernel_params = kernel_params @classmethod - def get_param_names(cls): - return super().get_param_names() + [ + def _get_param_names(cls): + return super()._get_param_names() + [ "alpha", "kernel", "gamma", diff --git a/python/cuml/cuml/linear_model/elastic_net.pyx b/python/cuml/cuml/linear_model/elastic_net.pyx index cae04051e1..a8e6b75a3d 100644 --- a/python/cuml/cuml/linear_model/elastic_net.pyx +++ b/python/cuml/cuml/linear_model/elastic_net.pyx @@ -273,8 +273,8 @@ class ElasticNet(UniversalBase, return self @classmethod - def get_param_names(cls): - return super().get_param_names() + [ + def _get_param_names(cls): + return super()._get_param_names() + [ "alpha", "l1_ratio", "fit_intercept", diff --git a/python/cuml/cuml/linear_model/lasso.py b/python/cuml/cuml/linear_model/lasso.py index 9b0aafa5ef..65a30be791 100644 --- a/python/cuml/cuml/linear_model/lasso.py +++ b/python/cuml/cuml/linear_model/lasso.py @@ -166,5 +166,5 @@ def __init__( ) @classmethod - def get_param_names(cls): - return list(set(super().get_param_names()) - {"l1_ratio"}) + def _get_param_names(cls): + return list(set(super()._get_param_names()) - {"l1_ratio"}) diff --git a/python/cuml/cuml/linear_model/linear_regression.pyx b/python/cuml/cuml/linear_model/linear_regression.pyx index ba38ba1711..35a73c111f 100644 --- a/python/cuml/cuml/linear_model/linear_regression.pyx +++ b/python/cuml/cuml/linear_model/linear_regression.pyx @@ -492,8 +492,8 @@ class LinearRegression(LinearPredictMixin, return super()._predict(X, convert_dtype=convert_dtype) @classmethod - def get_param_names(cls): - return super().get_param_names() + \ + def _get_param_names(cls): + return super()._get_param_names() + \ ['algorithm', 'fit_intercept', 'copy_X', 'normalize'] def get_attr_names(self): diff --git a/python/cuml/cuml/linear_model/logistic_regression.pyx b/python/cuml/cuml/linear_model/logistic_regression.pyx index 1d08d2f102..aa5283fef7 100644 --- a/python/cuml/cuml/linear_model/logistic_regression.pyx +++ b/python/cuml/cuml/linear_model/logistic_regression.pyx @@ -535,8 +535,8 @@ class LogisticRegression(UniversalBase, self.solver_model.intercept_ = value @classmethod - def get_param_names(cls): - return super().get_param_names() + [ + def _get_param_names(cls): + return super()._get_param_names() + [ "penalty", "tol", "C", diff --git a/python/cuml/cuml/linear_model/mbsgd_classifier.pyx b/python/cuml/cuml/linear_model/mbsgd_classifier.pyx index 6f13288f8d..3a7fcc772e 100644 --- a/python/cuml/cuml/linear_model/mbsgd_classifier.pyx +++ b/python/cuml/cuml/linear_model/mbsgd_classifier.pyx @@ -214,8 +214,8 @@ class MBSGDClassifier(Base, return self @classmethod - def get_param_names(cls): - return super().get_param_names() + [ + def _get_param_names(cls): + return super()._get_param_names() + [ "loss", "penalty", "alpha", diff --git a/python/cuml/cuml/linear_model/mbsgd_regressor.pyx b/python/cuml/cuml/linear_model/mbsgd_regressor.pyx index 1a2a4b52b8..a738eb6d74 100644 --- a/python/cuml/cuml/linear_model/mbsgd_regressor.pyx +++ b/python/cuml/cuml/linear_model/mbsgd_regressor.pyx @@ -208,8 +208,8 @@ class MBSGDRegressor(Base, return self @classmethod - def get_param_names(cls): - return super().get_param_names() + [ + def _get_param_names(cls): + return super()._get_param_names() + [ "loss", "penalty", "alpha", diff --git a/python/cuml/cuml/linear_model/ridge.pyx b/python/cuml/cuml/linear_model/ridge.pyx index 92a94ddf9e..ae84f1002a 100644 --- a/python/cuml/cuml/linear_model/ridge.pyx +++ b/python/cuml/cuml/linear_model/ridge.pyx @@ -357,8 +357,8 @@ class Ridge(UniversalBase, return self @classmethod - def get_param_names(cls): - return super().get_param_names() + \ + def _get_param_names(cls): + return super()._get_param_names() + \ ['solver', 'fit_intercept', 'normalize', 'alpha'] def get_attr_names(self): diff --git a/python/cuml/cuml/manifold/t_sne.pyx b/python/cuml/cuml/manifold/t_sne.pyx index 7d608167a1..11ade2ffe2 100644 --- a/python/cuml/cuml/manifold/t_sne.pyx +++ b/python/cuml/cuml/manifold/t_sne.pyx @@ -691,8 +691,8 @@ class TSNE(UniversalBase, return state @classmethod - def get_param_names(cls): - return super().get_param_names() + [ + def _get_param_names(cls): + return super()._get_param_names() + [ "n_components", "perplexity", "early_exaggeration", diff --git a/python/cuml/cuml/manifold/umap.pyx b/python/cuml/cuml/manifold/umap.pyx index dc5f037467..c873461a95 100644 --- a/python/cuml/cuml/manifold/umap.pyx +++ b/python/cuml/cuml/manifold/umap.pyx @@ -910,8 +910,8 @@ class UMAP(UniversalBase, super().gpu_to_cpu() @classmethod - def get_param_names(cls): - return super().get_param_names() + [ + def _get_param_names(cls): + return super()._get_param_names() + [ "n_neighbors", "n_components", "n_epochs", diff --git a/python/cuml/cuml/multiclass/multiclass.py b/python/cuml/cuml/multiclass/multiclass.py index 45671a275b..61a79e1d31 100644 --- a/python/cuml/cuml/multiclass/multiclass.py +++ b/python/cuml/cuml/multiclass/multiclass.py @@ -193,8 +193,8 @@ def decision_function(self, X) -> CumlArray: return self.multiclass_estimator.decision_function(X) @classmethod - def get_param_names(cls): - return super().get_param_names() + ["estimator", "strategy"] + def _get_param_names(cls): + return super()._get_param_names() + ["estimator", "strategy"] class OneVsRestClassifier(MulticlassClassifier): @@ -266,8 +266,9 @@ def __init__( strategy="ovr", ) - def get_param_names(self): - param_names = super().get_param_names() + @classmethod + def _get_param_names(cls): + param_names = super()._get_param_names() param_names.remove("strategy") return param_names @@ -340,7 +341,8 @@ def __init__( strategy="ovo", ) - def get_param_names(self): - param_names = super().get_param_names() + @classmethod + def _get_param_names(cls): + param_names = super()._get_param_names() param_names.remove("strategy") return param_names diff --git a/python/cuml/cuml/naive_bayes/naive_bayes.py b/python/cuml/cuml/naive_bayes/naive_bayes.py index b393090ea6..701f88862e 100644 --- a/python/cuml/cuml/naive_bayes/naive_bayes.py +++ b/python/cuml/cuml/naive_bayes/naive_bayes.py @@ -725,8 +725,8 @@ def _joint_log_likelihood(self, X): return cp.array(joint_log_likelihood).T @classmethod - def get_param_names(cls): - return super().get_param_names() + ["priors", "var_smoothing"] + def _get_param_names(cls): + return super()._get_param_names() + ["priors", "var_smoothing"] class _BaseDiscreteNB(_BaseNB): @@ -1059,8 +1059,9 @@ def _count_sparse( self.feature_count_ = self.feature_count_ + counts self.class_count_ = self.class_count_ + class_c - def get_param_names(self): - return super().get_param_names() + [ + @classmethod + def _get_param_names(cls): + return super()._get_param_names() + [ "alpha", "fit_prior", "class_prior", @@ -1374,8 +1375,9 @@ def _update_feature_log_prob(self, alpha): smoothed_cc.reshape(-1, 1) ) - def get_param_names(self): - return super().get_param_names() + ["binarize"] + @classmethod + def _get_param_names(cls): + return super()._get_param_names() + ["binarize"] class ComplementNB(_BaseDiscreteNB): @@ -1537,8 +1539,9 @@ def _update_feature_log_prob(self, alpha): feature_log_prob = -logged self.feature_log_prob_ = feature_log_prob - def get_param_names(self): - return super().get_param_names() + ["norm"] + @classmethod + def _get_param_names(cls): + return super()._get_param_names() + ["norm"] class CategoricalNB(_BaseDiscreteNB): diff --git a/python/cuml/cuml/neighbors/kernel_density.py b/python/cuml/cuml/neighbors/kernel_density.py index ceb88e91b2..3af2107995 100644 --- a/python/cuml/cuml/neighbors/kernel_density.py +++ b/python/cuml/cuml/neighbors/kernel_density.py @@ -226,8 +226,8 @@ def __init__( raise ValueError("invalid kernel: '{0}'".format(kernel)) @classmethod - def get_param_names(cls): - return super().get_param_names() + [ + def _get_param_names(cls): + return super()._get_param_names() + [ "bandwidth", "kernel", "metric", diff --git a/python/cuml/cuml/neighbors/kneighbors_classifier.pyx b/python/cuml/cuml/neighbors/kneighbors_classifier.pyx index 0751bf4c5c..ed72909203 100644 --- a/python/cuml/cuml/neighbors/kneighbors_classifier.pyx +++ b/python/cuml/cuml/neighbors/kneighbors_classifier.pyx @@ -299,5 +299,5 @@ class KNeighborsClassifier(ClassifierMixin, if len(final_classes) == 1 else tuple(final_classes) @classmethod - def get_param_names(cls): - return super().get_param_names() + ["weights"] + def _get_param_names(cls): + return super()._get_param_names() + ["weights"] diff --git a/python/cuml/cuml/neighbors/kneighbors_regressor.pyx b/python/cuml/cuml/neighbors/kneighbors_regressor.pyx index c9ec8f1b93..75fcfce5c6 100644 --- a/python/cuml/cuml/neighbors/kneighbors_regressor.pyx +++ b/python/cuml/cuml/neighbors/kneighbors_regressor.pyx @@ -235,5 +235,5 @@ class KNeighborsRegressor(RegressorMixin, return results @classmethod - def get_param_names(cls): - return super().get_param_names() + ["weights"] + def _get_param_names(cls): + return super()._get_param_names() + ["weights"] diff --git a/python/cuml/cuml/neighbors/nearest_neighbors.pyx b/python/cuml/cuml/neighbors/nearest_neighbors.pyx index 0c0ebb09a3..202d65ca8b 100644 --- a/python/cuml/cuml/neighbors/nearest_neighbors.pyx +++ b/python/cuml/cuml/neighbors/nearest_neighbors.pyx @@ -421,8 +421,8 @@ class NearestNeighbors(UniversalBase, return self @classmethod - def get_param_names(cls): - return super().get_param_names() + \ + def _get_param_names(cls): + return super()._get_param_names() + \ ["n_neighbors", "algorithm", "metric", "p", "metric_params", "algo_params", "n_jobs"] diff --git a/python/cuml/cuml/preprocessing/LabelEncoder.py b/python/cuml/cuml/preprocessing/LabelEncoder.py index ba6c1975ab..960935e61f 100644 --- a/python/cuml/cuml/preprocessing/LabelEncoder.py +++ b/python/cuml/cuml/preprocessing/LabelEncoder.py @@ -285,7 +285,7 @@ def inverse_transform(self, y: cudf.Series) -> cudf.Series: return res @classmethod - def get_param_names(cls): - return super().get_param_names() + [ + def _get_param_names(cls): + return super()._get_param_names() + [ "handle_unknown", ] diff --git a/python/cuml/cuml/preprocessing/TargetEncoder.py b/python/cuml/cuml/preprocessing/TargetEncoder.py index d0df2fbf66..a6e7524cf0 100644 --- a/python/cuml/cuml/preprocessing/TargetEncoder.py +++ b/python/cuml/cuml/preprocessing/TargetEncoder.py @@ -509,7 +509,7 @@ def _get_output_type(self, x): return "cupy" @classmethod - def get_param_names(cls): + def _get_param_names(cls): return [ "n_folds", "smooth", @@ -522,7 +522,7 @@ def get_params(self, deep=False): Returns a dict of all params owned by this class. """ params = dict() - variables = self.get_param_names() + variables = self._get_param_names() for key in variables: var_value = getattr(self, key, None) params[key] = var_value diff --git a/python/cuml/cuml/preprocessing/encoders.py b/python/cuml/cuml/preprocessing/encoders.py index 7874aaf4dc..943f3c294c 100644 --- a/python/cuml/cuml/preprocessing/encoders.py +++ b/python/cuml/cuml/preprocessing/encoders.py @@ -603,8 +603,8 @@ def get_feature_names(self, input_features=None): return np.array(feature_names, dtype=object) @classmethod - def get_param_names(cls): - return super().get_param_names() + [ + def _get_param_names(cls): + return super()._get_param_names() + [ "categories", "drop", "sparse", @@ -768,8 +768,9 @@ def inverse_transform(self, X): r = DataFrame(result) return _get_output(self.output_type, self.input_type, r, self.dtype) - def get_param_names(self): - return super().get_param_names() + [ + @classmethod + def _get_param_names(cls): + return super()._get_param_names() + [ "categories", "dtype", "handle_unknown", diff --git a/python/cuml/cuml/preprocessing/label.py b/python/cuml/cuml/preprocessing/label.py index 742383188d..20aac36ac8 100644 --- a/python/cuml/cuml/preprocessing/label.py +++ b/python/cuml/cuml/preprocessing/label.py @@ -288,8 +288,8 @@ def inverse_transform(self, y, threshold=None) -> CumlArray: return invert_labels(y_mapped, self.classes_) @classmethod - def get_param_names(cls): - return super().get_param_names() + [ + def _get_param_names(cls): + return super()._get_param_names() + [ "neg_label", "pos_label", "sparse_output", diff --git a/python/cuml/cuml/random_projection/random_projection.pyx b/python/cuml/cuml/random_projection/random_projection.pyx index 18c61b606e..81811a4849 100644 --- a/python/cuml/cuml/random_projection/random_projection.pyx +++ b/python/cuml/cuml/random_projection/random_projection.pyx @@ -446,8 +446,8 @@ class GaussianRandomProjection(Base, random_state=random_state) @classmethod - def get_param_names(cls): - return Base.get_param_names() + [ + def _get_param_names(cls): + return Base._get_param_names() + [ "n_components", "eps", "random_state" @@ -590,8 +590,9 @@ class SparseRandomProjection(Base, dense_output=dense_output, random_state=random_state) - def get_param_names(self): - return Base.get_param_names(self) + [ + @classmethod + def _get_param_names(cls): + return Base._get_param_names() + [ "n_components", "density", "eps", diff --git a/python/cuml/cuml/solvers/cd.pyx b/python/cuml/cuml/solvers/cd.pyx index a6874f7025..ba6c5ac12b 100644 --- a/python/cuml/cuml/solvers/cd.pyx +++ b/python/cuml/cuml/solvers/cd.pyx @@ -360,8 +360,8 @@ class CD(Base, return preds @classmethod - def get_param_names(cls): - return super().get_param_names() + [ + def _get_param_names(cls): + return super()._get_param_names() + [ "loss", "alpha", "l1_ratio", diff --git a/python/cuml/cuml/solvers/qn.pyx b/python/cuml/cuml/solvers/qn.pyx index 6d04242ee4..72f51c25b2 100644 --- a/python/cuml/cuml/solvers/qn.pyx +++ b/python/cuml/cuml/solvers/qn.pyx @@ -171,7 +171,7 @@ IF GPUBUILD == 1: return x def __init__(self, **kwargs): - allowed_keys = set(self.get_param_names()) + allowed_keys = set(self._get_param_names()) for key, val in kwargs.items(): if key in allowed_keys: setattr(self, key, val) @@ -183,7 +183,7 @@ IF GPUBUILD == 1: self.params[key] = val @classmethod - def get_param_names(cls): + def _get_param_names(cls): return cls.get_param_defaults().keys() def __str__(self): @@ -950,8 +950,9 @@ class QN(Base, else: self.intercept_ = CumlArray.zeros(shape=_num_classes) - def get_param_names(self): - return super().get_param_names() + \ + @classmethod + def _get_param_names(cls): + return super()._get_param_names() + \ ['loss', 'fit_intercept', 'l1_strength', 'l2_strength', 'max_iter', 'tol', 'linesearch_max_iter', 'lbfgs_memory', 'warm_start', 'delta', 'penalty_normalized'] diff --git a/python/cuml/cuml/solvers/sgd.pyx b/python/cuml/cuml/solvers/sgd.pyx index 6e10fab9a8..b6c452cc30 100644 --- a/python/cuml/cuml/solvers/sgd.pyx +++ b/python/cuml/cuml/solvers/sgd.pyx @@ -503,8 +503,8 @@ class SGD(Base, return preds @classmethod - def get_param_names(cls): - return super().get_param_names() + [ + def _get_param_names(cls): + return super()._get_param_names() + [ "loss", "penalty", "alpha", diff --git a/python/cuml/cuml/svm/linear.pyx b/python/cuml/cuml/svm/linear.pyx index ff84b33d5b..c5ff47cde9 100644 --- a/python/cuml/cuml/svm/linear.pyx +++ b/python/cuml/cuml/svm/linear.pyx @@ -146,13 +146,13 @@ cdef class LSVMPWrapper_: self.params[key] = val def __init__(self, **kwargs): - allowed_keys = set(self.get_param_names()) + allowed_keys = set(self._get_param_names()) for key, val in kwargs.items(): if key in allowed_keys: setattr(self, key, val) @classmethod - def get_param_names(cls): + def _get_param_names(cls): cdef LinearSVMParams ps return ps.keys() @@ -213,7 +213,7 @@ def __add_prop(prop_name): )) -for prop_name in LSVMPWrapper().get_param_names(): +for prop_name in LSVMPWrapper()._get_param_names(): if not hasattr(LSVMPWrapper, prop_name): __add_prop(prop_name) del __add_prop @@ -595,7 +595,7 @@ class LinearSVM(Base, metaclass=WithReexportedParams): return state def __init__(self, **kwargs): - # `tol` is special in that it's not present in get_param_names, + # `tol` is special in that it's not present in _get_param_names, # so having a special logic here does not affect pickling/cloning. tol = kwargs.pop('tol', None) if tol is not None: @@ -605,8 +605,8 @@ class LinearSVM(Base, metaclass=WithReexportedParams): self.change_tol = tol * default_to_ratio # All arguments are optional (they have defaults), # yet we need to check for unused arguments - allowed_keys = set(self.get_param_names()) - super_keys = set(super().get_param_names()) + allowed_keys = set(self._get_param_names()) + super_keys = set(super()._get_param_names()) remaining_kwargs = {} for key, val in kwargs.items(): if key not in allowed_keys or key in super_keys: diff --git a/python/cuml/cuml/svm/linear_svc.py b/python/cuml/cuml/svm/linear_svc.py index bb53ecb633..40df6f0808 100644 --- a/python/cuml/cuml/svm/linear_svc.py +++ b/python/cuml/cuml/svm/linear_svc.py @@ -174,7 +174,7 @@ def loss(self, loss: str): self.__loss = loss @classmethod - def get_param_names(cls): + def _get_param_names(cls): return list( { "handle", @@ -192,7 +192,7 @@ def get_param_names(cls): "grad_tol", "change_tol", "multi_class", - }.union(super().get_param_names()) + }.union(super()._get_param_names()) ) def fit(self, X, y, sample_weight=None, convert_dtype=True) -> "LinearSVM": diff --git a/python/cuml/cuml/svm/linear_svr.py b/python/cuml/cuml/svm/linear_svr.py index 93c2c13389..3f9b8040d9 100644 --- a/python/cuml/cuml/svm/linear_svr.py +++ b/python/cuml/cuml/svm/linear_svr.py @@ -153,7 +153,7 @@ def loss(self, loss: str): self.__loss = loss @classmethod - def get_param_names(cls): + def _get_param_names(cls): return list( { "handle", @@ -169,5 +169,5 @@ def get_param_names(cls): "grad_tol", "change_tol", "epsilon", - }.union(super().get_param_names()) + }.union(super()._get_param_names()) ) diff --git a/python/cuml/cuml/svm/svc.pyx b/python/cuml/cuml/svm/svc.pyx index d62d4729b0..290f5bc2a2 100644 --- a/python/cuml/cuml/svm/svc.pyx +++ b/python/cuml/cuml/svm/svc.pyx @@ -703,8 +703,8 @@ class SVC(SVMBase, return super().predict(X, False) @classmethod - def get_param_names(cls): - params = super().get_param_names() + \ + def _get_param_names(cls): + params = super()._get_param_names() + \ ["probability", "random_state", "class_weight", "multiclass_strategy"] diff --git a/python/cuml/cuml/svm/svm_base.pyx b/python/cuml/cuml/svm/svm_base.pyx index bd4f5c5681..9b68147f2b 100644 --- a/python/cuml/cuml/svm/svm_base.pyx +++ b/python/cuml/cuml/svm/svm_base.pyx @@ -662,8 +662,8 @@ class SVMBase(Base, return preds @classmethod - def get_param_names(cls): - return super().get_param_names() + [ + def _get_param_names(cls): + return super()._get_param_names() + [ "C", "kernel", "degree", diff --git a/python/cuml/cuml/tests/explainer/test_explainer_common.py b/python/cuml/cuml/tests/explainer/test_explainer_common.py index 5ee4e5f52b..659f8c365e 100644 --- a/python/cuml/cuml/tests/explainer/test_explainer_common.py +++ b/python/cuml/cuml/tests/explainer/test_explainer_common.py @@ -1,5 +1,5 @@ # -# Copyright (c) 2020-2023, NVIDIA CORPORATION. +# Copyright (c) 2020-2024, NVIDIA CORPORATION. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -141,7 +141,7 @@ def test_get_tag_from_model_func(model): for tag in _default_tags: res = get_tag_from_model_func( - func=mod.get_param_names, tag=tag, default="FFF" + func=mod._get_param_names, tag=tag, default="FFF" ) if tag != "preferred_input_order": @@ -153,7 +153,7 @@ def test_get_handle_from_cuml_model_func(model): mod = create_dummy_model(model) handle = get_handle_from_cuml_model_func( - mod.get_param_names, create_new=True + mod._get_param_names, create_new=True ) assert isinstance(handle, Handle) diff --git a/python/cuml/cuml/tests/test_base.py b/python/cuml/cuml/tests/test_base.py index dc70c8cf22..0cd01acabb 100644 --- a/python/cuml/cuml/tests/test_base.py +++ b/python/cuml/cuml/tests/test_base.py @@ -1,4 +1,4 @@ -# Copyright (c) 2019-2023, NVIDIA CORPORATION. +# Copyright (c) 2019-2024, NVIDIA CORPORATION. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -36,7 +36,7 @@ def test_base_class_usage(): # Ensure base class returns the 3 main properties needed by all classes base = cuml.Base() base.handle.sync() - base_params = base.get_param_names() + base_params = base._get_param_names() assert "handle" in base_params assert "verbose" in base_params @@ -160,10 +160,10 @@ def get_param_doc(param_doc_obj, name: str): @pytest.mark.parametrize("child_class", list(all_base_children.keys())) # ignore ColumnTransformer init warning @pytest.mark.filterwarnings("ignore:Transformers are required") -def test_base_children_get_param_names(child_class: str): +def test_base_children__get_param_names(child_class: str): """ This test ensures that the arguments in `Base.__init__` are available in - all derived classes `get_param_names` + all derived classes `_get_param_names` """ klass = all_base_children[child_class] @@ -183,9 +183,9 @@ def test_base_children_get_param_names(child_class: str): # Create an instance obj = klass(*bound.args, **bound.kwargs) - param_names = obj.get_param_names() + param_names = obj._get_param_names() - # Now ensure the base parameters are included in get_param_names + # Now ensure the base parameters are included in _get_param_names for name, param in sig.parameters.items(): if param.name == "output_mem_type": continue # TODO(wphicks): Add this to all algos diff --git a/python/cuml/cuml/tsa/arima.pyx b/python/cuml/cuml/tsa/arima.pyx index e93f871cb2..3513362013 100644 --- a/python/cuml/cuml/tsa/arima.pyx +++ b/python/cuml/cuml/tsa/arima.pyx @@ -578,13 +578,10 @@ class ARIMA(Base): setattr(self, "{}_".format(param_name), array) @classmethod - def get_param_names(cls): - raise NotImplementedError - - def get_param_names(self): + def _get_param_names(cls): """ .. warning:: ARIMA is unable to be cloned at this time. - The methods: `get_param_names()`, `get_params` and + The methods: `_get_param_names()`, `get_params` and `set_params` will raise ``NotImplementedError`` """ raise NotImplementedError("ARIMA is unable to be cloned via " @@ -593,7 +590,7 @@ class ARIMA(Base): def get_params(self, deep=True): """ .. warning:: ARIMA is unable to be cloned at this time. - The methods: `get_param_names()`, `get_params` and + The methods: `_get_param_names()`, `get_params` and `set_params` will raise ``NotImplementedError`` """ raise NotImplementedError("ARIMA is unable to be cloned via " @@ -602,7 +599,7 @@ class ARIMA(Base): def set_params(self, **params): """ .. warning:: ARIMA is unable to be cloned at this time. - The methods: `get_param_names()`, `get_params` and + The methods: `_get_param_names()`, `get_params` and `set_params` will raise ``NotImplementedError`` """ raise NotImplementedError("ARIMA is unable to be cloned via " diff --git a/python/cuml/cuml/tsa/holtwinters.pyx b/python/cuml/cuml/tsa/holtwinters.pyx index f22f363051..685e92fdea 100644 --- a/python/cuml/cuml/tsa/holtwinters.pyx +++ b/python/cuml/cuml/tsa/holtwinters.pyx @@ -577,8 +577,8 @@ class ExponentialSmoothing(Base): raise ValueError("Fit() the model to get season values") @classmethod - def get_param_names(cls): - return super().get_param_names() + [ + def _get_param_names(cls): + return super()._get_param_names() + [ "endog", "seasonal", "seasonal_periods", diff --git a/wiki/python/ESTIMATOR_GUIDE.md b/wiki/python/ESTIMATOR_GUIDE.md index a387df111d..84528608cd 100644 --- a/wiki/python/ESTIMATOR_GUIDE.md +++ b/wiki/python/ESTIMATOR_GUIDE.md @@ -15,7 +15,7 @@ This guide is meant to help developers follow the correct patterns when creating - [Returning Arrays](#returning-arrays) - [Estimator Design](#estimator-design) - [Initialization](#initialization) - - [Implementing `get_param_names()`](#implementing-get_param_names) + - [Implementing `_get_param_names()`](#implementing-_get_param_names) - [Estimator Tags and cuML Specific Tags](#estimator-tags-and-cuml-specific-tags) - [Estimator Array-Like Attributes](#estimator-array-like-attributes) - [Estimator Methods](#estimator-methods) @@ -77,10 +77,11 @@ At a high level, all cuML Estimators must: def predict(self, X) -> CumlArray: ... ``` -6. Implement `get_param_names()` including values returned by `super().get_param_names()` +6. Implement `_get_param_names()` including values returned by `super()._get_param_names()` ```python - def get_param_names(self): - return super().get_param_names() + [ + @classmethod + def _get_param_names(cls): + return super()._get_param_names() + [ "eps", "min_samples", ] @@ -254,19 +255,20 @@ def __init__(self, my_option="option1"): This will break cloning since the value of `self.my_option` is not a valid input to `__init__`. Instead, `my_option` should be saved as an attribute as-is. -### Implementing `get_param_names()` +### Implementing `_get_param_names()` -To support cloning, estimators need to implement the function `get_param_names()`. The returned value should be a list of strings of all estimator attributes that are necessary to duplicate the estimator. This method is used in `Base.get_params()` which will collect the collect the estimator param values from this list and pass this dictionary to a new estimator constructor. Therefore, all strings returned by `get_param_names()` should be arguments in `__init__()` otherwise an invalid argument exception will be raised. Most estimators implement `get_param_names()` similar to: +To support cloning, estimators need to implement the function `_get_param_names()`. The returned value should be a list of strings of all estimator attributes that are necessary to duplicate the estimator. This method is used in `Base.get_params()` which will collect the collect the estimator param values from this list and pass this dictionary to a new estimator constructor. Therefore, all strings returned by `_get_param_names()` should be arguments in `__init__()` otherwise an invalid argument exception will be raised. Most estimators implement `_get_param_names()` similar to: ```python -def get_param_names(self): - return super().get_param_names() + [ +@classmethod +def _get_param_names(cls): + return super()._get_param_names() + [ "eps", "min_samples", ] ``` -**Note:** Be sure to include `super().get_param_names()` in the returned list to properly set the `super()` attributes. +**Note:** Be sure to include `super()._get_param_names()` in the returned list to properly set the `super()` attributes. ### Estimator Tags and cuML-Specific Tags From 009546cdfd98abb1fab6a9a2ed4cf6e19d713e78 Mon Sep 17 00:00:00 2001 From: Divye Gala Date: Thu, 7 Nov 2024 17:29:43 -0500 Subject: [PATCH 08/22] Update ESTIMATOR_GUIDE.md --- wiki/python/ESTIMATOR_GUIDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/wiki/python/ESTIMATOR_GUIDE.md b/wiki/python/ESTIMATOR_GUIDE.md index 84528608cd..5413bfd6be 100644 --- a/wiki/python/ESTIMATOR_GUIDE.md +++ b/wiki/python/ESTIMATOR_GUIDE.md @@ -80,7 +80,7 @@ At a high level, all cuML Estimators must: 6. Implement `_get_param_names()` including values returned by `super()._get_param_names()` ```python @classmethod - def _get_param_names(cls): + def _get_param_names(cls): return super()._get_param_names() + [ "eps", "min_samples", From 9c3edce45c81e6dba63556b94b829b0efb45fac7 Mon Sep 17 00:00:00 2001 From: Dante Gama Dessavre Date: Mon, 11 Nov 2024 14:46:29 -0600 Subject: [PATCH 09/22] FEA First commit --- .../cuml/cuml/experimental/accel/__init__.py | 51 + .../cuml/cuml/experimental/accel/__main__.py | 80 ++ .../experimental/accel/_wrappers/__init__.py | 17 + .../experimental/accel/_wrappers/hdbscan.py | 24 + .../experimental/accel/_wrappers/sklearn.py | 240 ++++ .../cuml/experimental/accel/_wrappers/umap.py | 24 + .../cuml/experimental/accel/annotation.py | 47 + .../experimental/accel/estimator_proxy.py | 138 ++ .../experimental/accel/fast_slow_proxy.py | 1234 +++++++++++++++++ python/cuml/cuml/experimental/accel/magics.py | 45 + .../experimental/accel/module_accelerator.py | 662 +++++++++ python/cuml/cuml/internals/base.pyx | 71 +- .../estimators_hyperparams/test_dbscan.py | 91 ++ .../test_elastic_net.py | 218 +++ .../test_hdbscan_core.py | 328 +++++ .../test_hdbscan_extended.py | 214 +++ .../estimators_hyperparams/test_kmeans.py | 105 ++ .../test_kneighbors_classifier.py | 194 +++ .../test_kneighbors_regressor.py | 168 +++ .../estimators_hyperparams/test_lasso.py | 202 +++ .../test_linear_regression.py | 59 + .../test_logistic_regression.py | 195 +++ .../test_nearest_neighbors.py | 232 ++++ .../accel/estimators_hyperparams/test_pca.py | 164 +++ .../estimators_hyperparams/test_ridge.py | 163 +++ .../accel/estimators_hyperparams/test_tsne.py | 195 +++ .../accel/estimators_hyperparams/test_tsvd.py | 187 +++ .../accel/estimators_hyperparams/test_umap.py | 173 +++ .../accel/test_basic_estimators.py | 142 ++ .../tests/experimental/accel/test_pipeline.py | 165 +++ 30 files changed, 5826 insertions(+), 2 deletions(-) create mode 100644 python/cuml/cuml/experimental/accel/__init__.py create mode 100644 python/cuml/cuml/experimental/accel/__main__.py create mode 100644 python/cuml/cuml/experimental/accel/_wrappers/__init__.py create mode 100644 python/cuml/cuml/experimental/accel/_wrappers/hdbscan.py create mode 100644 python/cuml/cuml/experimental/accel/_wrappers/sklearn.py create mode 100644 python/cuml/cuml/experimental/accel/_wrappers/umap.py create mode 100644 python/cuml/cuml/experimental/accel/annotation.py create mode 100644 python/cuml/cuml/experimental/accel/estimator_proxy.py create mode 100644 python/cuml/cuml/experimental/accel/fast_slow_proxy.py create mode 100644 python/cuml/cuml/experimental/accel/magics.py create mode 100644 python/cuml/cuml/experimental/accel/module_accelerator.py create mode 100644 python/cuml/cuml/tests/experimental/accel/estimators_hyperparams/test_dbscan.py create mode 100644 python/cuml/cuml/tests/experimental/accel/estimators_hyperparams/test_elastic_net.py create mode 100644 python/cuml/cuml/tests/experimental/accel/estimators_hyperparams/test_hdbscan_core.py create mode 100644 python/cuml/cuml/tests/experimental/accel/estimators_hyperparams/test_hdbscan_extended.py create mode 100644 python/cuml/cuml/tests/experimental/accel/estimators_hyperparams/test_kmeans.py create mode 100644 python/cuml/cuml/tests/experimental/accel/estimators_hyperparams/test_kneighbors_classifier.py create mode 100644 python/cuml/cuml/tests/experimental/accel/estimators_hyperparams/test_kneighbors_regressor.py create mode 100644 python/cuml/cuml/tests/experimental/accel/estimators_hyperparams/test_lasso.py create mode 100644 python/cuml/cuml/tests/experimental/accel/estimators_hyperparams/test_linear_regression.py create mode 100644 python/cuml/cuml/tests/experimental/accel/estimators_hyperparams/test_logistic_regression.py create mode 100644 python/cuml/cuml/tests/experimental/accel/estimators_hyperparams/test_nearest_neighbors.py create mode 100644 python/cuml/cuml/tests/experimental/accel/estimators_hyperparams/test_pca.py create mode 100644 python/cuml/cuml/tests/experimental/accel/estimators_hyperparams/test_ridge.py create mode 100644 python/cuml/cuml/tests/experimental/accel/estimators_hyperparams/test_tsne.py create mode 100644 python/cuml/cuml/tests/experimental/accel/estimators_hyperparams/test_tsvd.py create mode 100644 python/cuml/cuml/tests/experimental/accel/estimators_hyperparams/test_umap.py create mode 100644 python/cuml/cuml/tests/experimental/accel/test_basic_estimators.py create mode 100644 python/cuml/cuml/tests/experimental/accel/test_pipeline.py diff --git a/python/cuml/cuml/experimental/accel/__init__.py b/python/cuml/cuml/experimental/accel/__init__.py new file mode 100644 index 0000000000..7a328d8a56 --- /dev/null +++ b/python/cuml/cuml/experimental/accel/__init__.py @@ -0,0 +1,51 @@ +# +# Copyright (c) 2024, NVIDIA CORPORATION. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + + +from .magics import load_ipython_extension + +# from .profiler import Profiler + +__all__ = ["load_ipython_extension", "install"] + + +LOADED = False + + +def install(): + """Enable cuML Accelerator Mode.""" + from .module_accelerator import ModuleAccelerator + + print("Installing cuML Accelerator...") + loader = ModuleAccelerator.install("sklearn", "cuml", "sklearn") + # loader_umap = ModuleAccelerator.install("umap", "cuml", "umap") + # loader_hdbscan = ModuleAccelerator.install("hdbscan", "cuml", "hdbscan") + global LOADED + LOADED = loader is not None + + +def pytest_load_initial_conftests(early_config, parser, args): + # We need to install ourselves before conftest.py import (which + # might import pandas) This hook is guaranteed to run before that + # happens see + # https://docs.pytest.org/en/7.1.x/reference/\ + # reference.html#pytest.hookspec.pytest_load_initial_conftests + try: + install() + except RuntimeError: + raise RuntimeError( + "An existing plugin has already loaded sklearn. Interposing failed." + ) diff --git a/python/cuml/cuml/experimental/accel/__main__.py b/python/cuml/cuml/experimental/accel/__main__.py new file mode 100644 index 0000000000..bcfaf1c881 --- /dev/null +++ b/python/cuml/cuml/experimental/accel/__main__.py @@ -0,0 +1,80 @@ +# +# Copyright (c) 2024, NVIDIA CORPORATION. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import click +import code +import logging +import runpy +import sys + +from . import install +from cuml.internals import logger + + +@click.command() +@click.option("-m", "module", required=False, help="Module to run") +@click.option( + "--profile", + is_flag=True, + default=False, + help="Perform per-function profiling of this script.", +) +@click.option( + "--line-profile", + is_flag=True, + default=False, + help="Perform per-line profiling of this script.", +) +@click.argument("args", nargs=-1) +def main(module, profile, line_profile, args): + """ """ + if not logging.getLogger().hasHandlers(): + logging.basicConfig(level=logging.DEBUG) + + logger.set_level(logger.level_debug) + logger.set_pattern("%v") + + install() + + if module: + (module,) = module + # run the module passing the remaining arguments + # as if it were run with python -m + sys.argv[:] = [module] + args # not thread safe? + runpy.run_module(module, run_name="__main__") + elif len(args) >= 1: + # Remove ourself from argv and continue + sys.argv[:] = args + runpy.run_path(args[0], run_name="__main__") + else: + if sys.stdin.isatty(): + banner = f"Python {sys.version} on {sys.platform}" + site_import = not sys.flags.no_site + if site_import: + cprt = 'Type "help", "copyright", "credits" or "license" for more information.' + banner += "\n" + cprt + else: + # Don't show prompts or banners if stdin is not a TTY + sys.ps1 = "" + sys.ps2 = "" + banner = "" + + # Launch an interactive interpreter + code.interact(banner=banner, exitmsg="") + + +if __name__ == "__main__": + main() diff --git a/python/cuml/cuml/experimental/accel/_wrappers/__init__.py b/python/cuml/cuml/experimental/accel/_wrappers/__init__.py new file mode 100644 index 0000000000..f1c63d4e82 --- /dev/null +++ b/python/cuml/cuml/experimental/accel/_wrappers/__init__.py @@ -0,0 +1,17 @@ +# +# Copyright (c) 2024, NVIDIA CORPORATION. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +from . import sklearn diff --git a/python/cuml/cuml/experimental/accel/_wrappers/hdbscan.py b/python/cuml/cuml/experimental/accel/_wrappers/hdbscan.py new file mode 100644 index 0000000000..24d182f41c --- /dev/null +++ b/python/cuml/cuml/experimental/accel/_wrappers/hdbscan.py @@ -0,0 +1,24 @@ +# +# Copyright (c) 2024, NVIDIA CORPORATION. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +from ..estimator_proxy import intercept + + +UMAP = intercept( + original_module="hdbscan", + accelerated_module="cuml.cluster", + original_class_name="HDBSCAN", +) diff --git a/python/cuml/cuml/experimental/accel/_wrappers/sklearn.py b/python/cuml/cuml/experimental/accel/_wrappers/sklearn.py new file mode 100644 index 0000000000..06ab812a57 --- /dev/null +++ b/python/cuml/cuml/experimental/accel/_wrappers/sklearn.py @@ -0,0 +1,240 @@ +# +# Copyright (c) 2024, NVIDIA CORPORATION. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +from ..estimator_proxy import intercept + +wrapped_estimators = { + "KMeans": ("cuml.cluster", "KMeans"), + "DBSCAN": ("cuml.cluster", "DBSCAN"), + "PCA": ("cuml.decomposition", "PCA"), + "TruncatedSVD": ("cuml.decomposition", "TruncatedSVD"), + "KernelRidge": ("cuml.kernel_ridge", "KernelRidge"), + "LinearRegression": "cuml.linear_model.LinearRegression", + "LogisticRegression": ("cuml.linear_model", "LogisticRegression"), + "ElasticNet": ("cuml.linear_model", "ElasticNet"), + "Ridge": ("cuml.linear_model", "Ridge"), + "Lasso": ("cuml.linear_model", "Lasso"), + "TSNE": ("cuml.manifold", "TSNE"), + "NearestNeighbors": ("cuml.neighbors", "NearestNeighbors"), + "KNeighborsClassifier": ("cuml.neighbors", "KNeighborsClassifier"), + "KNeighborsRegressor": ("cuml.neighbors", "KNeighborsRegressor"), +} + + +############################################################################### +# Clustering Estimators # +############################################################################### + +# AgglomerativeClustering = intercept(original_module="sklearn.cluster", +# accelerated_module="cuml.cluster", +# original_class_name="AgglomerativeClustering") + +KMeans = intercept( + original_module="sklearn.cluster", + accelerated_module="cuml.cluster", + original_class_name="KMeans", +) + +DBSCAN = intercept( + original_module="sklearn.cluster", + accelerated_module="cuml.cluster", + original_class_name="DBSCAN", +) + +# HDBSCAN = intercept( +# original_module="sklearn.cluster", +# accelerated_module="cuml.cluster", +# original_class_name="HDBSCAN", +# ) + + +############################################################################### +# Decomposition Estimators # +############################################################################### + + +PCA = intercept( + original_module="sklearn.decomposition", + accelerated_module="cuml.decomposition", + original_class_name="PCA", +) + + +# IncrementalPCA = intercept(original_module="sklearn.decomposition", +# accelerated_module="cuml.decomposition", +# original_class_name="IncrementalPCA") + + +TruncatedSVD = intercept( + original_module="sklearn.decomposition", + accelerated_module="cuml.decomposition", + original_class_name="TruncatedSVD", +) + + +############################################################################### +# Ensemble Estimators # +############################################################################### + + +# RandomForestClassifier = intercept(original_module="sklearn.ensemble", +# accelerated_module="cuml.ensemble", +# original_class_name="RandomForestClassifier") + +# RandomForestRegressor = intercept(original_module="sklearn.decomposition", +# accelerated_module="cuml.decomposition", +# original_class_name="RandomForestRegressor") + + +############################################################################### +# Linear Estimators # +############################################################################### + +KernelRidge = intercept( + original_module="sklearn.kernel_ridge", + accelerated_module="cuml.kernel_ridge", + original_class_name="KernelRidge", +) + +LinearRegression = intercept( + original_module="sklearn.linear_model", + accelerated_module="cuml.linear_model", + original_class_name="LinearRegression", +) + +LogisticRegression = intercept( + original_module="sklearn.linear_model", + accelerated_module="cuml.linear_model", + original_class_name="LogisticRegression", +) + +ElasticNet = intercept( + original_module="sklearn.linear_model", + accelerated_module="cuml.linear_model", + original_class_name="ElasticNet", +) + +Ridge = intercept( + original_module="sklearn.linear_model", + accelerated_module="cuml.linear_model", + original_class_name="Ridge", +) + +Lasso = intercept( + original_module="sklearn.linear_model", + accelerated_module="cuml.linear_model", + original_class_name="Lasso", +) + + +############################################################################### +# Manifold Estimators # +############################################################################### + +TSNE = intercept( + original_module="sklearn.manifold", + accelerated_module="cuml.manifold", + original_class_name="TSNE", +) + + +############################################################################### +# Bayes Estimators # +############################################################################### + +# GaussianNB = intercept(original_module="sklearn.naive_bayes", +# accelerated_module="cuml.naive_bayes", +# original_class_name="GaussianNB") + +# MultinomialNB = intercept(original_module="sklearn.naive_bayes", +# accelerated_module="cuml.naive_bayes", +# original_class_name="MultinomialNB") + +# BernoulliNB = intercept(original_module="sklearn.naive_bayes", +# accelerated_module="cuml.naive_bayes", +# original_class_name="BernoulliNB") + +# ComplementNB = intercept(original_module="sklearn.naive_bayes", +# accelerated_module="cuml.naive_bayes", +# original_class_name="ComplementNB") + + +############################################################################### +# Neighbors Estimators # +############################################################################### + + +NearestNeighbors = intercept( + original_module="sklearn.neighbors", + accelerated_module="cuml.neighbors", + original_class_name="NearestNeighbors", +) + +KNeighborsClassifier = intercept( + original_module="sklearn.neighbors", + accelerated_module="cuml.neighbors", + original_class_name="KNeighborsClassifier", +) + +KNeighborsRegressor = intercept( + original_module="sklearn.neighbors", + accelerated_module="cuml.neighbors", + original_class_name="KNeighborsRegressor", +) + +############################################################################### +# Rand Proj Estimators # +############################################################################### + + +# GaussianRandomProjection = intercept(original_module="sklearn.random_projection", +# accelerated_module="cuml.random_projection", +# original_class_name="GaussianRandomProjection") + + +# SparseRandomProjection = intercept(original_module="sklearn.random_projection", +# accelerated_module="cuml.random_projection", +# original_class_name="SparseRandomProjection") + + +############################################################################### +# SVM Estimators # +############################################################################### + + +# LinearSVC = intercept(original_module="sklearn.svm", +# accelerated_module="cuml.svm", +# original_class_name="LinearSVC") + +# LinearSVR = intercept(original_module="sklearn.svm", +# accelerated_module="cuml.svm", +# original_class_name="LinearSVR") + +# SVC = intercept(original_module="sklearn.svm", +# accelerated_module="cuml.svm", +# original_class_name="SVC") + +# SVR = intercept(original_module="sklearn.svm", +# accelerated_module="cuml.svm", +# original_class_name="SVR") + + +############################################################################### +# TSA Estimators # +############################################################################### + + +# not supported yet diff --git a/python/cuml/cuml/experimental/accel/_wrappers/umap.py b/python/cuml/cuml/experimental/accel/_wrappers/umap.py new file mode 100644 index 0000000000..dd8b6864b0 --- /dev/null +++ b/python/cuml/cuml/experimental/accel/_wrappers/umap.py @@ -0,0 +1,24 @@ +# +# Copyright (c) 2024, NVIDIA CORPORATION. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +from ..estimator_proxy import intercept + + +UMAP = intercept( + original_module="umap", + accelerated_module="cuml.manifold", + original_class_name="UMAP", +) diff --git a/python/cuml/cuml/experimental/accel/annotation.py b/python/cuml/cuml/experimental/accel/annotation.py new file mode 100644 index 0000000000..47b0017a3c --- /dev/null +++ b/python/cuml/cuml/experimental/accel/annotation.py @@ -0,0 +1,47 @@ +# +# Copyright (c) 2024, NVIDIA CORPORATION. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +from __future__ import annotations + +try: + import nvtx +except ImportError: + + class nvtx: # type: ignore + """Noop-stub with the same API as nvtx.""" + + push_range = lambda *args, **kwargs: None # noqa: E731 + pop_range = lambda *args, **kwargs: None # noqa: E731 + + class annotate: + """No-op annotation/context-manager""" + + def __init__( + self, + message: str | None = None, + color: str | None = None, + domain: str | None = None, + category: str | int | None = None, + ): + pass + + def __enter__(self): + return self + + def __exit__(self, *exc): + return False + + __call__ = lambda self, fn: fn # noqa: E731 diff --git a/python/cuml/cuml/experimental/accel/estimator_proxy.py b/python/cuml/cuml/experimental/accel/estimator_proxy.py new file mode 100644 index 0000000000..98cd32c8b3 --- /dev/null +++ b/python/cuml/cuml/experimental/accel/estimator_proxy.py @@ -0,0 +1,138 @@ +# +# Copyright (c) 2024, NVIDIA CORPORATION. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + + +import cuml +import importlib +import inspect + +from cuml.internals.global_settings import GlobalSettings +from cuml.internals.mem_type import MemoryType +from cuml.internals import logger +from typing import Optional + + +patched_classes = {} + + +class EstimatorInterceptor: + def __init__( + self, original_module, new_module, class_name_a, class_name_b + ): + self.original_module = original_module + self.new_module = new_module + self.class_name_a = class_name_a + self.class_name_b = class_name_b + + def load_and_replace(self): + # Import the original host module and cuML + module_a = importlib.import_module(self.original_module) + module_b = importlib.import_module(self.new_module) + + # Store a reference to the original (CPU) class + if self.class_name_a in patched_classes: + original_class_a = patched_classes[self.class_name_a] + else: + original_class_a = getattr(module_a, self.class_name_a) + patched_classes[self.class_name_a] = original_class_a + + # Get the class from cuML so ProxyEstimator inherits from it + class_b = getattr(module_b, self.class_name_b) + + # todo: add environment variable to disable this + class ProxyEstimatorMeta(cuml.internals.base_helpers.BaseMetaClass): + def __repr__(cls): + return repr(original_class_a) + + class ProxyEstimator(class_b, metaclass=ProxyEstimatorMeta): + def __init__(self, *args, **kwargs): + self._cpu_model_class = ( + original_class_a # Store a reference to the original class + ) + _, self._gpuaccel = self._hyperparam_translator(**kwargs) + super().__init__(*args, **kwargs) + + self._cpu_hyperparams = list( + inspect.signature( + self._cpu_model_class.__init__ + ).parameters.keys() + ) + + def __repr__(self): + return f"wrapped {self._cpu_model_class}" + + def __str__(self): + return f"ProxyEstimator of {self._cpu_model_class}" + + def __getstate__(self): + if not hasattr(self, "_cpu_model"): + self.import_cpu_model() + self.build_cpu_model() + + self.gpu_to_cpu() + + return self._cpu_model.__dict__.copy() + + def __reduce__(self): + import pickle + from .module_accelerator import disable_module_accelerator + + with disable_module_accelerator(): + with open("filename2.pkl", "wb") as f: + pickle.dump(self._cpu_model_class, f) + return ( + reconstruct_proxy, + (self._cpu_model_class, self.__getstate__()), + ) + # Version to only pickle sklearn + # return (self._cpu_model_class, (), self.__getstate__()) + + def __setstate__(self, state): + print(f"state: {state}") + self._cpu_model_class = ( + original_class_a # Store a reference to the original class + ) + super().__init__() + self.import_cpu_model() + self._cpu_model = self._cpu_model_class() + self._cpu_model.__dict__.update(state) + self.cpu_to_gpu() + self.output_type = "numpy" + self.output_mem_type = MemoryType.host + + +def reconstruct_proxy(orig_class, state): + "Function needed to pickle since ProxyEstimator is" + return ProxyEstimator.__setstate__(state) # noqa: F821 + + +def intercept( + original_module: str, + accelerated_module: str, + original_class_name: str, + accelerated_class_name: Optional[str] = None, +) -> None: + + if accelerated_class_name is None: + accelerated_class_name = original_class_name + + interceptor = EstimatorInterceptor( + original_module, + accelerated_module, + original_class_name, + accelerated_class_name, + ) + interceptor.load_and_replace() diff --git a/python/cuml/cuml/experimental/accel/fast_slow_proxy.py b/python/cuml/cuml/experimental/accel/fast_slow_proxy.py new file mode 100644 index 0000000000..5ae36110f8 --- /dev/null +++ b/python/cuml/cuml/experimental/accel/fast_slow_proxy.py @@ -0,0 +1,1234 @@ +# +# Copyright (c) 2024, NVIDIA CORPORATION. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +from __future__ import annotations + +import functools +import inspect +import operator +import os +import pickle +import types +import warnings +from collections.abc import Iterator +from enum import IntEnum +from typing import Any, Callable, Literal, Mapping + +import numpy as np + +# from ..options import _env_get_bool +# from ..testing import assert_eq +from .annotation import nvtx + + +def _env_get_int(name, default): + try: + return int(os.getenv(name, default)) + except (ValueError, TypeError): + return default + + +def _env_get_bool(name, default): + env = os.getenv(name) + if env is None: + return default + as_a_int = _env_get_int(name, None) + env = env.lower().strip() + if env == "true" or env == "on" or as_a_int: + return True + if env == "false" or env == "off" or as_a_int == 0: + return False + return default + + +def call_operator(fn, args, kwargs): + return fn(*args, **kwargs) + + +_CUML_ACCEL_NVTX_COLORS = { + "COPY_SLOW_TO_FAST": 0xCA0020, + "COPY_FAST_TO_SLOW": 0xF4A582, + "EXECUTE_FAST": 0x92C5DE, + "EXECUTE_SLOW": 0x0571B0, +} + + +_WRAPPER_ASSIGNMENTS = tuple( + attr + for attr in functools.WRAPPER_ASSIGNMENTS + # Skip __doc__ because we assign it on class creation using exec_body + # callable that updates the namespace of the class. + # Skip __annotations__ because there are differences between Python + # versions on how it is initialized for a class that doesn't explicitly + # define it and we don't want to force eager evaluation of anything that + # would normally be lazy (mostly for consistency, shouldn't cause any + # significant issues). + if attr not in ("__annotations__", "__doc__") +) + + +def callers_module_name(): + # Call f_back twice since this function adds an extra frame + return inspect.currentframe().f_back.f_back.f_globals["__name__"] + + +class _State(IntEnum): + """Simple enum to track the type of wrapped object of a final proxy""" + + SLOW = 0 + FAST = 1 + + +class _Unusable: + """ + A totally unusable type. When a "fast" object is not available, + it's useful to set it to _Unusable() so that any operations + on it fail, and ensure fallback to the corresponding + "slow" object. + """ + + def __call__(self, *args: Any, **kwds: Any) -> Any: + raise NotImplementedError( + "Fast implementation not available. " + "Falling back to the slow implementation" + ) + + def __getattribute__(self, name: str) -> Any: + if name in {"__class__"}: # needed for type introspection + return super().__getattribute__(name) + raise TypeError("Unusable type. Falling back to the slow object") + + def __repr__(self) -> str: + raise AttributeError("Unusable type. Falling back to the slow object") + + +class _PickleConstructor: + """A pickleable object to support construction in __reduce__. + + This object is used to avoid having unpickling call __init__ on the + objects, instead only invoking __new__. __init__ may have required + arguments or otherwise perform invalid initialization that we could skip + altogether since we're going to overwrite the wrapped object. + """ + + def __init__(self, type_): + self._type = type_ + + def __call__(self): + return object.__new__(self._type) + + +_DELETE = object() + + +def make_final_proxy_type( + name: str, + fast_type: type, + slow_type: type, + *, + fast_to_slow: Callable, + slow_to_fast: Callable, + module: str | None = None, + additional_attributes: Mapping[str, Any] | None = None, + postprocess: Callable[[_FinalProxy, Any, Any], Any] | None = None, + bases: tuple = (), + metaclasses: tuple = (), +) -> type[_FinalProxy]: + """ + Defines a fast-slow proxy type for a pair of "final" fast and slow + types. Final types are types for which known operations exist for + converting an object of "fast" type to "slow" and vice-versa. + + Parameters + ---------- + name: str + The name of the class returned + fast_type: type + slow_type: type + fast_to_slow: callable + Function that accepts a single argument of type `fast_type` + and returns an object of type `slow_type` + slow_to_fast: callable + Function that accepts a single argument of type `slow_type` + and returns an object of type `fast_type` + additional_attributes + Mapping of additional attributes to add to the class + (optional), these will override any defaulted attributes (e.g. + ``__init__`). If you want to remove a defaulted attribute + completely, pass the special sentinel ``_DELETE`` as a value. + postprocess + Optional function called to allow the proxy to postprocess + itself when being wrapped up, called with the proxy object, + the unwrapped result object, and the function that was used to + construct said unwrapped object. See also `_maybe_wrap_result`. + bases + Optional tuple of base classes to insert into the mro. + metaclasses + Optional tuple of metaclasses to unify with the base proxy metaclass. + + Notes + ----- + As a side-effect, this function adds `fast_type` and `slow_type` + to a global mapping of final types to their corresponding proxy + types, accessible via `get_final_type_map()`. + """ + + def __init__(self, *args, **kwargs): + _fast_slow_function_call( + lambda cls, args, kwargs: setattr( + self, "_fsproxy_wrapped", cls(*args, **kwargs) + ), + type(self), + args, + kwargs, + ) + + @nvtx.annotate( + "COPY_SLOW_TO_FAST", + color=_CUML_ACCEL_NVTX_COLORS["COPY_SLOW_TO_FAST"], + domain="cudf_pandas", + ) + def _fsproxy_slow_to_fast(self): + # if we are wrapping a slow object, + # convert it to a fast one + if self._fsproxy_state is _State.SLOW: + return slow_to_fast(self._fsproxy_wrapped) + return self._fsproxy_wrapped + + @nvtx.annotate( + "COPY_FAST_TO_SLOW", + color=_CUML_ACCEL_NVTX_COLORS["COPY_FAST_TO_SLOW"], + domain="cudf_pandas", + ) + def _fsproxy_fast_to_slow(self): + # if we are wrapping a fast object, + # convert it to a slow one + if self._fsproxy_state is _State.FAST: + return fast_to_slow(self._fsproxy_wrapped) + return self._fsproxy_wrapped + + @property # type: ignore + def _fsproxy_state(self) -> _State: + return ( + _State.FAST + if isinstance(self._fsproxy_wrapped, self._fsproxy_fast_type) + else _State.SLOW + ) + + slow_dir = dir(slow_type) + cls_dict = { + "__init__": __init__, + "__doc__": inspect.getdoc(slow_type), + "_fsproxy_slow_dir": slow_dir, + "_fsproxy_fast_type": fast_type, + "_fsproxy_slow_type": slow_type, + "_fsproxy_slow_to_fast": _fsproxy_slow_to_fast, + "_fsproxy_fast_to_slow": _fsproxy_fast_to_slow, + "_fsproxy_state": _fsproxy_state, + } + + if additional_attributes is None: + additional_attributes = {} + for method in _SPECIAL_METHODS: + if getattr(slow_type, method, False): + cls_dict[method] = _FastSlowAttribute(method) + for k, v in additional_attributes.items(): + if v is _DELETE and k in cls_dict: + del cls_dict[k] + elif v is not _DELETE: + cls_dict[k] = v + + for slow_name in dir(slow_type): + if slow_name in cls_dict or slow_name.startswith("__"): + continue + else: + cls_dict[slow_name] = _FastSlowAttribute( + slow_name, private=slow_name.startswith("_") + ) + + metaclass = _FastSlowProxyMeta + if metaclasses: + metaclass = types.new_class( # type: ignore + f"{name}_Meta", + metaclasses + (_FastSlowProxyMeta,), + {}, + ) + cls = types.new_class( + name, + (*bases, _FinalProxy), + {"metaclass": metaclass}, + lambda ns: ns.update(cls_dict), + ) + functools.update_wrapper( + cls, + slow_type, + assigned=_WRAPPER_ASSIGNMENTS, + updated=(), + ) + cls.__module__ = module if module is not None else callers_module_name() + + final_type_map = get_final_type_map() + if fast_type is not _Unusable: + final_type_map[fast_type] = cls + final_type_map[slow_type] = cls + + return cls + + +def make_intermediate_proxy_type( + name: str, + fast_type: type, + slow_type: type, + *, + module: str | None = None, +) -> type[_IntermediateProxy]: + """ + Defines a proxy type for a pair of "intermediate" fast and slow + types. Intermediate types are the types of the results of + operations invoked on final types. + + As a side-effect, this function adds `fast_type` and `slow_type` + to a global mapping of intermediate types to their corresponding + proxy types, accessible via `get_intermediate_type_map()`. + + Parameters + ---------- + name: str + The name of the class returned + fast_type: type + slow_type: type + """ + + def __init__(self, *args, **kwargs): + # disallow __init__. An intermediate proxy type can only be + # instantiated from (possibly chained) operations on a final + # proxy type. + raise TypeError( + f"Cannot directly instantiate object of type {type(self)}" + ) + + @property # type: ignore + def _fsproxy_state(self): + return ( + _State.FAST + if isinstance(self._fsproxy_wrapped, self._fsproxy_fast_type) + else _State.SLOW + ) + + @nvtx.annotate( + "COPY_SLOW_TO_FAST", + color=_CUML_ACCEL_NVTX_COLORS["COPY_SLOW_TO_FAST"], + domain="cudf_pandas", + ) + def _fsproxy_slow_to_fast(self): + if self._fsproxy_state is _State.SLOW: + return super(type(self), self)._fsproxy_slow_to_fast() + return self._fsproxy_wrapped + + @nvtx.annotate( + "COPY_FAST_TO_SLOW", + color=_CUML_ACCEL_NVTX_COLORS["COPY_FAST_TO_SLOW"], + domain="cudf_pandas", + ) + def _fsproxy_fast_to_slow(self): + if self._fsproxy_state is _State.FAST: + return super(type(self), self)._fsproxy_fast_to_slow() + return self._fsproxy_wrapped + + slow_dir = dir(slow_type) + cls_dict = { + "__init__": __init__, + "__doc__": inspect.getdoc(slow_type), + "_fsproxy_slow_dir": slow_dir, + "_fsproxy_fast_type": fast_type, + "_fsproxy_slow_type": slow_type, + "_fsproxy_slow_to_fast": _fsproxy_slow_to_fast, + "_fsproxy_fast_to_slow": _fsproxy_fast_to_slow, + "_fsproxy_state": _fsproxy_state, + } + for method in _SPECIAL_METHODS: + if getattr(slow_type, method, False): + cls_dict[method] = _FastSlowAttribute(method) + + for slow_name in dir(slow_type): + if slow_name in cls_dict or slow_name.startswith("__"): + continue + else: + cls_dict[slow_name] = _FastSlowAttribute( + slow_name, private=slow_name.startswith("_") + ) + + for slow_name in getattr(slow_type, "_attributes", []): + if slow_name in cls_dict: + continue + else: + cls_dict[slow_name] = _FastSlowAttribute( + slow_name, private=slow_name.startswith("_") + ) + + cls = types.new_class( + name, + (_IntermediateProxy,), + {"metaclass": _FastSlowProxyMeta}, + lambda ns: ns.update(cls_dict), + ) + functools.update_wrapper( + cls, + slow_type, + assigned=_WRAPPER_ASSIGNMENTS, + updated=(), + ) + cls.__module__ = module if module is not None else callers_module_name() + + intermediate_type_map = get_intermediate_type_map() + if fast_type is not _Unusable: + intermediate_type_map[fast_type] = cls + intermediate_type_map[slow_type] = cls + + return cls + + +def register_proxy_func(slow_func: Callable): + """ + Decorator to register custom function as a proxy for slow_func. + + Parameters + ---------- + slow_func: Callable + The function to register a wrapper for. + + Returns + ------- + Callable + """ + + def wrapper(func): + registered_functions = get_registered_functions() + registered_functions[slow_func] = func + functools.update_wrapper(func, slow_func) + return func + + return wrapper + + +@functools.lru_cache(maxsize=None) +def get_final_type_map(): + """ + Return the mapping of all known fast and slow final types to their + corresponding proxy types. + """ + return dict() + + +@functools.lru_cache(maxsize=None) +def get_intermediate_type_map(): + """ + Return a mapping of all known fast and slow intermediate types to their + corresponding proxy types. + """ + return dict() + + +@functools.lru_cache(maxsize=None) +def get_registered_functions(): + return dict() + + +def _raise_attribute_error(obj, name): + """ + Raise an AttributeError with a message that is consistent with + the error raised by Python for a non-existent attribute on a + proxy object. + """ + raise AttributeError(f"'{obj}' object has no attribute '{name}'") + + +class _FastSlowProxyMeta(type): + """ + Metaclass used to dynamically find class attributes and + classmethods of fast-slow proxy types. + """ + + _fsproxy_slow_dir: list + _fsproxy_slow_type: type + _fsproxy_fast_type: type + + @property + def _fsproxy_slow(self) -> type: + return self._fsproxy_slow_type + + @property + def _fsproxy_fast(self) -> type: + return self._fsproxy_fast_type + + def __dir__(self): + # Try to return the cached dir of the slow object, but if it + # doesn't exist, fall back to the default implementation. + try: + return self._fsproxy_slow_dir + except AttributeError: + return type.__dir__(self) + + def __subclasscheck__(self, __subclass: type) -> bool: + if super().__subclasscheck__(__subclass): + return True + if hasattr(__subclass, "_fsproxy_slow"): + return issubclass(__subclass._fsproxy_slow, self._fsproxy_slow) + return False + + def __instancecheck__(self, __instance: Any) -> bool: + if super().__instancecheck__(__instance): + return True + elif hasattr(type(__instance), "_fsproxy_slow"): + return issubclass(type(__instance), self) + return False + + +class _FastSlowProxy: + """ + Base class for all fast=slow proxy types. + + A fast-slow proxy is proxy for a pair of types that provide "fast" + and "slow" implementations of the same API. At any time, a + fast-slow proxy wraps an object of either "fast" type, or "slow" + type. Operations invoked on the fast-slow proxy are first + delegated to the "fast" type, and if that fails, to the "slow" + type. + """ + + _fsproxy_wrapped: Any + + def _fsproxy_fast_to_slow(self) -> Any: + """ + If the wrapped object is of "fast" type, returns the + corresponding "slow" object. Otherwise, returns the wrapped + object as-is. + """ + raise NotImplementedError("Abstract base class") + + def _fsproxy_slow_to_fast(self) -> Any: + """ + If the wrapped object is of "slow" type, returns the + corresponding "fast" object. Otherwise, returns the wrapped + object as-is. + """ + raise NotImplementedError("Abstract base class") + + @property + def _fsproxy_fast(self) -> Any: + """ + Returns the wrapped object. If the wrapped object is of "slow" + type, replaces it with the corresponding "fast" object before + returning it. + """ + self._fsproxy_wrapped = self._fsproxy_slow_to_fast() + return self._fsproxy_wrapped + + @property + def _fsproxy_slow(self) -> Any: + """ + Returns the wrapped object. If the wrapped object is of "fast" + type, replaces it with the corresponding "slow" object before + returning it. + """ + self._fsproxy_wrapped = self._fsproxy_fast_to_slow() + return self._fsproxy_wrapped + + def __dir__(self): + # Try to return the cached dir of the slow object, but if it + # doesn't exist, fall back to the default implementation. + try: + return self._fsproxy_slow_dir + except AttributeError: + return object.__dir__(self) + + def __setattr__(self, name, value): + if name.startswith("_"): + object.__setattr__(self, name, value) + return + return _FastSlowAttribute("__setattr__").__get__(self, type(self))( + name, value + ) + + +class _FinalProxy(_FastSlowProxy): + """ + Proxy type for a pair of fast and slow "final" types for which + there is a known conversion from fast to slow, and vice-versa. + The conversion between fast and slow types is done using + user-provided conversion functions. + + Do not attempt to use this class directly. Instead, use + `make_final_proxy_type` to create subtypes. + """ + + @classmethod + def _fsproxy_wrap(cls, value, func): + """Default mechanism to wrap a value in a proxy type + + Parameters + ---------- + cls + The proxy type + value + The value to wrap up + func + The function called that constructed value + + Returns + ------- + A new proxied object + + Notes + ----- + _FinalProxy subclasses can override this classmethod if they + need particular behaviour when wrapped up. + """ + proxy = object.__new__(cls) + proxy._fsproxy_wrapped = value + return proxy + + def __reduce__(self): + """ + In conjunction with `__proxy_setstate__`, this effectively enables + proxy types to be pickled and unpickled by pickling and unpickling + the underlying wrapped types. + """ + # Need a local import to avoid circular import issues + from .module_accelerator import disable_module_accelerator + + with disable_module_accelerator(): + pickled_wrapped_obj = pickle.dumps(self._fsproxy_wrapped) + return (_PickleConstructor(type(self)), (), pickled_wrapped_obj) + + def __setstate__(self, state): + # Need a local import to avoid circular import issues + from .module_accelerator import disable_module_accelerator + + with disable_module_accelerator(): + unpickled_wrapped_obj = pickle.loads(state) + self._fsproxy_wrapped = unpickled_wrapped_obj + + +class _IntermediateProxy(_FastSlowProxy): + """ + Proxy type for a pair of "intermediate" types that appear as + intermediate values when invoking operations on "final" types. + The conversion between fast and slow types is done by keeping + track of the sequence of operations that created the wrapped + object, and "playing back" that sequence starting from the "slow" + version of the originating _FinalProxy. + + Do not attempt to use this class directly. Instead, use + `make_intermediate_proxy_type` to create subtypes. + """ + + _method_chain: tuple[Callable, tuple, dict] + + @classmethod + def _fsproxy_wrap( + cls, + obj: Any, + method_chain: tuple[Callable, tuple, dict], + ): + """ + Parameters + ---------- + obj: The object to wrap + method_chain: A tuple of the form (func, args, kwargs) where + `func` is the function that was called to create `obj`, + and `args` and `kwargs` are the arguments that were passed + to `func`. + """ + proxy = object.__new__(cls) + proxy._fsproxy_wrapped = obj + proxy._method_chain = method_chain + return proxy + + @nvtx.annotate( + "COPY_SLOW_TO_FAST", + color=_CUML_ACCEL_NVTX_COLORS["COPY_SLOW_TO_FAST"], + domain="cudf_pandas", + ) + def _fsproxy_slow_to_fast(self) -> Any: + func, args, kwargs = self._method_chain + args, kwargs = _fast_arg(args), _fast_arg(kwargs) + return func(*args, **kwargs) + + @nvtx.annotate( + "COPY_FAST_TO_SLOW", + color=_CUML_ACCEL_NVTX_COLORS["COPY_FAST_TO_SLOW"], + domain="cudf_pandas", + ) + def _fsproxy_fast_to_slow(self) -> Any: + func, args, kwargs = self._method_chain + args, kwargs = _slow_arg(args), _slow_arg(kwargs) + return func(*args, **kwargs) + + def __reduce__(self): + """ + In conjunction with `__proxy_setstate__`, this effectively enables + proxy types to be pickled and unpickled by pickling and unpickling + the underlying wrapped types. + """ + # Need a local import to avoid circular import issues + from .module_accelerator import disable_module_accelerator + + with disable_module_accelerator(): + pickled_wrapped_obj = pickle.dumps(self._fsproxy_wrapped) + pickled_method_chain = pickle.dumps(self._method_chain) + return ( + _PickleConstructor(type(self)), + (), + (pickled_wrapped_obj, pickled_method_chain), + ) + + def __setstate__(self, state): + # Need a local import to avoid circular import issues + from .module_accelerator import disable_module_accelerator + + with disable_module_accelerator(): + unpickled_wrapped_obj = pickle.loads(state[0]) + unpickled_method_chain = pickle.loads(state[1]) + self._fsproxy_wrapped = unpickled_wrapped_obj + self._method_chain = unpickled_method_chain + + +class _CallableProxyMixin: + """ + Mixin class that implements __call__ for fast-slow proxies. + """ + + # For wrapped callables isinstance(self, FunctionType) should return True + __class__ = types.FunctionType # type: ignore + + def __call__(self, *args, **kwargs) -> Any: + result, _ = _fast_slow_function_call( + # We cannot directly call self here because we need it to be + # converted into either the fast or slow object (by + # _fast_slow_function_call) to avoid infinite recursion. + # TODO: When Python 3.11 is the minimum supported Python version + # this can use operator.call + call_operator, + self, + args, + kwargs, + ) + return result + + +class _FunctionProxy(_CallableProxyMixin): + """ + Proxy for a pair of fast and slow functions. + """ + + __name__: str + + def __init__( + self, + fast: Callable | _Unusable, + slow: Callable, + *, + assigned=None, + updated=None, + ): + self._fsproxy_fast = fast + self._fsproxy_slow = slow + if assigned is None: + assigned = functools.WRAPPER_ASSIGNMENTS + if updated is None: + updated = functools.WRAPPER_UPDATES + functools.update_wrapper( + self, + slow, + assigned=assigned, + updated=updated, + ) + + def __reduce__(self): + """ + In conjunction with `__proxy_setstate__`, this effectively enables + proxy types to be pickled and unpickled by pickling and unpickling + the underlying wrapped types. + """ + # Need a local import to avoid circular import issues + from .module_accelerator import disable_module_accelerator + + with disable_module_accelerator(): + pickled_fast = pickle.dumps(self._fsproxy_fast) + pickled_slow = pickle.dumps(self._fsproxy_slow) + return ( + _PickleConstructor(type(self)), + (), + (pickled_fast, pickled_slow), + ) + + def __setstate__(self, state): + # Need a local import to avoid circular import issues + from .module_accelerator import disable_module_accelerator + + with disable_module_accelerator(): + unpickled_fast = pickle.loads(state[0]) + unpickled_slow = pickle.loads(state[1]) + self._fsproxy_fast = unpickled_fast + self._fsproxy_slow = unpickled_slow + + +def is_bound_method(obj): + return inspect.ismethod(obj) and not inspect.isfunction(obj) + + +def is_function(obj): + return inspect.isfunction(obj) or isinstance(obj, types.FunctionType) + + +class _FastSlowAttribute: + """ + A descriptor type used to define attributes of fast-slow proxies. + """ + + _attr: Any + + def __init__(self, name: str, *, private: bool = False): + self._name = name + self._private = private + self._attr = None + self._doc = None + self._dir = None + + def __get__(self, instance, owner) -> Any: + from .module_accelerator import disable_module_accelerator + + if self._attr is None: + if self._private: + fast_attr = _Unusable() + else: + fast_attr = getattr( + owner._fsproxy_fast, self._name, _Unusable() + ) + + try: + slow_attr = getattr(owner._fsproxy_slow, self._name) + except AttributeError as e: + if instance is not None: + return _maybe_wrap_result( + getattr(instance._fsproxy_slow, self._name), + None, # type: ignore + ) + else: + raise e + + if _is_function_or_method(slow_attr): + self._attr = _MethodProxy(fast_attr, slow_attr) + else: + # for anything else, use a fast-slow attribute: + self._attr, _ = _fast_slow_function_call( + getattr, + owner, + self._name, + ) + + if isinstance( + self._attr, (property, functools.cached_property) + ): + with disable_module_accelerator(): + self._attr.__doc__ = inspect.getdoc(slow_attr) + + if instance is not None: + if isinstance(self._attr, _MethodProxy): + if is_bound_method(self._attr._fsproxy_slow): + return self._attr + else: + return types.MethodType(self._attr, instance) + else: + if self._private: + return _maybe_wrap_result( + getattr(instance._fsproxy_slow, self._name), + None, # type: ignore + ) + return _fast_slow_function_call( + getattr, + instance, + self._name, + )[0] + return self._attr + + +class _MethodProxy(_FunctionProxy): + def __init__(self, fast, slow): + super().__init__( + fast, + slow, + updated=functools.WRAPPER_UPDATES, + assigned=( + tuple(filter(lambda x: x != "__name__", _WRAPPER_ASSIGNMENTS)) + ), + ) + + def __dir__(self): + return self._fsproxy_slow.__dir__() + + @property + def __doc__(self): + return self._fsproxy_slow.__doc__ + + @property + def __name__(self): + return self._fsproxy_slow.__name__ + + @__name__.setter + def __name__(self, value): + try: + setattr(self._fsproxy_fast, "__name__", value) + except AttributeError: + pass + setattr(self._fsproxy_slow, "__name__", value) + + +# def _assert_fast_slow_eq(left, right): +# if _is_final_type(type(left)) or type(left) in NUMPY_TYPES: +# assert_eq(left, right) + + +def _fast_slow_function_call( + func: Callable, + /, + *args, + **kwargs, +) -> Any: + """ + Call `func` with all `args` and `kwargs` converted to their + respective fast type. If that fails, call `func` with all + `args` and `kwargs` converted to their slow type. + + Wrap the result in a fast-slow proxy if it is a type we know how + to wrap. + """ + from .module_accelerator import disable_module_accelerator + + fast = False + try: + with nvtx.annotate( + "EXECUTE_FAST", + color=_CUML_ACCEL_NVTX_COLORS["EXECUTE_FAST"], + domain="cudf_pandas", + ): + fast_args, fast_kwargs = _fast_arg(args), _fast_arg(kwargs) + result = func(*fast_args, **fast_kwargs) + if result is NotImplemented: + # try slow path + raise Exception() + fast = True + if _env_get_bool("CUDF_PANDAS_DEBUGGING", False): + try: + with nvtx.annotate( + "EXECUTE_SLOW_DEBUG", + color=_CUML_ACCEL_NVTX_COLORS["EXECUTE_SLOW"], + domain="cudf_pandas", + ): + slow_args, slow_kwargs = ( + _slow_arg(args), + _slow_arg(kwargs), + ) + with disable_module_accelerator(): + slow_result = func( # noqa:F841 + *slow_args, **slow_kwargs + ) # noqa + except Exception as e: + warnings.warn( + "The result from pandas could not be computed. " + f"The exception was {e}." + ) + # else: + # try: + # _assert_fast_slow_eq(result, slow_result) + # except AssertionError as e: + # warnings.warn( + # "The results from cudf and pandas were different. " + # f"The exception was {e}." + # ) + # except Exception as e: + # warnings.warn( + # "Pandas debugging mode failed. " + # f"The exception was {e}." + # ) + except Exception as err: + with nvtx.annotate( + "EXECUTE_SLOW", + color=_CUML_ACCEL_NVTX_COLORS["EXECUTE_SLOW"], + domain="cudf_pandas", + ): + slow_args, slow_kwargs = _slow_arg(args), _slow_arg(kwargs) + if _env_get_bool("LOG_FAST_FALLBACK", False): + from ._logger import log_fallback + + log_fallback(slow_args, slow_kwargs, err) + with disable_module_accelerator(): + result = func(*slow_args, **slow_kwargs) + return _maybe_wrap_result(result, func, *args, **kwargs), fast + + +def _transform_arg( + arg: Any, + attribute_name: Literal["_fsproxy_slow", "_fsproxy_fast"], + seen: set[int], +) -> Any: + """ + Transform "arg" into its corresponding slow (or fast) type. + """ + import numpy as np + + if isinstance(arg, (_FastSlowProxy, _FastSlowProxyMeta, _FunctionProxy)): + typ = getattr(arg, attribute_name) + if typ is _Unusable: + raise Exception("Cannot transform _Unusable") + return typ + elif isinstance(arg, types.ModuleType) and attribute_name in arg.__dict__: + return arg.__dict__[attribute_name] + elif isinstance(arg, list): + return type(arg)(_transform_arg(a, attribute_name, seen) for a in arg) + elif isinstance(arg, tuple): + # This attempts to handle arbitrary subclasses of tuple by + # assuming that if you've subclassed tuple with some special + # behaviour you'll also make the object pickleable by + # implementing the custom pickle protocol interface (either + # __getnewargs_ex__ or __getnewargs__). Perhaps this should + # use __reduce_ex__ instead... + if type(arg) is tuple: + # Must come first to avoid infinite recursion + return tuple(_transform_arg(a, attribute_name, seen) for a in arg) + elif hasattr(arg, "__getnewargs_ex__"): + # Partial implementation of to reconstruct with + # transformed pieces + # This handles scipy._lib._bunch._make_tuple_bunch + args, kwargs = ( + _transform_arg(a, attribute_name, seen) + for a in arg.__getnewargs_ex__() + ) + obj = type(arg).__new__(type(arg), *args, **kwargs) + if hasattr(obj, "__setstate__"): + raise NotImplementedError( + "Transforming tuple-like with __getnewargs_ex__ and " + "__setstate__ not implemented" + ) + if not hasattr(obj, "__dict__") and kwargs: + raise NotImplementedError( + "Transforming tuple-like with kwargs from " + "__getnewargs_ex__ and no __dict__ not implemented" + ) + obj.__dict__.update(kwargs) + return obj + elif hasattr(arg, "__getnewargs__"): + # This handles namedtuple, and would catch tuple if we + # didn't handle it above. + args = _transform_arg(arg.__getnewargs__(), attribute_name, seen) + return type(arg).__new__(type(arg), *args) + else: + # Hope we can just call the constructor with transformed entries. + return type(arg)( + _transform_arg(a, attribute_name, seen) for a in args + ) + elif isinstance(arg, dict): + return { + _transform_arg(k, attribute_name, seen): _transform_arg( + a, attribute_name, seen + ) + for k, a in arg.items() + } + elif isinstance(arg, np.ndarray) and arg.dtype == "O": + transformed = [ + _transform_arg(a, attribute_name, seen) for a in arg.flat + ] + # Keep the same memory layout as arg (the default is C_CONTIGUOUS) + if arg.flags["F_CONTIGUOUS"] and not arg.flags["C_CONTIGUOUS"]: + order = "F" + else: + order = "C" + result = np.empty(int(np.prod(arg.shape)), dtype=object, order=order) + result[...] = transformed + return result.reshape(arg.shape) + elif isinstance(arg, Iterator) and attribute_name == "_fsproxy_fast": + # this may include consumable objects like generators or + # IOBase objects, which we don't want unavailable to the slow + # path in case of fallback. So, we raise here and ensure the + # slow path is taken: + raise Exception() + elif isinstance(arg, types.FunctionType): + if id(arg) in seen: + # `arg` is mutually recursive with another function. We + # can't handle these cases yet: + return arg + seen.add(id(arg)) + return _replace_closurevars(arg, attribute_name, seen) + else: + return arg + + +def _fast_arg(arg: Any) -> Any: + """ + Transform "arg" into its corresponding fast type. + """ + seen: set[int] = set() + return _transform_arg(arg, "_fsproxy_fast", seen) + + +def _slow_arg(arg: Any) -> Any: + """ + Transform "arg" into its corresponding slow type. + """ + seen: set[int] = set() + return _transform_arg(arg, "_fsproxy_slow", seen) + + +def _maybe_wrap_result(result: Any, func: Callable, /, *args, **kwargs) -> Any: + """ + Wraps "result" in a fast-slow proxy if is a "proxiable" object. + """ + if _is_final_type(result): + typ = get_final_type_map()[type(result)] + return typ._fsproxy_wrap(result, func) + elif _is_intermediate_type(result): + typ = get_intermediate_type_map()[type(result)] + return typ._fsproxy_wrap(result, method_chain=(func, args, kwargs)) + elif _is_final_class(result): + return get_final_type_map()[result] + elif isinstance(result, list): + return type(result)( + [ + _maybe_wrap_result(r, operator.getitem, result, i) + for i, r in enumerate(result) + ] + ) + elif isinstance(result, tuple): + wrapped = ( + _maybe_wrap_result(r, operator.getitem, result, i) + for i, r in enumerate(result) + ) + if hasattr(result, "_make"): + # namedtuple + return type(result)._make(wrapped) + else: + return type(result)(wrapped) + elif isinstance(result, Iterator): + return (_maybe_wrap_result(r, lambda x: x, r) for r in result) + else: + return result + + +def _is_final_type(result: Any) -> bool: + return type(result) in get_final_type_map() + + +def _is_final_class(result: Any) -> bool: + if not isinstance(result, type): + return False + return result in get_final_type_map() + + +def _is_intermediate_type(result: Any) -> bool: + return type(result) in get_intermediate_type_map() + + +def _is_function_or_method(obj: Any) -> bool: + res = isinstance( + obj, + ( + types.FunctionType, + types.BuiltinFunctionType, + types.MethodType, + types.WrapperDescriptorType, + types.MethodWrapperType, + types.MethodDescriptorType, + types.BuiltinMethodType, + ), + ) + if not res: + try: + return "cython_function_or_method" in str(type(obj)) + except Exception: + return False + return res + + +def _replace_closurevars( + f: types.FunctionType, + attribute_name: Literal["_fsproxy_slow", "_fsproxy_fast"], + seen: set[int], +) -> Callable[..., Any]: + """ + Return a copy of `f` with its closure variables replaced with + their corresponding slow (or fast) types. + """ + if f.__closure__: + # GH #254: If empty cells are present - which can happen in + # situations like when `f` is a method that invokes the + # "empty" `super()` - the call to `getclosurevars` below will + # fail. For now, we just return `f` in this case. If needed, + # we can consider populating empty cells with a placeholder + # value to allow the call to `getclosurevars` to succeed. + if any(c == types.CellType() for c in f.__closure__): + return f + + f_nonlocals, f_globals, _, _ = inspect.getclosurevars(f) + + g_globals = _transform_arg(f_globals, attribute_name, seen) + g_nonlocals = _transform_arg(f_nonlocals, attribute_name, seen) + + # if none of the globals/nonlocals were transformed, we + # can just return f: + if all(f_globals[k] is g_globals[k] for k in f_globals) and all( + g_nonlocals[k] is f_nonlocals[k] for k in f_nonlocals + ): + return f + + g_closure = tuple(types.CellType(val) for val in g_nonlocals.values()) + + # https://github.com/rapidsai/cudf/issues/15548 + new_g_globals = f.__globals__.copy() + new_g_globals.update(g_globals) + + g = types.FunctionType( + f.__code__, + new_g_globals, + name=f.__name__, + argdefs=f.__defaults__, + closure=g_closure, + ) + return functools.update_wrapper( + g, + f, + assigned=functools.WRAPPER_ASSIGNMENTS + ("__kwdefaults__",), + ) + + +def is_proxy_object(obj: Any) -> bool: + """Determine if an object is proxy object + + Parameters + ---------- + obj : object + Any python object. + + """ + if _FastSlowProxyMeta in type(type(obj)).__mro__: + return True + return False + + +NUMPY_TYPES: set[str] = set(np.sctypeDict.values()) + + +_SPECIAL_METHODS: set[str] = {} diff --git a/python/cuml/cuml/experimental/accel/magics.py b/python/cuml/cuml/experimental/accel/magics.py new file mode 100644 index 0000000000..77c4851e59 --- /dev/null +++ b/python/cuml/cuml/experimental/accel/magics.py @@ -0,0 +1,45 @@ +# +# Copyright (c) 2024, NVIDIA CORPORATION. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + + +try: + from IPython.core.magic import Magics, cell_magic, magics_class + + # from .profiler import Profiler, lines_with_profiling + + # @magics_class + # class CumlAccelMagic(Magics): + # @cell_magic("cuml.accelerator.profile") + # def profile(self, _, cell): + # with Profiler() as profiler: + # get_ipython().run_cell(cell) # noqa: F821 + # profiler.print_per_function_stats() + + # @cell_magic("cuml.accelerator.line_profile") + # def line_profile(self, _, cell): + # new_cell = lines_with_profiling(cell.split("\n")) + # get_ipython().run_cell(new_cell) # noqa: F821 + + def load_ipython_extension(ip): + from . import install + + install() + # ip.register_magics(CumlAccelMagic) + +except ImportError: + + def load_ipython_extension(ip): + pass diff --git a/python/cuml/cuml/experimental/accel/module_accelerator.py b/python/cuml/cuml/experimental/accel/module_accelerator.py new file mode 100644 index 0000000000..cce13fac12 --- /dev/null +++ b/python/cuml/cuml/experimental/accel/module_accelerator.py @@ -0,0 +1,662 @@ +# +# Copyright (c) 2024, NVIDIA CORPORATION. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +from __future__ import annotations + +import contextlib +import functools +import importlib +import importlib.abc +import importlib.machinery +import os +import pathlib +import sys +import threading +import warnings +from abc import abstractmethod +from importlib._bootstrap import _ImportLockContext as ImportLock +from types import ModuleType +from typing import Any, ContextManager, NamedTuple + +from typing_extensions import Self + +from .fast_slow_proxy import ( + _FunctionProxy, + _is_function_or_method, + _Unusable, + get_final_type_map, + get_intermediate_type_map, + get_registered_functions, +) +from ._wrappers.sklearn import wrapped_estimators + +from cuml.internals import logger + + +def rename_root_module(module: str, root: str, new_root: str) -> str: + """ + Rename a module to a new root. + + Parameters + ---------- + module + Module to rename + root + Original root + new_root + New root + + Returns + ------- + New module name (if it matches root) otherwise original name. + """ + if module.startswith(root): + return new_root + module[len(root) :] + else: + return module + + +class DeducedMode(NamedTuple): + use_fast_lib: bool + slow_lib: str + fast_lib: str + + +def deduce_cuml_accel_mode(slow_lib: str, fast_lib: str) -> DeducedMode: + """ + Determine if cudf.pandas should use the requested fast library. + + Parameters + ---------- + slow_lib + Name of the slow library + fast_lib + Name of the fast library + + Returns + ------- + Whether the fast library is being used, and the resulting names of + the "slow" and "fast" libraries. + """ + if "CUDF_PANDAS_FALLBACK_MODE" not in os.environ: + try: + importlib.import_module(fast_lib) + return DeducedMode( + use_fast_lib=True, slow_lib=slow_lib, fast_lib=fast_lib + ) + except Exception as e: + warnings.warn( + f"Exception encountered importing {fast_lib}: {e}." + f"Falling back to only using {slow_lib}." + ) + return DeducedMode( + use_fast_lib=False, slow_lib=slow_lib, fast_lib=slow_lib + ) + + +class ModuleAcceleratorBase( + importlib.abc.MetaPathFinder, importlib.abc.Loader +): + _instance: ModuleAcceleratorBase | None = None + mod_name: str + fast_lib: str + slow_lib: str + + # When walking the module tree and wrapping module attributes, + # we often will come across the same object more than once. We + # don't want to create separate wrappers for each + # instance, so we keep a registry of all module attributes + # that we can look up to see if we have already wrapped an + # attribute before + _wrapped_objs: dict[Any, Any] + + def __new__( + cls, + mod_name: str, + fast_lib: str, + slow_lib: str, + ): + """Build a custom module finder that will provide wrapped modules + on demand. + + Parameters + ---------- + mod_name + Import name to deliver modules under. + fast_lib + Name of package that provides "fast" implementation + slow_lib + Name of package that provides "slow" fallback implementation + """ + if ModuleAcceleratorBase._instance is not None: + raise RuntimeError( + "Only one instance of ModuleAcceleratorBase allowed" + ) + self = object.__new__(cls) + self.mod_name = mod_name + self.fast_lib = fast_lib + self.slow_lib = slow_lib + + # When walking the module tree and wrapping module attributes, + # we often will come across the same object more than once. We + # don't want to create separate wrappers for each + # instance, so we keep a registry of all module attributes + # that we can look up to see if we have already wrapped an + # attribute before + self._wrapped_objs = {} + self._wrapped_objs.update(get_final_type_map()) + self._wrapped_objs.update(get_intermediate_type_map()) + self._wrapped_objs.update(get_registered_functions()) + + ModuleAcceleratorBase._instance = self + return self + + def __repr__(self) -> str: + return ( + f"{self.__class__.__name__}" + f"(fast={self.fast_lib}, slow={self.slow_lib})" + ) + + def find_spec( + self, fullname: str, path, target=None + ) -> importlib.machinery.ModuleSpec | None: + """Provide ourselves as a module loader. + + Parameters + ---------- + fullname + Name of module to be imported, if it starts with the name + that we are using to wrap, we will deliver ourselves as a + loader, otherwise defer to the standard Python loaders. + + Returns + ------- + A ModuleSpec with ourself as loader if we're interposing, + otherwise None to pass off to the next loader. + """ + if fullname == self.mod_name or fullname.startswith( + f"{self.mod_name}." + ): + return importlib.machinery.ModuleSpec( + name=fullname, + loader=self, + # Note, this influences the repr of the module, so we may want + # to change it if we ever want to control that. + origin=None, + loader_state=None, + is_package=True, + ) + return None + + def create_module(self, spec) -> ModuleType | None: + return None + + def exec_module(self, mod: ModuleType): + # importlib calls this function with the global import lock held. + self._populate_module(mod) + + @abstractmethod + def disabled(self) -> ContextManager: + pass + + def _postprocess_module( + self, + mod: ModuleType, + slow_mod: ModuleType, + fast_mod: ModuleType | None, + ) -> ModuleType: + """Ensure that the wrapped module satisfies required invariants. + + Parameters + ---------- + mod + Wrapped module to postprocess + slow_mod + Slow version that we are mimicking + fast_mod + Fast module that provides accelerated implementations (may + be None + + Returns + ------- + Checked and validated module + + Notes + ----- + The implementation of fast-slow proxies imposes certain + requirements on the wrapped modules that it delivers. This + function encodes those requirements and raises if the module + does not satisfy them. + + This post-processing routine should be kept up to date with any + requirements encoded by fast_slow_proxy.py + """ + mod.__dict__["_fsproxy_slow"] = slow_mod + if fast_mod is not None: + mod.__dict__["_fsproxy_fast"] = fast_mod + return mod + + @abstractmethod + def _populate_module(self, mod: ModuleType) -> ModuleType: + """Populate given module with appropriate attributes. + + This traverses the attributes of the slow module corresponding + to mod and mirrors those in the provided module in a wrapped + mode that attempts to execute them using the fast module first. + + Parameters + ---------- + mod + Module to populate + + Returns + ------- + ModuleType + Populated module + + Notes + ----- + In addition to the attributes of the slow module, + the returned module must have the following attributes: + + - '_fsproxy_slow': the corresponding slow module + - '_fsproxy_fast': the corresponding fast module + + This is necessary for correct rewriting of UDFs when calling + to the respective fast/slow libraries. + + The necessary invariants are checked and applied in + :meth:`_postprocess_module`. + """ + pass + + def _wrap_attribute( + self, + slow_attr: Any, + fast_attr: Any | _Unusable, + name: str, + ) -> Any: + """ + Return the wrapped version of an attribute. + + Parameters + ---------- + slow_attr : Any + The attribute from the slow module + fast_mod : Any (or None) + The same attribute from the fast module, if it exists + name + Name of attribute + + Returns + ------- + Wrapped attribute + """ + wrapped_attr: Any + # TODO: what else should we make sure not to get from the fast + # library? + if name in {"__all__", "__dir__", "__file__", "__doc__"}: + wrapped_attr = slow_attr + elif self.fast_lib == self.slow_lib: + # no need to create a fast-slow wrapper + wrapped_attr = slow_attr + if any( + [ + slow_attr in get_registered_functions(), + slow_attr in get_final_type_map(), + slow_attr in get_intermediate_type_map(), + ] + ): + # attribute already registered in self._wrapped_objs + return self._wrapped_objs[slow_attr] + if isinstance(slow_attr, ModuleType) and slow_attr.__name__.startswith( + self.slow_lib + ): + # attribute is a submodule of the slow library, + # replace the string "{slow_lib}" in the submodule's + # name with "{self.mod_name}" + # now, attempt to import the wrapped module, which will + # recursively wrap all of its attributes: + return importlib.import_module( + rename_root_module( + slow_attr.__name__, self.slow_lib, self.mod_name + ) + ) + if slow_attr in self._wrapped_objs: + if type(fast_attr) is _Unusable: + # we don't want to replace a wrapped object that + # has a usable fast object with a wrapped object + # with a an unusable fast object. + return self._wrapped_objs[slow_attr] + if name in wrapped_estimators: + mod = importlib.import_module(wrapped_estimators[name][0]) + wrapped_attr = getattr(mod, wrapped_estimators[name][1]) + elif _is_function_or_method(slow_attr): + wrapped_attr = _FunctionProxy(fast_attr, slow_attr) + else: + wrapped_attr = slow_attr + return wrapped_attr + + @classmethod + @abstractmethod + def install( + cls, destination_module: str, fast_lib: str, slow_lib: str + ) -> Self | None: + """ + Install the loader in sys.meta_path. + + Parameters + ---------- + destination_module + Name under which the importer will kick in + fast_lib + Name of fast module + slow_lib + Name of slow module we are trying to mimic + + Returns + ------- + Instance of the class (or None if the loader was not installed) + + Notes + ----- + This function is idempotent. If called with the same arguments + a second time, it does not create a new loader, but instead + returns the existing loader from ``sys.meta_path``. + + """ + pass + + +class ModuleAccelerator(ModuleAcceleratorBase): + """ + A finder and loader that produces "accelerated" modules. + + When someone attempts to import the specified slow library with + this finder enabled, we intercept the import and deliver an + equivalent, accelerated, version of the module. This provides + attributes and modules that check if they are being used from + "within" the slow (or fast) library themselves. If this is the + case, the implementation is forwarded to the actual slow library + implementation, otherwise a proxy implementation is used (which + attempts to call the fast version first). + """ + + _denylist: tuple[str] + _use_fast_lib: bool + _use_fast_lib_lock: threading.RLock + _module_cache_prefix: str = "_slow_lib_" + + # TODO: Add possibility for either an explicit allow-list of + # libraries where the slow_lib should be wrapped, or, more likely + # a block-list that adds to the set of libraries where no proxying occurs. + def __new__( + cls, + fast_lib, + slow_lib, + ): + self = super().__new__( + cls, + slow_lib, + fast_lib, + slow_lib, + ) + # Import the real versions of the modules so that we can + # rewrite the sys.modules cache. + slow_module = importlib.import_module(slow_lib) + fast_module = importlib.import_module(fast_lib) + # Note, this is not thread safe, but install() below grabs the + # lock for the whole initialisation and modification of + # sys.meta_path. + for mod in sys.modules.copy(): + if mod.startswith(self.slow_lib): + sys.modules[self._module_cache_prefix + mod] = sys.modules[mod] + del sys.modules[mod] + self._denylist = (*slow_module.__path__, *fast_module.__path__) + + # Lock to manage temporarily disabling delivering wrapped attributes + self._use_fast_lib_lock = threading.RLock() + self._use_fast_lib = True + return self + + def _populate_module(self, mod: ModuleType): + mod_name = mod.__name__ + + # Here we attempt to import "_fsproxy_slow_lib.x.y.z", but + # "_fsproxy_slow_lib" does not exist anywhere as a real file, so + # how does this work? + # The importer attempts to import ".z" by first importing + # "_fsproxy_slow_lib.x.y", this recurses until we find + # "_fsproxy_slow_lib.x" (say), which does exist because we set that up + # in __init__. Now the importer looks at the __path__ + # attribute of "x" and uses that to find the relative location + # to look for "y". This __path__ points to the real location + # of "slow_lib.x". So, as long as we rewire the _already imported_ + # slow_lib modules in sys.modules to _fsproxy_slow_lib, when we + # get here this will find the right thing. + # The above exposition is for lazily imported submodules (e.g. + # avoiding circular imports by putting an import at function + # level). For everything that is eagerly imported when we do + # "import slow_lib" this import line is trivial because we + # immediately pull the correct result out of sys.modules. + # print(f"mod_name: {mod_name}") + + # print(f"rename_root_module :{rename_root_module( + # mod_name, + # self.slow_lib, + # self._module_cache_prefix + self.slow_lib, + # )}") + slow_mod = importlib.import_module( + rename_root_module( + mod_name, + self.slow_lib, + self._module_cache_prefix + self.slow_lib, + ) + ) + try: + fast_mod = importlib.import_module( + rename_root_module(mod_name, self.slow_lib, self.fast_lib) + ) + except Exception: + fast_mod = None + + # The version that will be used if called within a denylist + # package + real_attributes = {} + # The version that will be used outside denylist packages + for key in slow_mod.__dir__(): + with warnings.catch_warnings(): + warnings.simplefilter("ignore", FutureWarning) + slow_attr = getattr(slow_mod, key) + fast_attr = getattr(fast_mod, key, _Unusable()) + real_attributes[key] = slow_attr + try: + wrapped_attr = self._wrap_attribute(slow_attr, fast_attr, key) + self._wrapped_objs[slow_attr] = wrapped_attr + except TypeError: + # slow_attr is not hashable + pass + + # Our module has (basically) no static attributes and instead + # always delivers them dynamically where the behaviour is + # dependent on the calling module. + setattr( + mod, + "__getattr__", + functools.partial( + self.getattr_real_or_wrapped, + real=real_attributes, + wrapped_objs=self._wrapped_objs, + loader=self, + ), + ) + + # ...but, we want to pretend like we expose the same attributes + # as the equivalent slow module + setattr(mod, "__dir__", slow_mod.__dir__) + + # We set __path__ to the real path so that importers like + # jinja2.PackageLoader("slow_mod") work correctly. + # Note (dgd): this doesn't work for resources.files(data_module) + if getattr(slow_mod, "__path__", False): + assert mod.__spec__ + mod.__path__ = slow_mod.__path__ + mod.__spec__.submodule_search_locations = [*slow_mod.__path__] + return self._postprocess_module(mod, slow_mod, fast_mod) + + @contextlib.contextmanager + def disabled(self): + """Return a context manager for disabling the module accelerator. + + Within the block, any wrapped objects will instead deliver + attributes from their real counterparts (as if the current + nested block were in the denylist). + + Returns + ------- + Context manager for disabling things + """ + try: + self._use_fast_lib_lock.acquire() + # The same thread might enter this context manager + # multiple times, so we need to remember the previous + # value + saved = self._use_fast_lib + self._use_fast_lib = False + yield + finally: + self._use_fast_lib = saved + self._use_fast_lib_lock.release() + + @staticmethod + def getattr_real_or_wrapped( + name: str, + *, + real: dict[str, Any], + wrapped_objs, + loader: ModuleAccelerator, + ) -> Any: + """ + Obtain an attribute from a module from either the real or + wrapped namespace. + + Parameters + ---------- + name + Attribute to return + real + Unwrapped "original" attributes + wrapped + Wrapped attributes + loader + Loader object that manages denylist and other skipping + + Returns + ------- + The requested attribute (either real or wrapped) + """ + with loader._use_fast_lib_lock: + # Have to hold the lock to read this variable since + # another thread might modify it. + # Modification has to happen with the lock held for the + # duration, so if someone else has modified things, then + # we block trying to acquire the lock (hence it is safe to + # release the lock after reading this value) + use_real = not loader._use_fast_lib + if not use_real: + # Only need to check the denylist if we're not turned off. + frame = sys._getframe() + # We cannot possibly be at the top level. + assert frame.f_back + calling_module = pathlib.PurePath(frame.f_back.f_code.co_filename) + use_real = _caller_in_denylist( + calling_module, tuple(loader._denylist) + ) + try: + if use_real: + return real[name] + else: + return wrapped_objs[real[name]] + except KeyError: + raise AttributeError(f"No attribute '{name}'") + except TypeError: + # real[name] is an unhashable type + return real[name] + + @classmethod + def install( + cls, + destination_module: str, + fast_lib: str, + slow_lib: str, + ) -> Self | None: + # This grabs the global _import_ lock to avoid concurrent + # threads modifying sys.modules. + # We also make sure that we finish installing ourselves in + # sys.meta_path before releasing the lock so that there isn't + # a race between our modification of sys.modules and someone + # else importing the slow_lib before we have added ourselves + # to the meta_path + with ImportLock(): + logger.debug("Module Accelerator Install") + logger.debug(f"destination_module: {destination_module}") + logger.debug(f"fast_lib: {fast_lib}") + logger.debug(f"slow_lib: {slow_lib}") + logger.info("Non Estimator Function Dispatching disabled...") + if destination_module != slow_lib: + raise RuntimeError( + f"Destination module '{destination_module}' must match" + f"'{slow_lib}' for this to work." + ) + mode = deduce_cuml_accel_mode(slow_lib, fast_lib) + if mode.use_fast_lib: + importlib.import_module( + f".._wrappers.{mode.slow_lib}", __name__ + ) + try: + (self,) = ( + p + for p in sys.meta_path + if isinstance(p, cls) + and p.slow_lib == mode.slow_lib + and p.fast_lib == mode.fast_lib + ) + except ValueError: + self = cls(mode.fast_lib, mode.slow_lib) + sys.meta_path.insert(0, self) + return self + + +def disable_module_accelerator() -> contextlib.ExitStack: + """ + Temporarily disable any module acceleration. + """ + with contextlib.ExitStack() as stack: + for finder in sys.meta_path: + if isinstance(finder, ModuleAcceleratorBase): + stack.enter_context(finder.disabled()) + return stack.pop_all() + assert False # pacify type checker + + +# because this function gets called so often and is quite +# expensive to run, we cache the results: +@functools.lru_cache(maxsize=1024) +def _caller_in_denylist(calling_module, denylist): + CUML_ACCELERATOR_PATH = __file__.rsplit("/", 1)[0] + return not calling_module.is_relative_to(CUML_ACCELERATOR_PATH) and any( + calling_module.is_relative_to(path) for path in denylist + ) diff --git a/python/cuml/cuml/internals/base.pyx b/python/cuml/cuml/internals/base.pyx index 9813acbba4..46741f2d0b 100644 --- a/python/cuml/cuml/internals/base.pyx +++ b/python/cuml/cuml/internals/base.pyx @@ -202,6 +202,12 @@ class Base(TagsMixin, del base # optional! """ + _base_hyperparam_interop_translator = { + "n_jobs": None + } + + _hyperparam_interop_translator = {} + def __init__(self, *, handle=None, verbose=False, @@ -471,6 +477,38 @@ class Base(TagsMixin, func = nvtx_annotate(message=msg, domain="cuml_python")(func) setattr(self, func_name, func) + @classmethod + def _hyperparam_translator(cls, **kwargs): + """ + This method is meant to do checks and translations of hyperparameters + at estimator creating time. + Each children estimator can override the method, returning either + modifier **kwargs with equivalent options, or + """ + gpu_hyperparams = cls.get_param_names() + kwargs.pop("self", None) + gpuaccel = True + for arg, value in kwargs.items(): + + if arg not in gpu_hyperparams: + + if arg in cls._base_hyperparam_interop_translator: + if cls._base_hyperparam_interop_translator[arg] == "pass": + gpuaccel = gpuaccel and True + + elif arg in cls._hyperparam_interop_translator: + if cls._hyperparam_interop_translator[arg] == "pass": + gpuaccel = gpuaccel and True + else: + gpuaccel = False + + else: + gpuaccel = False + + # we need to enable this if we enable translation for regular cuML + # kwargs["_gpuaccel"] = gpuaccel + return kwargs, gpuaccel + # Internal, non class owned helper functions def _check_output_type_str(output_str): @@ -681,11 +719,13 @@ class UniversalBase(Base): keyword arguments to be passed to the function for the call """ # look for current device_type - device_type = cuml.global_settings.device_type + # device_type = cuml.global_settings.device_type + device_type = self._dispatch_selector(func_name, *args, **kwargs) # GPU case - if device_type == DeviceType.device: + if device_type == DeviceType.device or func_name not in ['fit', 'fit_transform', 'fit_predict']: # call the function from the GPU estimator + logger.debug(f"Performing {func_name} in GPU") return gpu_func(self, *args, **kwargs) # CPU case @@ -725,3 +765,30 @@ class UniversalBase(Base): # return function result return res + + def _dispatch_selector(self, func_name, *args, **kwargs): + """ + """ + + if not self._gpuaccel: + device_type = DeviceType.host + else: + if not self._should_dispatch_cpu(func_name, *args, **kwargs): + device_type = DeviceType.device + else: + device_type = DeviceType.host + + return device_type + + def _should_dispatch_cpu(self, func_name, *args, **kwargs): + """ + This method is meant to do checks of data sizes and other things + at fit and other method call time, to decide where to disptach + a function. For hyperparameters of the estimator, + see the method _hyperparam_translator. + Each estimator inheritting from UniversalBase can override this + method to have custom rules of when to dispatch to CPU depending + on the data passed to fit/predict... + """ + + return False diff --git a/python/cuml/cuml/tests/experimental/accel/estimators_hyperparams/test_dbscan.py b/python/cuml/cuml/tests/experimental/accel/estimators_hyperparams/test_dbscan.py new file mode 100644 index 0000000000..af4503d3ed --- /dev/null +++ b/python/cuml/cuml/tests/experimental/accel/estimators_hyperparams/test_dbscan.py @@ -0,0 +1,91 @@ +# +# Copyright (c) 2024, NVIDIA CORPORATION. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import pytest +import numpy as np +from sklearn.datasets import make_blobs +from sklearn.cluster import DBSCAN +from sklearn.metrics import adjusted_rand_score + + +@pytest.fixture(scope="module") +def clustering_data(): + X, y = make_blobs( + n_samples=300, + centers=3, + cluster_std=[1.0, 2.5, 0.5], + random_state=42, + ) + return X, y + + +@pytest.mark.parametrize("eps", [0.1, 0.5, 1.0, 2.0]) +def test_dbscan_eps(clustering_data, eps): + X, y_true = clustering_data + dbscan = DBSCAN(eps=eps).fit(X) + y_pred = dbscan.labels_ + ari = adjusted_rand_score(y_true, y_pred) + + +@pytest.mark.parametrize("min_samples", [1, 5, 10, 20]) +def test_dbscan_min_samples(clustering_data, min_samples): + X, y_true = clustering_data + dbscan = DBSCAN(eps=0.5, min_samples=min_samples).fit(X) + y_pred = dbscan.labels_ + ari = adjusted_rand_score(y_true, y_pred) + + +@pytest.mark.parametrize("metric", ["euclidean", "manhattan", "chebyshev"]) +def test_dbscan_metric(clustering_data, metric): + X, y_true = clustering_data + dbscan = DBSCAN(eps=0.5, metric=metric).fit(X) + y_pred = dbscan.labels_ + ari = adjusted_rand_score(y_true, y_pred) + + +@pytest.mark.parametrize( + "algorithm", ["auto", "ball_tree", "kd_tree", "brute"] +) +def test_dbscan_algorithm(clustering_data, algorithm): + X, y_true = clustering_data + dbscan = DBSCAN(eps=0.5, algorithm=algorithm).fit(X) + y_pred = dbscan.labels_ + ari = adjusted_rand_score(y_true, y_pred) + + +@pytest.mark.parametrize("leaf_size", [10, 30, 50]) +def test_dbscan_leaf_size(clustering_data, leaf_size): + X, y_true = clustering_data + dbscan = DBSCAN(eps=0.5, leaf_size=leaf_size).fit(X) + y_pred = dbscan.labels_ + ari = adjusted_rand_score(y_true, y_pred) + + +@pytest.mark.parametrize("p", [1, 2, 3]) +def test_dbscan_p(clustering_data, p): + X, y_true = clustering_data + dbscan = DBSCAN(eps=0.5, metric="minkowski", p=p).fit(X) + y_pred = dbscan.labels_ + ari = adjusted_rand_score(y_true, y_pred) + + +def test_dbscan_consistency(clustering_data): + X, y_true = clustering_data + dbscan1 = DBSCAN(eps=0.5).fit(X) + dbscan2 = DBSCAN(eps=0.5).fit(X) + assert np.array_equal( + dbscan1.labels_, dbscan2.labels_ + ), "Results should be consistent across runs" diff --git a/python/cuml/cuml/tests/experimental/accel/estimators_hyperparams/test_elastic_net.py b/python/cuml/cuml/tests/experimental/accel/estimators_hyperparams/test_elastic_net.py new file mode 100644 index 0000000000..ba22b82a56 --- /dev/null +++ b/python/cuml/cuml/tests/experimental/accel/estimators_hyperparams/test_elastic_net.py @@ -0,0 +1,218 @@ +# +# Copyright (c) 2024, NVIDIA CORPORATION. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import pytest +import numpy as np +from sklearn.datasets import make_regression +from sklearn.linear_model import ElasticNet +from sklearn.metrics import mean_squared_error, r2_score +from sklearn.preprocessing import StandardScaler + + +@pytest.fixture(scope="module") +def regression_data(): + X, y = make_regression( + n_samples=500, + n_features=20, + n_informative=10, + noise=0.1, + random_state=42, + ) + # Standardize features + X = StandardScaler().fit_transform(X) + return X, y + + +@pytest.mark.parametrize("alpha", [0.1, 0.5, 1.0, 2.0]) +def test_elasticnet_alpha(regression_data, alpha): + X, y = regression_data + model = ElasticNet(alpha=alpha, random_state=42) + model.fit(X, y) + y_pred = model.predict(X) + # Compute R^2 score + r2 = r2_score(y, y_pred) + assert r2 > 0.5, f"R^2 score should be reasonable for alpha={alpha}" + + +@pytest.mark.parametrize("l1_ratio", [0.0, 0.5, 0.7, 1.0]) +def test_elasticnet_l1_ratio(regression_data, l1_ratio): + X, y = regression_data + model = ElasticNet(alpha=1.0, l1_ratio=l1_ratio, random_state=42) + model.fit(X, y) + y_pred = model.predict(X) + # Compute R^2 score + r2 = r2_score(y, y_pred) + assert r2 > 0.5, f"R^2 score should be reasonable for l1_ratio={l1_ratio}" + # Check sparsity of coefficients when l1_ratio=1 (equivalent to Lasso) + if l1_ratio == 1.0: + num_nonzero = np.sum(model.coef_ != 0) + assert ( + num_nonzero < X.shape[1] + ), "Some coefficients should be zero when l1_ratio=1.0" + + +@pytest.mark.parametrize("max_iter", [100, 500, 1000]) +def test_elasticnet_max_iter(regression_data, max_iter): + X, y = regression_data + model = ElasticNet(max_iter=max_iter, random_state=42) + model.fit(X, y) + assert ( + model.n_iter_ <= max_iter + ), "Number of iterations should not exceed max_iter" + + +@pytest.mark.parametrize("tol", [1e-4, 1e-3, 1e-2]) +def test_elasticnet_tol(regression_data, tol): + X, y = regression_data + model = ElasticNet(tol=tol, random_state=42) + model.fit(X, y) + y_pred = model.predict(X) + # Compute R^2 score + r2 = r2_score(y, y_pred) + assert r2 > 0.5, f"R^2 score should be reasonable for tol={tol}" + + +@pytest.mark.parametrize("fit_intercept", [True, False]) +def test_elasticnet_fit_intercept(regression_data, fit_intercept): + X, y = regression_data + model = ElasticNet(fit_intercept=fit_intercept, random_state=42) + model.fit(X, y) + y_pred = model.predict(X) + # Compute R^2 score + r2 = r2_score(y, y_pred) + assert ( + r2 > 0.5 + ), f"R^2 score should be reasonable with fit_intercept={fit_intercept}" + + +@pytest.mark.parametrize("precompute", [True, False]) +def test_elasticnet_precompute(regression_data, precompute): + X, y = regression_data + model = ElasticNet(precompute=precompute, random_state=42) + model.fit(X, y) + y_pred = model.predict(X) + # Compute R^2 score + r2 = r2_score(y, y_pred) + assert ( + r2 > 0.5 + ), f"R^2 score should be reasonable with precompute={precompute}" + + +@pytest.mark.parametrize("selection", ["cyclic", "random"]) +def test_elasticnet_selection(regression_data, selection): + X, y = regression_data + model = ElasticNet(selection=selection, random_state=42) + model.fit(X, y) + y_pred = model.predict(X) + # Compute R^2 score + r2 = r2_score(y, y_pred) + assert ( + r2 > 0.5 + ), f"R^2 score should be reasonable with selection={selection}" + + +def test_elasticnet_random_state(regression_data): + X, y = regression_data + model1 = ElasticNet(selection="random", random_state=42) + model1.fit(X, y) + model2 = ElasticNet(selection="random", random_state=42) + model2.fit(X, y) + # Coefficients should be the same when random_state is fixed + np.testing.assert_allclose( + model1.coef_, + model2.coef_, + err_msg="Coefficients should be the same with the same random_state", + ) + model3 = ElasticNet(selection="random", random_state=24) + model3.fit(X, y) + # Coefficients might differ with a different random_state + with pytest.raises(AssertionError): + np.testing.assert_allclose( + model1.coef_, + model3.coef_, + err_msg="Coefficients should differ with different random_state", + ) + + +def test_elasticnet_convergence_warning(regression_data): + X, y = regression_data + from sklearn.exceptions import ConvergenceWarning + + with pytest.warns(ConvergenceWarning): + model = ElasticNet(max_iter=1, random_state=42) + model.fit(X, y) + + +def test_elasticnet_coefficients(regression_data): + X, y = regression_data + model = ElasticNet(alpha=0.1, l1_ratio=0.5, random_state=42) + model.fit(X, y) + coef_nonzero = np.sum(model.coef_ != 0) + assert coef_nonzero > 0, "There should be non-zero coefficients" + + +def test_elasticnet_l1_ratio_effect(regression_data): + X, y = regression_data + model_l1 = ElasticNet(alpha=0.1, l1_ratio=1.0, random_state=42) + model_l1.fit(X, y) + model_l2 = ElasticNet(alpha=0.1, l1_ratio=0.0, random_state=42) + model_l2.fit(X, y) + num_nonzero_l1 = np.sum(model_l1.coef_ != 0) + num_nonzero_l2 = np.sum(model_l2.coef_ != 0) + assert ( + num_nonzero_l1 <= num_nonzero_l2 + ), "L1 regularization should produce sparser coefficients than L2" + + +@pytest.mark.parametrize("copy_X", [True, False]) +def test_elasticnet_copy_X(regression_data, copy_X): + X, y = regression_data + X_original = X.copy() + model = ElasticNet(copy_X=copy_X, random_state=42) + model.fit(X, y) + if copy_X: + # X should remain unchanged + assert np.allclose( + X, X_original + ), "X has been modified when copy_X=True" + else: + # X might be modified when copy_X=False + pass # We cannot guarantee X remains unchanged + + +def test_elasticnet_positive(regression_data): + X, y = regression_data + model = ElasticNet(positive=True, random_state=42) + model.fit(X, y) + # All coefficients should be non-negative + assert np.all( + model.coef_ >= 0 + ), "All coefficients should be non-negative when positive=True" + + +def test_elasticnet_warm_start(regression_data): + X, y = regression_data + model = ElasticNet(warm_start=True, random_state=42) + model.fit(X, y) + coef_old = model.coef_.copy() + # Fit again with more iterations + model.set_params(max_iter=2000) + model.fit(X, y) + coef_new = model.coef_ + # Coefficients should change after more iterations + assert not np.allclose( + coef_old, coef_new + ), "Coefficients should update when warm_start=True" diff --git a/python/cuml/cuml/tests/experimental/accel/estimators_hyperparams/test_hdbscan_core.py b/python/cuml/cuml/tests/experimental/accel/estimators_hyperparams/test_hdbscan_core.py new file mode 100644 index 0000000000..8b19f5a9d1 --- /dev/null +++ b/python/cuml/cuml/tests/experimental/accel/estimators_hyperparams/test_hdbscan_core.py @@ -0,0 +1,328 @@ +# +# Copyright (c) 2024, NVIDIA CORPORATION. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import pytest +import numpy as np +from sklearn.datasets import make_blobs, make_moons +from sklearn.preprocessing import StandardScaler +import hdbscan + + +@pytest.fixture(scope="module") +def synthetic_data(): + X, y = make_blobs( + n_samples=500, + n_features=2, + centers=5, + cluster_std=0.5, + random_state=42, + ) + # Standardize features + X = StandardScaler().fit_transform(X) + return X, y + + +@pytest.mark.parametrize("min_cluster_size", [5, 15, 30]) +def test_hdbscan_min_cluster_size(synthetic_data, min_cluster_size): + X, _ = synthetic_data + clusterer = hdbscan.HDBSCAN(min_cluster_size=min_cluster_size) + cluster_labels = clusterer.fit_predict(X) + # Check that clusters are formed + n_clusters = len(set(cluster_labels)) - (1 if -1 in cluster_labels else 0) + assert ( + n_clusters > 0 + ), f"Should find clusters with min_cluster_size={min_cluster_size}" + + +@pytest.mark.parametrize("min_samples", [1, 5, 15]) +def test_hdbscan_min_samples(synthetic_data, min_samples): + X, _ = synthetic_data + clusterer = hdbscan.HDBSCAN(min_samples=min_samples) + cluster_labels = clusterer.fit_predict(X) + # Check that clusters are formed + n_clusters = len(set(cluster_labels)) - (1 if -1 in cluster_labels else 0) + assert ( + n_clusters > 0 + ), f"Should find clusters with min_samples={min_samples}" + + +@pytest.mark.parametrize( + "metric", ["euclidean", "manhattan", "chebyshev", "minkowski"] +) +def test_hdbscan_metric(synthetic_data, metric): + X, _ = synthetic_data + p = 0.5 if metric == "minkowski" else None + clusterer = hdbscan.HDBSCAN(metric=metric, p=p) + cluster_labels = clusterer.fit_predict(X) + # Check that clusters are formed + n_clusters = len(set(cluster_labels)) - (1 if -1 in cluster_labels else 0) + assert n_clusters > 0, f"Should find clusters with metric={metric}" + + +@pytest.mark.parametrize("method", ["eom", "leaf"]) +def test_hdbscan_cluster_selection_method(synthetic_data, method): + X, _ = synthetic_data + clusterer = hdbscan.HDBSCAN(cluster_selection_method=method) + cluster_labels = clusterer.fit_predict(X) + # Check that clusters are formed + n_clusters = len(set(cluster_labels)) - (1 if -1 in cluster_labels else 0) + assert ( + n_clusters > 0 + ), f"Should find clusters with cluster_selection_method={method}" + + +def test_hdbscan_prediction_data(synthetic_data): + X, _ = synthetic_data + clusterer = hdbscan.HDBSCAN(prediction_data=True) + clusterer.fit(X) + # Check that prediction data is available + assert hasattr( + clusterer, "prediction_data_" + ), "Prediction data should be available when prediction_data=True" + + +@pytest.mark.parametrize("algorithm", ["best", "generic"]) +def test_hdbscan_algorithm(synthetic_data, algorithm): + X, _ = synthetic_data + clusterer = hdbscan.HDBSCAN(algorithm=algorithm) + cluster_labels = clusterer.fit_predict(X) + # Check that clusters are formed + n_clusters = len(set(cluster_labels)) - (1 if -1 in cluster_labels else 0) + assert n_clusters > 0, f"Should find clusters with algorithm={algorithm}" + + +@pytest.mark.parametrize("leaf_size", [10, 30, 50]) +def test_hdbscan_leaf_size(synthetic_data, leaf_size): + X, _ = synthetic_data + clusterer = hdbscan.HDBSCAN(leaf_size=leaf_size) + cluster_labels = clusterer.fit_predict(X) + # Check that clusters are formed + n_clusters = len(set(cluster_labels)) - (1 if -1 in cluster_labels else 0) + assert n_clusters > 0, f"Should find clusters with leaf_size={leaf_size}" + + +def test_hdbscan_gen_min_span_tree(synthetic_data): + X, _ = synthetic_data + clusterer = hdbscan.HDBSCAN(gen_min_span_tree=True) + clusterer.fit(X) + # Check that the minimum spanning tree is generated + assert hasattr( + clusterer, "minimum_spanning_tree_" + ), "Minimum spanning tree should be generated when gen_min_span_tree=True" + + +def test_hdbscan_memory(synthetic_data, tmpdir): + X, _ = synthetic_data + from joblib import Memory + + memory = Memory(location=tmpdir) + clusterer = hdbscan.HDBSCAN(memory=memory) + clusterer.fit(X) + # Check that cache directory is used + # assert tmpdir.listdir(), "Cache directory should not be empty when memory caching is used" + + +def test_hdbscan_approx_min_span_tree(synthetic_data): + X, _ = synthetic_data + clusterer = hdbscan.HDBSCAN(approx_min_span_tree=True) + clusterer.fit(X) + # Check that the parameter is set correctly + assert ( + clusterer.approx_min_span_tree is True + ), "approx_min_span_tree should be set to True" + + +@pytest.mark.parametrize("n_jobs", [1, -1]) +def test_hdbscan_core_dist_n_jobs(synthetic_data, n_jobs): + X, _ = synthetic_data + clusterer = hdbscan.HDBSCAN(core_dist_n_jobs=n_jobs) + clusterer.fit(X) + # We assume the code runs without error; no direct way to test n_jobs effect + assert True, f"HDBSCAN ran successfully with core_dist_n_jobs={n_jobs}" + + +def test_hdbscan_probabilities(synthetic_data): + X, _ = synthetic_data + clusterer = hdbscan.HDBSCAN() + clusterer.fit(X) + # Check that cluster membership probabilities are available + assert hasattr( + clusterer, "probabilities_" + ), "Cluster membership probabilities should be available after fitting" + + +def test_hdbscan_outlier_scores(synthetic_data): + X, _ = synthetic_data + clusterer = hdbscan.HDBSCAN() + clusterer.fit(X) + # Check that outlier scores are available + assert hasattr( + clusterer, "outlier_scores_" + ), "Outlier scores should be available after fitting" + + +def test_hdbscan_fit_predict(synthetic_data): + X, _ = synthetic_data + clusterer = hdbscan.HDBSCAN() + labels_fit = clusterer.fit(X).labels_ + labels_predict = clusterer.fit_predict(X) + # Check that labels from fit and fit_predict are the same + assert np.array_equal( + labels_fit, labels_predict + ), "Labels from fit and fit_predict should be the same" + + +def test_hdbscan_invalid_metric(synthetic_data): + X, _ = synthetic_data + with pytest.raises(ValueError): + clusterer = hdbscan.HDBSCAN(metric="invalid_metric") + clusterer.fit(X) + + +def test_hdbscan_sparse_input(): + from scipy.sparse import csr_matrix + + X, _ = make_blobs( + n_samples=100, + n_features=2, + centers=3, + cluster_std=0.5, + random_state=42, + ) + X_sparse = csr_matrix(X) + clusterer = hdbscan.HDBSCAN() + cluster_labels = clusterer.fit_predict(X_sparse) + # Check that clusters are formed + n_clusters = len(set(cluster_labels)) - (1 if -1 in cluster_labels else 0) + assert n_clusters > 0, "Should find clusters with sparse input data" + + +def test_hdbscan_non_convex_shapes(): + X, y = make_moons(n_samples=300, noise=0.05, random_state=42) + clusterer = hdbscan.HDBSCAN(min_cluster_size=5) + cluster_labels = clusterer.fit_predict(X) + # Check that at least two clusters are found + n_clusters = len(set(cluster_labels)) - (1 if -1 in cluster_labels else 0) + assert n_clusters >= 2, "Should find clusters in non-convex shapes" + + +def test_hdbscan_prediction(synthetic_data): + X_train, _ = synthetic_data + X_test, _ = make_blobs( + n_samples=100, + n_features=2, + centers=5, + cluster_std=0.5, + random_state=24, + ) + X_test = StandardScaler().fit_transform(X_test) + clusterer = hdbscan.HDBSCAN(prediction_data=True) + clusterer.fit(X_train) + test_labels, strengths = hdbscan.approximate_predict(clusterer, X_test) + # Check that labels are assigned to test data + assert ( + len(test_labels) == X_test.shape[0] + ), "Labels should be assigned to test data points" + + +def test_hdbscan_single_linkage_tree(synthetic_data): + X, _ = synthetic_data + clusterer = hdbscan.HDBSCAN(gen_min_span_tree=True) + clusterer.fit(X) + # Check that the single linkage tree is generated + assert hasattr( + clusterer, "single_linkage_tree_" + ), "Single linkage tree should be generated after fitting" + + +def test_hdbscan_condensed_tree(synthetic_data): + X, _ = synthetic_data + clusterer = hdbscan.HDBSCAN() + clusterer.fit(X) + # Check that the condensed tree is available + assert hasattr( + clusterer, "condensed_tree_" + ), "Condensed tree should be available after fitting" + + +def test_hdbscan_exemplars(synthetic_data): + X, _ = synthetic_data + clusterer = hdbscan.HDBSCAN() + clusterer.fit(X) + # Check that cluster exemplars are available + assert hasattr( + clusterer, "exemplars_" + ), "Cluster exemplars should be available after fitting" + + +def test_hdbscan_prediction_data_with_prediction(synthetic_data): + X_train, _ = synthetic_data + clusterer = hdbscan.HDBSCAN(prediction_data=True) + clusterer.fit(X_train) + # Use training data for prediction as a simple test + test_labels, strengths = hdbscan.approximate_predict(clusterer, X_train) + # Check that labels from prediction match original labels + assert np.array_equal( + clusterer.labels_, test_labels + ), "Predicted labels should match original labels for training data" + + +def test_hdbscan_predict_without_prediction_data(synthetic_data): + X_train, _ = synthetic_data + clusterer = hdbscan.HDBSCAN(prediction_data=False) + clusterer.fit(X_train) + with pytest.raises(AttributeError): + hdbscan.approximate_predict(clusterer, X_train) + + +def test_hdbscan_min_cluster_size_effect(synthetic_data): + X, _ = synthetic_data + min_cluster_sizes = [5, 15, 30, 50] + n_clusters_list = [] + for size in min_cluster_sizes: + clusterer = hdbscan.HDBSCAN(min_cluster_size=size) + cluster_labels = clusterer.fit_predict(X) + n_clusters = len(set(cluster_labels)) - ( + 1 if -1 in cluster_labels else 0 + ) + n_clusters_list.append(n_clusters) + # Expect fewer clusters as min_cluster_size increases + assert n_clusters_list == sorted( + n_clusters_list, reverse=True + ), "Number of clusters should decrease as min_cluster_size increases" + + +def test_hdbscan_min_span_tree_effect(synthetic_data): + X, _ = synthetic_data + clusterer_with_tree = hdbscan.HDBSCAN(gen_min_span_tree=True) + clusterer_with_tree.fit(X) + clusterer_without_tree = hdbscan.HDBSCAN(gen_min_span_tree=False) + clusterer_without_tree.fit(X) + # Check that the minimum spanning tree affects the clustering (may not always be true) + assert np.array_equal( + clusterer_with_tree.labels_, clusterer_without_tree.labels_ + ), "Clustering should be consistent regardless of gen_min_span_tree" + + +def test_hdbscan_allow_single_cluster(synthetic_data): + X, _ = synthetic_data + clusterer = hdbscan.HDBSCAN(allow_single_cluster=True) + cluster_labels = clusterer.fit_predict(X) + # Check that clusters are formed + n_clusters = len(set(cluster_labels)) - (1 if -1 in cluster_labels else 0) + assert ( + n_clusters >= 1 + ), "Should allow a single cluster when allow_single_cluster=True" diff --git a/python/cuml/cuml/tests/experimental/accel/estimators_hyperparams/test_hdbscan_extended.py b/python/cuml/cuml/tests/experimental/accel/estimators_hyperparams/test_hdbscan_extended.py new file mode 100644 index 0000000000..1e590ff798 --- /dev/null +++ b/python/cuml/cuml/tests/experimental/accel/estimators_hyperparams/test_hdbscan_extended.py @@ -0,0 +1,214 @@ +# +# Copyright (c) 2024, NVIDIA CORPORATION. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + + +import pytest +import numpy as np +from sklearn.datasets import make_blobs, make_moons +from sklearn.preprocessing import StandardScaler +import hdbscan +from hdbscan import validity +from hdbscan import prediction + + +@pytest.fixture(scope="module") +def synthetic_data(): + X, y = make_blobs( + n_samples=500, + n_features=2, + centers=5, + cluster_std=0.5, + random_state=42, + ) + # Standardize features + X = StandardScaler().fit_transform(X) + return X, y + + +def test_hdbscan_approximate_predict(synthetic_data): + X_train, _ = synthetic_data + X_test, _ = make_blobs( + n_samples=100, + n_features=2, + centers=5, + cluster_std=0.5, + random_state=24, + ) + X_test = StandardScaler().fit_transform(X_test) + clusterer = hdbscan.HDBSCAN(prediction_data=True) + clusterer.fit(X_train) + test_labels, strengths = hdbscan.approximate_predict(clusterer, X_test) + # Check that labels are assigned to test data + assert ( + len(test_labels) == X_test.shape[0] + ), "Labels should be assigned to test data points" + assert ( + len(strengths) == X_test.shape[0] + ), "Strengths should be computed for test data points" + # Check that strengths are between 0 and 1 + assert np.all( + (strengths >= 0) & (strengths <= 1) + ), "Strengths should be between 0 and 1" + + +def test_hdbscan_membership_vector(synthetic_data): + X_train, _ = synthetic_data + clusterer = hdbscan.HDBSCAN(prediction_data=True) + clusterer.fit(X_train) + point = X_train[0].reshape((1, 2)) + membership = hdbscan.membership_vector(clusterer, point) + + +def test_hdbscan_all_points_membership_vectors(synthetic_data): + X_train, _ = synthetic_data + clusterer = hdbscan.HDBSCAN(prediction_data=True) + clusterer.fit(X_train) + memberships = hdbscan.all_points_membership_vectors(clusterer) + # Check that the number of membership vectors matches the number of samples + assert ( + len(memberships) == X_train.shape[0] + ), "There should be a membership vector for each sample" + # Check that each membership vector sums to 1 + for membership in memberships: + # Check that all probabilities are between 0 and 1 + assert all( + 0.0 <= v <= 1.0 for v in membership + ), "Probabilities should be between 0 and 1" + + +def test_hdbscan_validity_index(synthetic_data): + X, _ = synthetic_data + clusterer = hdbscan.HDBSCAN() + clusterer.fit(X) + score = validity.validity_index(X, clusterer.labels_, metric="euclidean") + # Check that the validity index is a finite number + assert np.isfinite(score), "Validity index should be a finite number" + + +def test_hdbscan_condensed_tree(synthetic_data): + X, _ = synthetic_data + clusterer = hdbscan.HDBSCAN() + clusterer.fit(X) + condensed_tree = clusterer.condensed_tree_ + # Check that the condensed tree has the expected attributes + assert hasattr( + condensed_tree, "to_pandas" + ), "Condensed tree should have a 'to_pandas' method" + # Convert to pandas DataFrame and check columns + df = condensed_tree.to_pandas() + + +def test_hdbscan_single_linkage_tree_attribute(synthetic_data): + X, _ = synthetic_data + clusterer = hdbscan.HDBSCAN() + clusterer.fit(X) + single_linkage_tree = clusterer.single_linkage_tree_ + # Check that the single linkage tree has the expected attributes + assert hasattr( + single_linkage_tree, "to_numpy" + ), "Single linkage tree should have a 'to_numpy' method" + # Convert to NumPy array and check shape + sl_tree_array = single_linkage_tree.to_numpy() + assert ( + sl_tree_array.shape[1] == 4 + ), "Single linkage tree array should have 4 columns" + + +def test_hdbscan_flat_clustering(synthetic_data): + X, _ = synthetic_data + clusterer = hdbscan.HDBSCAN() + clusterer.fit(X) + # Extract clusters at a specific cluster_selection_epsilon + clusterer_flat = hdbscan.HDBSCAN(cluster_selection_epsilon=0.1) + clusterer_flat.fit(X) + # Check that clusters are formed + n_clusters_flat = len(set(clusterer_flat.labels_)) - ( + 1 if -1 in clusterer_flat.labels_ else 0 + ) + assert n_clusters_flat > 0, "Should find clusters with flat clustering" + + +def test_hdbscan_prediction_membership_vector(synthetic_data): + X_train, _ = synthetic_data + clusterer = hdbscan.HDBSCAN(prediction_data=True) + clusterer.fit(X_train) + point = X_train[0].reshape((1, 2)) + membership = prediction.membership_vector(clusterer, point) + + +def test_hdbscan_prediction_all_points_membership_vectors(synthetic_data): + X_train, _ = synthetic_data + clusterer = hdbscan.HDBSCAN(prediction_data=True) + clusterer.fit(X_train) + memberships = prediction.all_points_membership_vectors(clusterer) + # Check that the number of membership vectors matches the number of samples + assert ( + len(memberships) == X_train.shape[0] + ), "There should be a membership vector for each sample" + for membership in memberships: + # Check that all probabilities are between 0 and 1 + assert all( + 0.0 <= v <= 1.0 for v in membership + ), "Probabilities should be between 0 and 1" + + +def test_hdbscan_outlier_exposure(synthetic_data): + # Note: hdbscan may not have a function named 'outlier_exposure' + # This is a placeholder for any outlier detection functionality + X, _ = synthetic_data + clusterer = hdbscan.HDBSCAN() + clusterer.fit(X) + # Check if outlier scores are computed + if hasattr(clusterer, "outlier_scores_"): + outlier_scores = clusterer.outlier_scores_ + # Check that outlier scores are finite numbers + assert np.all( + np.isfinite(outlier_scores) + ), "Outlier scores should be finite numbers" + else: + pytest.skip( + "Outlier exposure functionality is not available in this version of HDBSCAN" + ) + + +# test requires networkx +# def test_hdbscan_extract_single_linkage_tree(synthetic_data): +# X, _ = synthetic_data +# clusterer = hdbscan.HDBSCAN() +# clusterer.fit(X) +# # Extract the single linkage tree +# sl_tree = clusterer.single_linkage_tree_.to_networkx() +# # Check that the tree has the correct number of nodes +# assert sl_tree.number_of_nodes() == X.shape[0], "Single linkage tree should have a node for each data point" + + +def test_hdbscan_get_exemplars(synthetic_data): + X, _ = synthetic_data + clusterer = hdbscan.HDBSCAN() + clusterer.fit(X) + if hasattr(clusterer, "exemplars_"): + exemplars = clusterer.exemplars_ + # Check that exemplars are available for each cluster + n_clusters = len(set(clusterer.labels_)) - ( + 1 if -1 in clusterer.labels_ else 0 + ) + assert ( + len(exemplars) == n_clusters + ), "There should be exemplars for each cluster" + else: + pytest.skip( + "Exemplar functionality is not available in this version of HDBSCAN" + ) diff --git a/python/cuml/cuml/tests/experimental/accel/estimators_hyperparams/test_kmeans.py b/python/cuml/cuml/tests/experimental/accel/estimators_hyperparams/test_kmeans.py new file mode 100644 index 0000000000..7ea1b22202 --- /dev/null +++ b/python/cuml/cuml/tests/experimental/accel/estimators_hyperparams/test_kmeans.py @@ -0,0 +1,105 @@ +# +# Copyright (c) 2024, NVIDIA CORPORATION. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import pytest +import numpy as np +from sklearn.datasets import make_blobs +from sklearn.cluster import KMeans +from sklearn.metrics import adjusted_rand_score + + +@pytest.fixture(scope="module") +def clustering_data(): + X, y = make_blobs( + n_samples=300, centers=3, cluster_std=1.0, random_state=42 + ) + return X, y + + +@pytest.mark.parametrize("n_clusters", [2, 3, 4, 5]) +def test_kmeans_n_clusters(clustering_data, n_clusters): + X, y_true = clustering_data + kmeans = KMeans(n_clusters=n_clusters, random_state=42).fit(X) + y_pred = kmeans.labels_ + ari = adjusted_rand_score(y_true, y_pred) + + +@pytest.mark.parametrize("init", ["k-means++", "random"]) +def test_kmeans_init(clustering_data, init): + X, y_true = clustering_data + kmeans = KMeans(n_clusters=3, init=init, random_state=42).fit(X) + y_pred = kmeans.labels_ + ari = adjusted_rand_score(y_true, y_pred) + + +@pytest.mark.parametrize("n_init", [1, 5, 10, 20]) +def test_kmeans_n_init(clustering_data, n_init): + X, y_true = clustering_data + kmeans = KMeans(n_clusters=3, n_init=n_init, random_state=42).fit(X) + y_pred = kmeans.labels_ + ari = adjusted_rand_score(y_true, y_pred) + + +@pytest.mark.parametrize("max_iter", [100, 300, 500]) +def test_kmeans_max_iter(clustering_data, max_iter): + X, y_true = clustering_data + kmeans = KMeans(n_clusters=3, max_iter=max_iter, random_state=42).fit(X) + y_pred = kmeans.labels_ + ari = adjusted_rand_score(y_true, y_pred) + + +@pytest.mark.parametrize("tol", [1e-4, 1e-3, 1e-2]) +def test_kmeans_tol(clustering_data, tol): + X, y_true = clustering_data + kmeans = KMeans(n_clusters=3, tol=tol, random_state=42).fit(X) + y_pred = kmeans.labels_ + ari = adjusted_rand_score(y_true, y_pred) + + +@pytest.mark.parametrize("algorithm", ["elkan", "lloyd"]) +def test_kmeans_algorithm(clustering_data, algorithm): + X, y_true = clustering_data + kmeans = KMeans(n_clusters=3, algorithm=algorithm, random_state=42).fit(X) + y_pred = kmeans.labels_ + ari = adjusted_rand_score(y_true, y_pred) + + +@pytest.mark.parametrize("copy_x", [True, False]) +def test_kmeans_copy_x(clustering_data, copy_x): + X, y_true = clustering_data + X_original = X.copy() + kmeans = KMeans(n_clusters=3, copy_x=copy_x, random_state=42).fit(X) + if copy_x: + # X should remain unchanged + assert np.allclose( + X, X_original + ), "X has been modified when copy_x=True" + else: + # X might be modified when copy_x=False + pass # We cannot guarantee X remains unchanged + y_pred = kmeans.labels_ + ari = adjusted_rand_score(y_true, y_pred) + + +def test_kmeans_random_state(clustering_data): + X, y_true = clustering_data + kmeans1 = KMeans(n_clusters=3, random_state=42).fit(X) + kmeans2 = KMeans(n_clusters=3, random_state=42).fit(X) + # With the same random_state, results should be the same + assert np.allclose(kmeans1.cluster_centers_, kmeans2.cluster_centers_) + kmeans3 = KMeans(n_clusters=3, random_state=24).fit(X) + # With different random_state, results might differ + assert not np.allclose(kmeans1.cluster_centers_, kmeans3.cluster_centers_) diff --git a/python/cuml/cuml/tests/experimental/accel/estimators_hyperparams/test_kneighbors_classifier.py b/python/cuml/cuml/tests/experimental/accel/estimators_hyperparams/test_kneighbors_classifier.py new file mode 100644 index 0000000000..8776754dd6 --- /dev/null +++ b/python/cuml/cuml/tests/experimental/accel/estimators_hyperparams/test_kneighbors_classifier.py @@ -0,0 +1,194 @@ +# +# Copyright (c) 2024, NVIDIA CORPORATION. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + + +import pytest +import numpy as np +from sklearn.datasets import make_classification +from sklearn.neighbors import KNeighborsClassifier +from sklearn.preprocessing import StandardScaler +from sklearn.metrics import accuracy_score + + +@pytest.fixture(scope="module") +def classification_data(): + X, y = make_classification( + n_samples=500, + n_features=20, + n_informative=15, + n_redundant=5, + n_classes=3, + random_state=42, + ) + # Standardize features + X = StandardScaler().fit_transform(X) + return X, y + + +@pytest.mark.parametrize("n_neighbors", [1, 3, 5, 10]) +def test_knn_classifier_n_neighbors(classification_data, n_neighbors): + X, y = classification_data + model = KNeighborsClassifier(n_neighbors=n_neighbors) + model.fit(X, y) + y_pred = model.predict(X) + acc = accuracy_score(y, y_pred) + assert ( + acc > 0.7 + ), f"Accuracy should be reasonable with n_neighbors={n_neighbors}" + + +@pytest.mark.parametrize("weights", ["uniform", "distance"]) +def test_knn_classifier_weights(classification_data, weights): + X, y = classification_data + model = KNeighborsClassifier(weights=weights) + model.fit(X, y) + y_pred = model.predict(X) + acc = accuracy_score(y, y_pred) + assert acc > 0.7, f"Accuracy should be reasonable with weights={weights}" + + +@pytest.mark.parametrize( + "algorithm", ["auto", "ball_tree", "kd_tree", "brute"] +) +def test_knn_classifier_algorithm(classification_data, algorithm): + X, y = classification_data + model = KNeighborsClassifier(algorithm=algorithm) + model.fit(X, y) + y_pred = model.predict(X) + acc = accuracy_score(y, y_pred) + assert ( + acc > 0.7 + ), f"Accuracy should be reasonable with algorithm={algorithm}" + + +@pytest.mark.parametrize("leaf_size", [10, 30, 50]) +def test_knn_classifier_leaf_size(classification_data, leaf_size): + X, y = classification_data + model = KNeighborsClassifier(leaf_size=leaf_size) + model.fit(X, y) + y_pred = model.predict(X) + acc = accuracy_score(y, y_pred) + assert ( + acc > 0.7 + ), f"Accuracy should be reasonable with leaf_size={leaf_size}" + + +@pytest.mark.parametrize( + "metric", ["euclidean", "manhattan", "chebyshev", "minkowski"] +) +def test_knn_classifier_metric(classification_data, metric): + X, y = classification_data + model = KNeighborsClassifier(metric=metric) + model.fit(X, y) + y_pred = model.predict(X) + acc = accuracy_score(y, y_pred) + assert acc > 0.7, f"Accuracy should be reasonable with metric={metric}" + + +@pytest.mark.parametrize("p", [1, 2, 3]) +def test_knn_classifier_p_parameter(classification_data, p): + X, y = classification_data + model = KNeighborsClassifier(metric="minkowski", p=p) + model.fit(X, y) + y_pred = model.predict(X) + acc = accuracy_score(y, y_pred) + assert acc > 0.7, f"Accuracy should be reasonable with p={p}" + + +def test_knn_classifier_weights_callable(classification_data): + X, y = classification_data + + def custom_weights(distances): + return np.ones_like(distances) + + model = KNeighborsClassifier(weights=custom_weights) + model.fit(X, y) + y_pred = model.predict(X) + acc = accuracy_score(y, y_pred) + assert acc > 0.7, "Accuracy should be reasonable with custom weights" + + +def test_knn_classifier_invalid_algorithm(classification_data): + X, y = classification_data + with pytest.raises(ValueError): + model = KNeighborsClassifier(algorithm="invalid_algorithm") + model.fit(X, y) + + +def test_knn_classifier_invalid_metric(classification_data): + X, y = classification_data + with pytest.raises(ValueError): + model = KNeighborsClassifier(metric="invalid_metric") + model.fit(X, y) + + +def test_knn_classifier_invalid_weights(classification_data): + X, y = classification_data + with pytest.raises(ValueError): + model = KNeighborsClassifier(weights="invalid_weight") + model.fit(X, y) + + +def test_knn_classifier_predict_proba(classification_data): + X, y = classification_data + model = KNeighborsClassifier() + model.fit(X, y) + proba = model.predict_proba(X) + # Check that probabilities sum to 1 + assert np.allclose(proba.sum(axis=1), 1), "Probabilities should sum to 1" + # Check shape + assert proba.shape == ( + X.shape[0], + len(np.unique(y)), + ), "Probability matrix shape should be (n_samples, n_classes)" + + +def test_knn_classifier_no_data(): + with pytest.raises(ValueError): + model = KNeighborsClassifier() + model.fit(None, None) + + +def test_knn_classifier_sparse_input(): + from scipy.sparse import csr_matrix + + X, y = make_classification(n_samples=100, n_features=20, random_state=42) + X_sparse = csr_matrix(X) + model = KNeighborsClassifier() + model.fit(X_sparse, y) + y_pred = model.predict(X_sparse) + acc = accuracy_score(y, y_pred) + assert acc > 0.7, "Accuracy should be reasonable with sparse input" + + +def test_knn_classifier_multilabel(): + from sklearn.datasets import make_multilabel_classification + + X, y = make_multilabel_classification( + n_samples=100, n_features=20, n_classes=3, random_state=42 + ) + model = KNeighborsClassifier() + model.fit(X, y) + y_pred = model.predict(X) + # Check that the predicted shape matches the true labels + assert ( + y_pred.shape == y.shape + ), "Predicted labels should have the same shape as true labels" + # Calculate accuracy for multi-label + acc = (y_pred == y).mean() + assert ( + acc > 0.7 + ), "Accuracy should be reasonable for multi-label classification" diff --git a/python/cuml/cuml/tests/experimental/accel/estimators_hyperparams/test_kneighbors_regressor.py b/python/cuml/cuml/tests/experimental/accel/estimators_hyperparams/test_kneighbors_regressor.py new file mode 100644 index 0000000000..bd1b025a42 --- /dev/null +++ b/python/cuml/cuml/tests/experimental/accel/estimators_hyperparams/test_kneighbors_regressor.py @@ -0,0 +1,168 @@ +# +# Copyright (c) 2024, NVIDIA CORPORATION. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import pytest +import numpy as np +from sklearn.datasets import make_regression +from sklearn.neighbors import KNeighborsRegressor +from sklearn.preprocessing import StandardScaler +from sklearn.metrics import r2_score + + +@pytest.fixture(scope="module") +def regression_data(): + X, y = make_regression( + n_samples=500, + n_features=20, + n_informative=15, + noise=0.1, + random_state=42, + ) + # Standardize features + X = StandardScaler().fit_transform(X) + return X, y + + +@pytest.mark.parametrize("n_neighbors", [1, 3, 5, 10]) +def test_knn_regressor_n_neighbors(regression_data, n_neighbors): + X, y = regression_data + model = KNeighborsRegressor(n_neighbors=n_neighbors) + model.fit(X, y) + y_pred = model.predict(X) + r2 = r2_score(y, y_pred) + + +@pytest.mark.parametrize("weights", ["uniform", "distance"]) +def test_knn_regressor_weights(regression_data, weights): + X, y = regression_data + model = KNeighborsRegressor(weights=weights) + model.fit(X, y) + y_pred = model.predict(X) + r2 = r2_score(y, y_pred) + assert r2 > 0.7, f"R^2 score should be reasonable with weights={weights}" + + +@pytest.mark.parametrize( + "algorithm", ["auto", "ball_tree", "kd_tree", "brute"] +) +def test_knn_regressor_algorithm(regression_data, algorithm): + X, y = regression_data + model = KNeighborsRegressor(algorithm=algorithm) + model.fit(X, y) + y_pred = model.predict(X) + r2 = r2_score(y, y_pred) + assert ( + r2 > 0.7 + ), f"R^2 score should be reasonable with algorithm={algorithm}" + + +@pytest.mark.parametrize("leaf_size", [10, 30, 50]) +def test_knn_regressor_leaf_size(regression_data, leaf_size): + X, y = regression_data + model = KNeighborsRegressor(leaf_size=leaf_size) + model.fit(X, y) + y_pred = model.predict(X) + r2 = r2_score(y, y_pred) + assert ( + r2 > 0.7 + ), f"R^2 score should be reasonable with leaf_size={leaf_size}" + + +@pytest.mark.parametrize( + "metric", ["euclidean", "manhattan", "chebyshev", "minkowski"] +) +def test_knn_regressor_metric(regression_data, metric): + X, y = regression_data + model = KNeighborsRegressor(metric=metric) + model.fit(X, y) + y_pred = model.predict(X) + r2 = r2_score(y, y_pred) + + +@pytest.mark.parametrize("p", [1, 2, 3]) +def test_knn_regressor_p_parameter(regression_data, p): + X, y = regression_data + model = KNeighborsRegressor(metric="minkowski", p=p) + model.fit(X, y) + y_pred = model.predict(X) + r2 = r2_score(y, y_pred) + assert r2 > 0.7, f"R^2 score should be reasonable with p={p}" + + +def test_knn_regressor_weights_callable(regression_data): + X, y = regression_data + + def custom_weights(distances): + return np.ones_like(distances) + + model = KNeighborsRegressor(weights=custom_weights) + model.fit(X, y) + y_pred = model.predict(X) + r2 = r2_score(y, y_pred) + assert r2 > 0.7, "R^2 score should be reasonable with custom weights" + + +def test_knn_regressor_invalid_algorithm(regression_data): + X, y = regression_data + with pytest.raises(ValueError): + model = KNeighborsRegressor(algorithm="invalid_algorithm") + model.fit(X, y) + + +def test_knn_regressor_invalid_metric(regression_data): + X, y = regression_data + with pytest.raises(ValueError): + model = KNeighborsRegressor(metric="invalid_metric") + model.fit(X, y) + + +def test_knn_regressor_invalid_weights(regression_data): + X, y = regression_data + with pytest.raises(ValueError): + model = KNeighborsRegressor(weights="invalid_weight") + model.fit(X, y) + + +def test_knn_regressor_no_data(): + with pytest.raises(ValueError): + model = KNeighborsRegressor() + model.fit(None, None) + + +def test_knn_regressor_sparse_input(): + from scipy.sparse import csr_matrix + + X, y = make_regression(n_samples=100, n_features=20, random_state=42) + X_sparse = csr_matrix(X) + model = KNeighborsRegressor() + model.fit(X_sparse, y) + y_pred = model.predict(X_sparse) + r2 = r2_score(y, y_pred) + + +def test_knn_regressor_multioutput(): + X, y = make_regression( + n_samples=100, n_features=20, n_targets=3, random_state=42 + ) + model = KNeighborsRegressor() + model.fit(X, y) + y_pred = model.predict(X) + # Check that the predicted shape matches the true targets + assert ( + y_pred.shape == y.shape + ), "Predicted outputs should have the same shape as true outputs" + # Calculate R^2 score for multi-output regression + r2 = r2_score(y, y_pred) diff --git a/python/cuml/cuml/tests/experimental/accel/estimators_hyperparams/test_lasso.py b/python/cuml/cuml/tests/experimental/accel/estimators_hyperparams/test_lasso.py new file mode 100644 index 0000000000..3e82432e51 --- /dev/null +++ b/python/cuml/cuml/tests/experimental/accel/estimators_hyperparams/test_lasso.py @@ -0,0 +1,202 @@ +# +# Copyright (c) 2024, NVIDIA CORPORATION. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import pytest +import numpy as np +from sklearn.datasets import make_regression +from sklearn.linear_model import Lasso +from sklearn.metrics import r2_score +from sklearn.preprocessing import StandardScaler + + +@pytest.fixture(scope="module") +def regression_data(): + X, y, coef = make_regression( + n_samples=500, + n_features=20, + n_informative=10, + noise=0.1, + coef=True, + random_state=42, + ) + # Standardize features + X = StandardScaler().fit_transform(X) + return X, y, coef + + +@pytest.mark.parametrize("alpha", [0.1, 1.0, 10.0, 100.0]) +def test_lasso_alpha(regression_data, alpha): + X, y, _ = regression_data + model = Lasso(alpha=alpha, random_state=42) + model.fit(X, y) + y_pred = model.predict(X) + # Compute R^2 score + r2 = r2_score(y, y_pred) + + +def test_lasso_alpha_sparsity(regression_data): + X, y, _ = regression_data + alphas = [0.1, 1.0, 10.0, 100.0] + zero_counts = [] + for alpha in alphas: + model = Lasso(alpha=alpha, random_state=42) + model.fit(X, y) + zero_counts.append(np.sum(model.coef_ == 0)) + # Check that zero_counts increases with alpha + assert zero_counts == sorted( + zero_counts + ), "Number of zero coefficients should increase with alpha" + + +@pytest.mark.parametrize("max_iter", [100, 500, 1000]) +def test_lasso_max_iter(regression_data, max_iter): + X, y, _ = regression_data + model = Lasso(max_iter=max_iter, random_state=42) + model.fit(X, y) + assert ( + model.n_iter_ <= max_iter + ), "Number of iterations should not exceed max_iter" + + +@pytest.mark.parametrize("tol", [1e-4, 1e-3, 1e-2]) +def test_lasso_tol(regression_data, tol): + X, y, _ = regression_data + model = Lasso(tol=tol, random_state=42) + model.fit(X, y) + y_pred = model.predict(X) + # Compute R^2 score + r2 = r2_score(y, y_pred) + assert r2 > 0.5, f"R^2 score should be reasonable for tol={tol}" + + +@pytest.mark.parametrize("fit_intercept", [True, False]) +def test_lasso_fit_intercept(regression_data, fit_intercept): + X, y, _ = regression_data + model = Lasso(fit_intercept=fit_intercept, random_state=42) + model.fit(X, y) + y_pred = model.predict(X) + # Compute R^2 score + r2 = r2_score(y, y_pred) + assert ( + r2 > 0.5 + ), f"R^2 score should be reasonable with fit_intercept={fit_intercept}" + + +def test_lasso_positive(regression_data): + X, y, _ = regression_data + model = Lasso(positive=True, random_state=42) + model.fit(X, y) + # All coefficients should be non-negative + assert np.all( + model.coef_ >= 0 + ), "All coefficients should be non-negative when positive=True" + + +def test_lasso_random_state(regression_data): + X, y, _ = regression_data + model1 = Lasso(selection="random", random_state=42) + model1.fit(X, y) + model2 = Lasso(selection="random", random_state=42) + model2.fit(X, y) + # Coefficients should be the same when random_state is fixed + np.testing.assert_allclose( + model1.coef_, + model2.coef_, + err_msg="Coefficients should be the same with the same random_state", + ) + model3 = Lasso(selection="random", random_state=24) + model3.fit(X, y) + # Coefficients might differ with a different random_state + with pytest.raises(AssertionError): + np.testing.assert_allclose( + model1.coef_, + model3.coef_, + err_msg="Coefficients should differ with different random_state", + ) + + +def test_lasso_warm_start(regression_data): + X, y, _ = regression_data + model = Lasso(warm_start=True, random_state=42) + model.fit(X, y) + coef_old = model.coef_.copy() + # Fit again with different alpha + model.set_params(alpha=10.0) + model.fit(X, y) + coef_new = model.coef_ + # Coefficients should change after refitting with a different alpha + assert not np.allclose( + coef_old, coef_new + ), "Coefficients should update when warm_start=True" + + +@pytest.mark.parametrize("copy_X", [True, False]) +def test_lasso_copy_X(regression_data, copy_X): + X, y, _ = regression_data + X_original = X.copy() + model = Lasso(copy_X=copy_X, random_state=42) + model.fit(X, y) + if copy_X: + # X should remain unchanged + assert np.allclose( + X, X_original + ), "X has been modified when copy_X=True" + else: + # X might be modified when copy_X=False + pass # We cannot guarantee X remains unchanged + + +def test_lasso_convergence_warning(regression_data): + X, y, _ = regression_data + from sklearn.exceptions import ConvergenceWarning + + with pytest.warns(ConvergenceWarning): + model = Lasso(max_iter=1, random_state=42) + model.fit(X, y) + + +def test_lasso_coefficients_sparsity(regression_data): + X, y, _ = regression_data + model = Lasso(alpha=1.0, random_state=42) + model.fit(X, y) + coef_zero = np.sum(model.coef_ == 0) + assert ( + coef_zero > 0 + ), "There should be zero coefficients indicating sparsity" + + +@pytest.mark.parametrize("selection", ["cyclic", "random"]) +def test_lasso_selection(regression_data, selection): + X, y, _ = regression_data + model = Lasso(selection=selection, random_state=42) + model.fit(X, y) + y_pred = model.predict(X) + r2 = r2_score(y, y_pred) + assert ( + r2 > 0.5 + ), f"R^2 score should be reasonable with selection={selection}" + + +@pytest.mark.parametrize("precompute", [True, False]) +def test_lasso_precompute(regression_data, precompute): + X, y, _ = regression_data + model = Lasso(precompute=precompute, random_state=42) + model.fit(X, y) + y_pred = model.predict(X) + r2 = r2_score(y, y_pred) + assert ( + r2 > 0.5 + ), f"R^2 score should be reasonable with precompute={precompute}" diff --git a/python/cuml/cuml/tests/experimental/accel/estimators_hyperparams/test_linear_regression.py b/python/cuml/cuml/tests/experimental/accel/estimators_hyperparams/test_linear_regression.py new file mode 100644 index 0000000000..34bc2c0358 --- /dev/null +++ b/python/cuml/cuml/tests/experimental/accel/estimators_hyperparams/test_linear_regression.py @@ -0,0 +1,59 @@ +# Copyright (c) 2024, NVIDIA CORPORATION. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pytest +import numpy as np +from sklearn.datasets import make_regression +from sklearn.linear_model import LinearRegression +from sklearn.metrics import r2_score + + +@pytest.fixture(scope="module") +def regression_data(): + X, y = make_regression( + n_samples=100, n_features=20, noise=0.1, random_state=42 + ) + return X, y + + +@pytest.mark.parametrize("fit_intercept", [True, False]) +def test_linear_regression_fit_intercept(regression_data, fit_intercept): + X, y = regression_data + lr = LinearRegression(fit_intercept=fit_intercept).fit(X, y) + y_pred = lr.predict(X) + + +@pytest.mark.parametrize("copy_X", [True, False]) +def test_linear_regression_copy_X(regression_data, copy_X): + X, y = regression_data + X_original = X.copy() + lr = LinearRegression(copy_X=copy_X).fit(X, y) + if copy_X: + # X should remain unchanged + assert np.array_equal( + X, X_original + ), "X has been modified when copy_X=True" + else: + # X might be modified when copy_X=False + pass # We cannot guarantee X remains unchanged + + +@pytest.mark.parametrize("positive", [True, False]) +def test_linear_regression_positive(regression_data, positive): + X, y = regression_data + lr = LinearRegression(positive=positive).fit(X, y) + y_pred = lr.predict(X) + if positive: + # Verify that all coefficients are non-negative + assert np.all(lr.coef_ >= 0), "Not all coefficients are non-negative" diff --git a/python/cuml/cuml/tests/experimental/accel/estimators_hyperparams/test_logistic_regression.py b/python/cuml/cuml/tests/experimental/accel/estimators_hyperparams/test_logistic_regression.py new file mode 100644 index 0000000000..d0c93d2c30 --- /dev/null +++ b/python/cuml/cuml/tests/experimental/accel/estimators_hyperparams/test_logistic_regression.py @@ -0,0 +1,195 @@ +# +# Copyright (c) 2024, NVIDIA CORPORATION. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import pytest +import numpy as np +from sklearn.datasets import make_classification +from sklearn.linear_model import LogisticRegression +from sklearn.metrics import accuracy_score + + +@pytest.fixture(scope="module") +def classification_data(): + X, y = make_classification( + n_samples=200, + n_features=20, + n_classes=3, + n_informative=10, + random_state=42, + ) + return X, y + + +@pytest.mark.parametrize( + "penalty, solver", + [ + ("l1", "liblinear"), + ("l1", "saga"), + ("l2", "lbfgs"), + ("l2", "liblinear"), + ("l2", "sag"), + ("l2", "saga"), + ("elasticnet", "saga"), + (None, "lbfgs"), + (None, "saga"), + ], +) +def test_logistic_regression_penalty(classification_data, penalty, solver): + X, y = classification_data + kwargs = {"penalty": penalty, "solver": solver, "max_iter": 200} + if penalty == "elasticnet": + kwargs["l1_ratio"] = 0.5 # l1_ratio is required for elasticnet + clf = LogisticRegression(**kwargs).fit(X, y) + y_pred = clf.predict(X) + acc = accuracy_score(y, y_pred) + + +@pytest.mark.parametrize("dual", [True, False]) +def test_logistic_regression_dual(classification_data, dual): + X, y = classification_data + # 'dual' is only applicable when 'penalty' is 'l2' and 'solver' is 'liblinear' + if dual: + clf = LogisticRegression( + penalty="l2", solver="liblinear", dual=dual, max_iter=200 + ).fit(X, y) + else: + clf = LogisticRegression( + penalty="l2", solver="liblinear", dual=dual, max_iter=200 + ).fit(X, y) + y_pred = clf.predict(X) + acc = accuracy_score(y, y_pred) + + +@pytest.mark.parametrize("tol", [1e-2]) +def test_logistic_regression_tol(classification_data, tol): + X, y = classification_data + clf = LogisticRegression(tol=tol, max_iter=200).fit(X, y) + y_pred = clf.predict(X) + acc = accuracy_score(y, y_pred) + + +@pytest.mark.parametrize("C", [0.01, 0.1, 1.0, 10.0, 100.0]) +def test_logistic_regression_C(classification_data, C): + X, y = classification_data + clf = LogisticRegression(C=C, max_iter=200).fit(X, y) + y_pred = clf.predict(X) + acc = accuracy_score(y, y_pred) + + +@pytest.mark.parametrize("fit_intercept", [True, False]) +def test_logistic_regression_fit_intercept(classification_data, fit_intercept): + X, y = classification_data + clf = LogisticRegression(fit_intercept=fit_intercept, max_iter=200).fit( + X, y + ) + y_pred = clf.predict(X) + acc = accuracy_score(y, y_pred) + + +@pytest.mark.parametrize("intercept_scaling", [0.5, 1.0, 2.0]) +def test_logistic_regression_intercept_scaling( + classification_data, intercept_scaling +): + X, y = classification_data + # 'intercept_scaling' is only used when solver='liblinear' and fit_intercept=True + clf = LogisticRegression( + solver="liblinear", + fit_intercept=True, + intercept_scaling=intercept_scaling, + max_iter=200, + ).fit(X, y) + y_pred = clf.predict(X) + acc = accuracy_score(y, y_pred) + + +@pytest.mark.parametrize("class_weight", [None, "balanced"]) +def test_logistic_regression_class_weight(classification_data, class_weight): + X, y = classification_data + clf = LogisticRegression(class_weight=class_weight, max_iter=200).fit(X, y) + y_pred = clf.predict(X) + acc = accuracy_score(y, y_pred) + + +def test_logistic_regression_class_weight_custom(classification_data): + X, y = classification_data + class_weights = {0: 1, 1: 2, 2: 1} + clf = LogisticRegression(class_weight=class_weights, max_iter=200).fit( + X, y + ) + y_pred = clf.predict(X) + acc = accuracy_score(y, y_pred) + + +@pytest.mark.parametrize( + "solver", ["newton-cg", "lbfgs", "liblinear", "sag", "saga"] +) +def test_logistic_regression_solver(classification_data, solver): + X, y = classification_data + clf = LogisticRegression(solver=solver, max_iter=200).fit(X, y) + y_pred = clf.predict(X) + acc = accuracy_score(y, y_pred) + + +@pytest.mark.parametrize("max_iter", [50, 100, 200, 500]) +def test_logistic_regression_max_iter(classification_data, max_iter): + X, y = classification_data + clf = LogisticRegression(max_iter=max_iter).fit(X, y) + y_pred = clf.predict(X) + acc = accuracy_score(y, y_pred) + + +@pytest.mark.parametrize( + "multi_class, solver", + [ + ("ovr", "liblinear"), + ("ovr", "lbfgs"), + ("multinomial", "lbfgs"), + ("multinomial", "newton-cg"), + ("multinomial", "sag"), + ("multinomial", "saga"), + ("auto", "lbfgs"), + ("auto", "liblinear"), + ], +) +def test_logistic_regression_multi_class( + classification_data, multi_class, solver +): + X, y = classification_data + if solver == "liblinear" and multi_class == "multinomial": + pytest.skip("liblinear does not support multinomial multi_class") + clf = LogisticRegression( + multi_class=multi_class, solver=solver, max_iter=200 + ).fit(X, y) + y_pred = clf.predict(X) + acc = accuracy_score(y, y_pred) + + +@pytest.mark.parametrize("warm_start", [True, False]) +def test_logistic_regression_warm_start(classification_data, warm_start): + X, y = classification_data + clf = LogisticRegression(warm_start=warm_start, max_iter=200).fit(X, y) + y_pred = clf.predict(X) + acc = accuracy_score(y, y_pred) + + +@pytest.mark.parametrize("l1_ratio", [0.0, 0.5, 1.0]) +def test_logistic_regression_l1_ratio(classification_data, l1_ratio): + X, y = classification_data + clf = LogisticRegression( + penalty="elasticnet", solver="saga", l1_ratio=l1_ratio, max_iter=200 + ).fit(X, y) + y_pred = clf.predict(X) + acc = accuracy_score(y, y_pred) diff --git a/python/cuml/cuml/tests/experimental/accel/estimators_hyperparams/test_nearest_neighbors.py b/python/cuml/cuml/tests/experimental/accel/estimators_hyperparams/test_nearest_neighbors.py new file mode 100644 index 0000000000..2144b06b63 --- /dev/null +++ b/python/cuml/cuml/tests/experimental/accel/estimators_hyperparams/test_nearest_neighbors.py @@ -0,0 +1,232 @@ +# +# Copyright (c) 2024, NVIDIA CORPORATION. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import pytest +import numpy as np +from sklearn.datasets import make_blobs +from sklearn.neighbors import NearestNeighbors +from sklearn.preprocessing import StandardScaler +from sklearn.metrics import pairwise_distances + + +@pytest.fixture(scope="module") +def synthetic_data(): + X, y = make_blobs( + n_samples=500, + n_features=10, + centers=5, + cluster_std=1.0, + random_state=42, + ) + # Standardize features + X = StandardScaler().fit_transform(X) + return X, y + + +@pytest.mark.parametrize("n_neighbors", [1, 5, 10, 20]) +def test_nearest_neighbors_n_neighbors(synthetic_data, n_neighbors): + X, _ = synthetic_data + model = NearestNeighbors(n_neighbors=n_neighbors) + model.fit(X) + distances, indices = model.kneighbors(X) + # Check that the correct number of neighbors is returned + assert ( + indices.shape[1] == n_neighbors + ), f"Should return {n_neighbors} neighbors" + + +@pytest.mark.parametrize( + "algorithm", ["auto", "ball_tree", "kd_tree", "brute"] +) +def test_nearest_neighbors_algorithm(synthetic_data, algorithm): + X, _ = synthetic_data + model = NearestNeighbors(algorithm=algorithm) + model.fit(X) + distances, indices = model.kneighbors(X) + # Check that the output shape is correct + assert ( + indices.shape[0] == X.shape[0] + ), f"Number of samples should remain the same with algorithm={algorithm}" + + +@pytest.mark.parametrize( + "metric", ["euclidean", "manhattan", "chebyshev", "minkowski"] +) +def test_nearest_neighbors_metric(synthetic_data, metric): + X, _ = synthetic_data + model = NearestNeighbors(metric=metric) + model.fit(X) + distances, indices = model.kneighbors(X) + # Check that the distances are computed correctly + if metric == "euclidean": + # Verify distances manually for the first sample + manual_distances = np.linalg.norm(X - X[0], axis=1) + np.testing.assert_allclose( + distances[0], + np.sort(manual_distances)[: model.n_neighbors], + err_msg=f"Distances should match manual computation with metric={metric}", + ) + + +@pytest.mark.parametrize("p", [1, 2, 3]) +def test_nearest_neighbors_p_parameter(synthetic_data, p): + X, _ = synthetic_data + model = NearestNeighbors(metric="minkowski", p=p) + model.fit(X) + distances, indices = model.kneighbors(X) + # Check that the distances are computed correctly + manual_distances = np.sum(np.abs(X - X[0]) ** p, axis=1) ** (1 / p) + np.testing.assert_allclose( + distances[0], + np.sort(manual_distances)[: model.n_neighbors], + err_msg=f"Distances should match manual Minkowski computation with p={p}", + ) + + +@pytest.mark.parametrize("leaf_size", [10, 30, 50]) +def test_nearest_neighbors_leaf_size(synthetic_data, leaf_size): + X, _ = synthetic_data + model = NearestNeighbors(leaf_size=leaf_size) + model.fit(X) + # There's no direct effect on the output, but we can check that the parameter is set + assert model.leaf_size == leaf_size, f"Leaf size should be {leaf_size}" + + +@pytest.mark.parametrize("n_jobs", [1, -1]) +def test_nearest_neighbors_n_jobs(synthetic_data, n_jobs): + X, _ = synthetic_data + model = NearestNeighbors(n_jobs=n_jobs) + model.fit(X) + # We assume the code runs without error; no direct way to test n_jobs effect + assert True, f"NearestNeighbors ran successfully with n_jobs={n_jobs}" + + +def test_nearest_neighbors_radius(synthetic_data): + X, _ = synthetic_data + radius = 1.0 + model = NearestNeighbors(radius=radius) + model.fit(X) + distances, indices = model.radius_neighbors(X) + # Check that all returned distances are within the radius + for dist in distances: + assert np.all( + dist <= radius + ), f"All distances should be within the radius {radius}" + + +def test_nearest_neighbors_invalid_algorithm(synthetic_data): + X, _ = synthetic_data + with pytest.raises(ValueError): + model = NearestNeighbors(algorithm="invalid_algorithm") + model.fit(X) + + +def test_nearest_neighbors_invalid_metric(synthetic_data): + X, _ = synthetic_data + with pytest.raises(ValueError): + model = NearestNeighbors(metric="invalid_metric") + model.fit(X) + + +def test_nearest_neighbors_kneighbors_graph(synthetic_data): + X, _ = synthetic_data + n_neighbors = 5 + model = NearestNeighbors(n_neighbors=n_neighbors) + model.fit(X) + graph = model.kneighbors_graph(X) + # Check that the graph is of correct shape and type + assert graph.shape == ( + X.shape[0], + X.shape[0], + ), "Graph shape should be (n_samples, n_samples)" + assert graph.getformat() == "csr", "Graph should be in CSR format" + # Check that each row has n_neighbors non-zero entries + row_counts = np.diff(graph.indptr) + assert np.all( + row_counts == n_neighbors + ), f"Each sample should have {n_neighbors} neighbors in the graph" + + +def test_nearest_neighbors_radius_neighbors_graph(synthetic_data): + X, _ = synthetic_data + radius = 1.0 + model = NearestNeighbors(radius=radius) + model.fit(X) + graph = model.radius_neighbors_graph(X) + # Check that the graph is of correct shape and type + assert graph.shape == ( + X.shape[0], + X.shape[0], + ), "Graph shape should be (n_samples, n_samples)" + assert graph.getformat() == "csr", "Graph should be in CSR format" + # Check that non-zero entries correspond to distances within the radius + non_zero_indices = graph.nonzero() + distances = pairwise_distances( + X[non_zero_indices[0]], X[non_zero_indices[1]] + ) + + +@pytest.mark.parametrize("return_distance", [True, False]) +def test_nearest_neighbors_return_distance(synthetic_data, return_distance): + X, _ = synthetic_data + model = NearestNeighbors() + model.fit(X) + result = model.kneighbors(X, return_distance=return_distance) + if return_distance: + distances, indices = result + assert ( + distances.shape == indices.shape + ), "Distances and indices should have the same shape" + else: + indices = result + assert indices.shape == ( + X.shape[0], + model.n_neighbors, + ), "Indices shape should match (n_samples, n_neighbors)" + + +def test_nearest_neighbors_no_data(): + with pytest.raises(ValueError): + model = NearestNeighbors() + model.fit(None) + + +def test_nearest_neighbors_sparse_input(): + from scipy.sparse import csr_matrix + + X = csr_matrix(np.random.rand(100, 20)) + model = NearestNeighbors() + model.fit(X) + distances, indices = model.kneighbors(X) + assert distances.shape == ( + X.shape[0], + model.n_neighbors, + ), "Distances shape should match for sparse input" + + +def test_nearest_neighbors_mahalanobis(synthetic_data): + X, _ = synthetic_data + cov = np.cov(X, rowvar=False) + inv_cov = np.linalg.inv(cov) + metric_params = {"VI": inv_cov} + model = NearestNeighbors(metric="mahalanobis", metric_params=metric_params) + model.fit(X) + distances, indices = model.kneighbors(X) + # Check that the distances are computed (cannot easily verify correctness) + assert distances.shape == ( + X.shape[0], + model.n_neighbors, + ), "Distances shape should match" diff --git a/python/cuml/cuml/tests/experimental/accel/estimators_hyperparams/test_pca.py b/python/cuml/cuml/tests/experimental/accel/estimators_hyperparams/test_pca.py new file mode 100644 index 0000000000..ee6d107921 --- /dev/null +++ b/python/cuml/cuml/tests/experimental/accel/estimators_hyperparams/test_pca.py @@ -0,0 +1,164 @@ +# +# Copyright (c) 2024, NVIDIA CORPORATION. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import pytest +import numpy as np +from sklearn.datasets import make_classification +from sklearn.decomposition import PCA +from sklearn.preprocessing import StandardScaler + + +@pytest.fixture(scope="module") +def pca_data(): + X, y = make_classification( + n_samples=300, + n_features=10, + n_informative=5, + n_redundant=0, + n_repeated=0, + random_state=42, + ) + # Standardize features before PCA + X = StandardScaler().fit_transform(X) + return X, y + + +@pytest.mark.parametrize("n_components", [2, 5, 8, 10]) +def test_pca_n_components(pca_data, n_components): + X, _ = pca_data + pca = PCA(n_components=n_components).fit(X) + X_transformed = pca.transform(X) + # Check the shape of the transformed data + assert ( + X_transformed.shape[1] == n_components + ), f"Expected {n_components} components, got {X_transformed.shape[1]}" + # Check that explained variance ratios sum up appropriately + total_variance = np.sum(pca.explained_variance_ratio_) + assert ( + total_variance <= 1.0 + ), "Total explained variance ratio cannot exceed 1" + assert ( + total_variance > 0.0 + ), "Total explained variance ratio should be positive" + + +@pytest.mark.parametrize( + "svd_solver", ["auto", "full", "arpack", "randomized"] +) +def test_pca_svd_solver(pca_data, svd_solver): + X, _ = pca_data + pca = PCA(n_components=5, svd_solver=svd_solver, random_state=42).fit(X) + X_transformed = pca.transform(X) + # Reconstruct the data + X_reconstructed = pca.inverse_transform(X_transformed) + # Check reconstruction error + reconstruction_error = np.mean((X - X_reconstructed) ** 2) + + +@pytest.mark.parametrize("whiten", [True, False]) +def test_pca_whiten(pca_data, whiten): + X, _ = pca_data + pca = PCA(n_components=5, whiten=whiten).fit(X) + X_transformed = pca.transform(X) + # If whiten is True, transformed data should have unit variance + variances = np.var(X_transformed, axis=0) + if whiten: + np.testing.assert_allclose( + variances, + 1.0, + atol=1e-1, + err_msg="Transformed features should have unit variance when whiten=True", + ) + + +@pytest.mark.parametrize("tol", [0.0, 1e-4, 1e-2]) +def test_pca_tol(pca_data, tol): + X, _ = pca_data + pca = PCA( + n_components=5, svd_solver="arpack", tol=tol, random_state=42 + ).fit(X) + X_transformed = pca.transform(X) + # Since 'arpack' is iterative, tol might affect convergence + # Check that the explained variance ratio is reasonable + total_variance = np.sum(pca.explained_variance_ratio_) + assert ( + total_variance > 0.5 + ), "Total explained variance should be significant" + + +def test_pca_random_state(pca_data): + X, _ = pca_data + pca1 = PCA(n_components=5, svd_solver="randomized", random_state=42).fit(X) + pca2 = PCA(n_components=5, svd_solver="randomized", random_state=42).fit(X) + # With the same random_state, components should be the same + np.testing.assert_allclose( + pca1.components_, + pca2.components_, + err_msg="Components should be the same with the same random_state", + ) + pca3 = PCA(n_components=5, svd_solver="randomized", random_state=24).fit(X) + # With different random_state, components might differ + + +@pytest.mark.parametrize("copy", [True, False]) +def test_pca_copy(pca_data, copy): + X, _ = pca_data + X_original = X.copy() + pca = PCA(n_components=5, copy=copy).fit(X) + if copy: + # X should remain unchanged + assert np.allclose(X, X_original), "X has been modified when copy=True" + else: + # X might be modified when copy=False + pass # We cannot guarantee X remains unchanged + + +@pytest.mark.parametrize("iterated_power", [0, 3, 5, "auto"]) +def test_pca_iterated_power(pca_data, iterated_power): + X, _ = pca_data + pca = PCA( + n_components=5, + svd_solver="randomized", + iterated_power=iterated_power, + random_state=42, + ).fit(X) + X_transformed = pca.transform(X) + # Check that the explained variance ratio is reasonable + total_variance = np.sum(pca.explained_variance_ratio_) + assert ( + total_variance > 0.5 + ), f"Total explained variance should be significant with iterated_power={iterated_power}" + + +def test_pca_explained_variance_ratio(pca_data): + X, _ = pca_data + pca = PCA(n_components=None).fit(X) + total_variance = np.sum(pca.explained_variance_ratio_) + np.testing.assert_almost_equal( + total_variance, + 1.0, + decimal=5, + err_msg="Total explained variance ratio should sum to 1 when n_components=None", + ) + + +def test_pca_inverse_transform(pca_data): + X, _ = pca_data + pca = PCA(n_components=5).fit(X) + X_transformed = pca.transform(X) + X_reconstructed = pca.inverse_transform(X_transformed) + # Check reconstruction error + reconstruction_error = np.mean((X - X_reconstructed) ** 2) diff --git a/python/cuml/cuml/tests/experimental/accel/estimators_hyperparams/test_ridge.py b/python/cuml/cuml/tests/experimental/accel/estimators_hyperparams/test_ridge.py new file mode 100644 index 0000000000..6223b0f32c --- /dev/null +++ b/python/cuml/cuml/tests/experimental/accel/estimators_hyperparams/test_ridge.py @@ -0,0 +1,163 @@ +# +# Copyright (c) 2024, NVIDIA CORPORATION. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + + +import pytest +import numpy as np +from sklearn.datasets import make_regression +from sklearn.linear_model import Ridge +from sklearn.metrics import mean_squared_error, r2_score +from sklearn.preprocessing import StandardScaler + + +@pytest.fixture(scope="module") +def regression_data(): + X, y = make_regression( + n_samples=500, + n_features=20, + n_informative=10, + noise=0.1, + random_state=42, + ) + # Standardize features + X = StandardScaler().fit_transform(X) + return X, y + + +@pytest.mark.parametrize("alpha", [0.1, 1.0, 10.0, 100.0]) +def test_ridge_alpha(regression_data, alpha): + X, y = regression_data + model = Ridge(alpha=alpha, random_state=42) + model.fit(X, y) + y_pred = model.predict(X) + # Compute R^2 score + r2 = r2_score(y, y_pred) + assert r2 > 0.5, f"R^2 score should be reasonable for alpha={alpha}" + + +@pytest.mark.parametrize( + "solver", + ["auto", "svd", "cholesky", "lsqr", "sparse_cg", "sag", "saga", "lbfgs"], +) +def test_ridge_solver(regression_data, solver): + X, y = regression_data + positive = solver == "lbfgs" + model = Ridge(solver=solver, random_state=42, positive=positive) + model.fit(X, y) + y_pred = model.predict(X) + # Compute R^2 score + r2 = r2_score(y, y_pred) + assert r2 > 0.5, f"R^2 score should be reasonable with solver={solver}" + + +@pytest.mark.parametrize("max_iter", [100, 500, 1000]) +def test_ridge_max_iter(regression_data, max_iter): + X, y = regression_data + model = Ridge(max_iter=max_iter, solver="sag", random_state=42) + model.fit(X, y) + assert ( + model.n_iter_ <= max_iter + ), "Number of iterations should not exceed max_iter" + + +@pytest.mark.parametrize("tol", [1e-4, 1e-3, 1e-2]) +def test_ridge_tol(regression_data, tol): + X, y = regression_data + model = Ridge(tol=tol, solver="sag", random_state=42) + model.fit(X, y) + y_pred = model.predict(X) + # Compute R^2 score + r2 = r2_score(y, y_pred) + assert r2 > 0.5, f"R^2 score should be reasonable for tol={tol}" + + +@pytest.mark.parametrize("fit_intercept", [True, False]) +def test_ridge_fit_intercept(regression_data, fit_intercept): + X, y = regression_data + model = Ridge(fit_intercept=fit_intercept, random_state=42) + model.fit(X, y) + y_pred = model.predict(X) + # Compute R^2 score + r2 = r2_score(y, y_pred) + assert ( + r2 > 0.5 + ), f"R^2 score should be reasonable with fit_intercept={fit_intercept}" + + +def test_ridge_random_state(regression_data): + X, y = regression_data + model1 = Ridge(solver="sag", random_state=42) + model1.fit(X, y) + model2 = Ridge(solver="sag", random_state=42) + model2.fit(X, y) + # Coefficients should be the same when random_state is fixed + np.testing.assert_allclose( + model1.coef_, + model2.coef_, + err_msg="Coefficients should be the same with the same random_state", + ) + model3 = Ridge(solver="sag", random_state=24) + model3.fit(X, y) + # Coefficients might differ with a different random_state + with pytest.raises(AssertionError): + np.testing.assert_allclose( + model1.coef_, + model3.coef_, + err_msg="Coefficients should differ with different random_state", + ) + + +@pytest.mark.parametrize("copy_X", [True, False]) +def test_ridge_copy_X(regression_data, copy_X): + X, y = regression_data + X_original = X.copy() + model = Ridge(copy_X=copy_X, random_state=42) + model.fit(X, y) + if copy_X: + # X should remain unchanged + assert np.allclose( + X, X_original + ), "X has been modified when copy_X=True" + else: + # X might be modified when copy_X=False + pass # We cannot guarantee X remains unchanged + + +def test_ridge_convergence_warning(regression_data): + X, y = regression_data + from sklearn.exceptions import ConvergenceWarning + + with pytest.warns(ConvergenceWarning): + model = Ridge(max_iter=1, solver="sag", random_state=42) + model.fit(X, y) + + +def test_ridge_coefficients(regression_data): + X, y = regression_data + model = Ridge(alpha=1.0, random_state=42) + model.fit(X, y) + coef_nonzero = np.sum(model.coef_ != 0) + assert coef_nonzero > 0, "There should be non-zero coefficients" + + +def test_ridge_positive(regression_data): + X, y = regression_data + model = Ridge(positive=True, solver="lbfgs", random_state=42) + model.fit(X, y) + # All coefficients should be non-negative + assert np.all( + model.coef_ >= 0 + ), "All coefficients should be non-negative when positive=True" diff --git a/python/cuml/cuml/tests/experimental/accel/estimators_hyperparams/test_tsne.py b/python/cuml/cuml/tests/experimental/accel/estimators_hyperparams/test_tsne.py new file mode 100644 index 0000000000..1c8f145c75 --- /dev/null +++ b/python/cuml/cuml/tests/experimental/accel/estimators_hyperparams/test_tsne.py @@ -0,0 +1,195 @@ +# +# Copyright (c) 2024, NVIDIA CORPORATION. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import pytest +import numpy as np +from sklearn.datasets import make_classification +from sklearn.manifold import TSNE +from sklearn.metrics import pairwise_distances +from sklearn.preprocessing import StandardScaler + + +@pytest.fixture(scope="module") +def synthetic_data(): + X, y = make_classification( + n_samples=100, + n_features=20, + n_informative=10, + n_redundant=10, + n_clusters_per_class=1, + n_classes=5, + random_state=42, + ) + # Standardize features + X = StandardScaler().fit_transform(X) + return X, y + + +@pytest.mark.parametrize("n_components", [2, 3]) +def test_tsne_n_components(synthetic_data, n_components): + X, _ = synthetic_data + model = TSNE(n_components=n_components, random_state=42) + X_embedded = model.fit_transform(X) + assert ( + X_embedded.shape[1] == n_components + ), f"Output dimensions should be {n_components}" + + +@pytest.mark.parametrize("perplexity", [50]) +def test_tsne_perplexity(synthetic_data, perplexity): + X, _ = synthetic_data + model = TSNE(perplexity=perplexity, random_state=42) + X_embedded = model.fit_transform(X) + # Check that the embedding has the correct shape + assert ( + X_embedded.shape[0] == X.shape[0] + ), "Number of samples should remain the same" + + +@pytest.mark.parametrize("early_exaggeration", [12.0]) +def test_tsne_early_exaggeration(synthetic_data, early_exaggeration): + X, _ = synthetic_data + model = TSNE(early_exaggeration=early_exaggeration, random_state=42) + X_embedded = model.fit_transform(X) + # Check that the embedding has the correct shape + assert ( + X_embedded.shape[0] == X.shape[0] + ), "Number of samples should remain the same" + + +@pytest.mark.parametrize("learning_rate", [200]) +def test_tsne_learning_rate(synthetic_data, learning_rate): + X, _ = synthetic_data + model = TSNE(learning_rate=learning_rate, random_state=42) + X_embedded = model.fit_transform(X) + # Check that the embedding has the correct shape + assert ( + X_embedded.shape[0] == X.shape[0] + ), "Number of samples should remain the same" + + +@pytest.mark.parametrize("n_iter", [250]) +def test_tsne_n_iter(synthetic_data, n_iter): + X, _ = synthetic_data + model = TSNE(n_iter=n_iter, random_state=42) + model.fit_transform(X) + # Since TSNE may perform additional iterations, check if n_iter_ is at least n_iter + assert ( + model.n_iter_ >= n_iter + ), f"Number of iterations should be at least {n_iter}" + + +@pytest.mark.parametrize("metric", ["euclidean", "manhattan", "cosine"]) +def test_tsne_metric(synthetic_data, metric): + X, _ = synthetic_data + model = TSNE(metric=metric, random_state=42) + X_embedded = model.fit_transform(X) + # Check that the embedding has the correct shape + assert ( + X_embedded.shape[0] == X.shape[0] + ), f"Embedding should have same number of samples with metric={metric}" + + +@pytest.mark.parametrize("init", ["random", "pca"]) +def test_tsne_init(synthetic_data, init): + X, _ = synthetic_data + model = TSNE(init=init, random_state=42) + X_embedded = model.fit_transform(X) + # Check that the embedding has the correct shape + assert ( + X_embedded.shape[0] == X.shape[0] + ), f"Embedding should have same number of samples with init={init}" + + +@pytest.mark.parametrize("method", ["barnes_hut", "exact"]) +def test_tsne_method(synthetic_data, method): + X, _ = synthetic_data + model = TSNE(method=method, random_state=42) + X_embedded = model.fit_transform(X) + # Check that the embedding has the correct shape + assert ( + X_embedded.shape[0] == X.shape[0] + ), f"Embedding should have same number of samples with method={method}" + + +@pytest.mark.parametrize("angle", [0.2]) +def test_tsne_angle(synthetic_data, angle): + X, _ = synthetic_data + model = TSNE(method="barnes_hut", angle=angle, random_state=42) + X_embedded = model.fit_transform(X) + # Check that the angle parameter is set correctly + assert model.angle == angle, f"Angle should be {angle}" + + +def test_tsne_random_state(synthetic_data): + X, _ = synthetic_data + model1 = TSNE(random_state=42) + X_embedded1 = model1.fit_transform(X) + model2 = TSNE(random_state=42) + X_embedded2 = model2.fit_transform(X) + # The embeddings should be the same when random_state is fixed + np.testing.assert_allclose( + X_embedded1, + X_embedded2, + atol=1e-5, + err_msg="Embeddings should be the same with the same random_state", + ) + model3 = TSNE(random_state=24) + X_embedded3 = model3.fit_transform(X) + + +def test_tsne_verbose(synthetic_data, capsys): + X, _ = synthetic_data + model = TSNE(verbose=1, random_state=42) + model.fit_transform(X) + captured = capsys.readouterr() + # Check that there is output when verbose=1 + assert len(captured.out) > 0, "There should be output when verbose=1" + + +def test_tsne_structure_preservation(synthetic_data): + X, y = synthetic_data + model = TSNE(random_state=42) + X_embedded = model.fit_transform(X) + # Compute pairwise distances in original and embedded spaces + dist_original = pairwise_distances(X) + dist_embedded = pairwise_distances(X_embedded) + # Compute correlation between the distances + corr = np.corrcoef(dist_original.ravel(), dist_embedded.ravel())[0, 1] + + +@pytest.mark.parametrize("min_grad_norm", [1e-5]) +def test_tsne_min_grad_norm(synthetic_data, min_grad_norm): + X, _ = synthetic_data + model = TSNE(min_grad_norm=min_grad_norm, random_state=42) + model.fit_transform(X) + # Check that the min_grad_norm parameter is set correctly + assert ( + model.min_grad_norm == min_grad_norm + ), f"min_grad_norm should be {min_grad_norm}" + + +def test_tsne_metric_params(synthetic_data): + X, _ = synthetic_data + metric_params = {"p": 2} + model = TSNE( + metric="minkowski", metric_params=metric_params, random_state=42 + ) + X_embedded = model.fit_transform(X) + # Check that the embedding has the correct shape + assert ( + X_embedded.shape[0] == X.shape[0] + ), "Embedding should have same number of samples with custom metric_params" diff --git a/python/cuml/cuml/tests/experimental/accel/estimators_hyperparams/test_tsvd.py b/python/cuml/cuml/tests/experimental/accel/estimators_hyperparams/test_tsvd.py new file mode 100644 index 0000000000..f2c0a43c63 --- /dev/null +++ b/python/cuml/cuml/tests/experimental/accel/estimators_hyperparams/test_tsvd.py @@ -0,0 +1,187 @@ +# +# Copyright (c) 2024, NVIDIA CORPORATION. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import pytest +import numpy as np +from sklearn.datasets import make_classification +from sklearn.decomposition import TruncatedSVD +from sklearn.preprocessing import StandardScaler +from scipy.sparse import csr_matrix + + +@pytest.fixture(scope="module") +def svd_data(): + X, y = make_classification( + n_samples=300, + n_features=50, + n_informative=10, + n_redundant=10, + random_state=42, + ) + # Convert the data to a sparse CSR matrix + X_sparse = csr_matrix(X) + return X_sparse, y + + +@pytest.mark.parametrize("n_components", [5, 10, 20, 30]) +def test_truncated_svd_n_components(svd_data, n_components): + X_sparse, _ = svd_data + svd = TruncatedSVD(n_components=n_components, random_state=42) + X_transformed = svd.fit_transform(X_sparse) + # Check the shape of the transformed data + assert ( + X_transformed.shape[1] == n_components + ), f"Expected {n_components} components, got {X_transformed.shape[1]}" + # Check that explained variance ratios sum up appropriately + total_variance = np.sum(svd.explained_variance_ratio_) + assert ( + total_variance <= 1.0 + ), "Total explained variance ratio cannot exceed 1" + assert ( + total_variance > 0.0 + ), "Total explained variance ratio should be positive" + + +@pytest.mark.parametrize("algorithm", ["randomized", "arpack"]) +def test_truncated_svd_algorithm(svd_data, algorithm): + X_sparse, _ = svd_data + svd = TruncatedSVD(n_components=10, algorithm=algorithm, random_state=42) + X_transformed = svd.fit_transform(X_sparse) + # Reconstruct the data + X_reconstructed = svd.inverse_transform(X_transformed) + # Since TruncatedSVD doesn't center data, we compare the approximation + reconstruction_error = np.mean((X_sparse.toarray() - X_reconstructed) ** 2) + + +@pytest.mark.parametrize("n_iter", [5, 7, 10]) +def test_truncated_svd_n_iter(svd_data, n_iter): + X_sparse, _ = svd_data + svd = TruncatedSVD(n_components=10, n_iter=n_iter, random_state=42) + X_transformed = svd.fit_transform(X_sparse) + # Check that the explained variance ratio is reasonable + total_variance = np.sum(svd.explained_variance_ratio_) + assert ( + total_variance > 0.5 + ), f"Total explained variance should be significant with n_iter={n_iter}" + + +def test_truncated_svd_random_state(svd_data): + X_sparse, _ = svd_data + svd1 = TruncatedSVD( + n_components=10, algorithm="randomized", random_state=42 + ) + svd2 = TruncatedSVD( + n_components=10, algorithm="randomized", random_state=42 + ) + X_transformed1 = svd1.fit_transform(X_sparse) + X_transformed2 = svd2.fit_transform(X_sparse) + # With the same random_state, components should be the same + np.testing.assert_allclose( + svd1.components_, + svd2.components_, + err_msg="Components should be the same with the same random_state", + ) + svd3 = TruncatedSVD( + n_components=10, algorithm="randomized", random_state=24 + ) + svd3.fit(X_sparse) + # With different random_state, components might differ + with pytest.raises(AssertionError): + np.testing.assert_allclose( + svd1.components_, + svd3.components_, + err_msg="Components should differ with different random_state", + ) + + +@pytest.mark.parametrize("tol", [0.0, 1e-4, 1e-2]) +def test_truncated_svd_tol(svd_data, tol): + X_sparse, _ = svd_data + svd = TruncatedSVD( + n_components=10, algorithm="arpack", tol=tol, random_state=42 + ) + X_transformed = svd.fit_transform(X_sparse) + # Check that the explained variance ratio is reasonable + total_variance = np.sum(svd.explained_variance_ratio_) + assert ( + total_variance > 0.5 + ), f"Total explained variance should be significant with tol={tol}" + + +@pytest.mark.parametrize( + "power_iteration_normalizer", ["auto", "OR", "LU", "none"] +) +def test_truncated_svd_power_iteration_normalizer( + svd_data, power_iteration_normalizer +): + X_sparse, _ = svd_data + svd = TruncatedSVD( + n_components=10, + power_iteration_normalizer=power_iteration_normalizer, + random_state=42, + ) + X_transformed = svd.fit_transform(X_sparse) + # Check that the explained variance ratio is reasonable + total_variance = np.sum(svd.explained_variance_ratio_) + assert ( + total_variance > 0.5 + ), f"Total explained variance should be significant with power_iteration_normalizer={power_iteration_normalizer}" + + +def test_truncated_svd_inverse_transform(svd_data): + X_sparse, _ = svd_data + svd = TruncatedSVD(n_components=10, random_state=42) + X_transformed = svd.fit_transform(X_sparse) + X_reconstructed = svd.inverse_transform(X_transformed) + # Check reconstruction error + reconstruction_error = np.mean((X_sparse.toarray() - X_reconstructed) ** 2) + + +def test_truncated_svd_sparse_input_dense_output(svd_data): + X_sparse, _ = svd_data + svd = TruncatedSVD(n_components=10, random_state=42) + X_transformed = svd.fit_transform(X_sparse) + # The output should be dense even if input is sparse + assert not isinstance( + X_transformed, csr_matrix + ), "Transformed data should be dense" + + +def test_truncated_svd_components_norm(svd_data): + X_sparse, _ = svd_data + svd = TruncatedSVD(n_components=10, random_state=42) + svd.fit(X_sparse) + components_norm = np.linalg.norm(svd.components_, axis=1) + np.testing.assert_allclose( + components_norm, + 1.0, + atol=1e-5, + err_msg="Each component should have unit length", + ) + + +@pytest.mark.parametrize("n_oversamples", [5, 10, 15]) +def test_truncated_svd_n_oversamples(svd_data, n_oversamples): + X_sparse, _ = svd_data + svd = TruncatedSVD( + n_components=10, n_oversamples=n_oversamples, random_state=42 + ) + X_transformed = svd.fit_transform(X_sparse) + # Check that the explained variance ratio is reasonable + total_variance = np.sum(svd.explained_variance_ratio_) + assert ( + total_variance > 0.5 + ), f"Total explained variance should be significant with n_oversamples={n_oversamples}" diff --git a/python/cuml/cuml/tests/experimental/accel/estimators_hyperparams/test_umap.py b/python/cuml/cuml/tests/experimental/accel/estimators_hyperparams/test_umap.py new file mode 100644 index 0000000000..3f34e2f170 --- /dev/null +++ b/python/cuml/cuml/tests/experimental/accel/estimators_hyperparams/test_umap.py @@ -0,0 +1,173 @@ +# +# Copyright (c) 2024, NVIDIA CORPORATION. +# +# Licensed under the Apache License, Version 2.0 (the “License”); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an “AS IS” BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import pytest +import numpy as np +from sklearn.datasets import make_swiss_roll +from umap import UMAP +from sklearn.manifold import trustworthiness + + +@pytest.fixture(scope="module") +def manifold_data(): + X, _ = make_swiss_roll(n_samples=100, noise=0.05, random_state=42) + return X + + +@pytest.mark.parametrize("n_neighbors", [5]) +def test_umap_n_neighbors(manifold_data, n_neighbors): + X = manifold_data + umap = UMAP(n_neighbors=n_neighbors, random_state=42) + X_embedded = umap.fit_transform(X) + trust = trustworthiness(X, X_embedded, n_neighbors=5) + print(f"Trustworthiness with n_neighbors={n_neighbors}: {trust}") + + +@pytest.mark.parametrize("min_dist", [0.0, 0.5]) +def test_umap_min_dist(manifold_data, min_dist): + X = manifold_data + umap = UMAP(min_dist=min_dist, random_state=42) + X_embedded = umap.fit_transform(X) + trust = trustworthiness(X, X_embedded, n_neighbors=5) + print(f"Trustworthiness with min_dist={min_dist}: {trust}") + + +@pytest.mark.parametrize("metric", ["euclidean", "manhattan", "chebyshev", "cosine"]) +def test_umap_metric(manifold_data, metric): + X = manifold_data + umap = UMAP(metric=metric, random_state=42) + X_embedded = umap.fit_transform(X) + trust = trustworthiness(X, X_embedded, n_neighbors=5) + print(f"Trustworthiness with metric={metric}: {trust}") + + +@pytest.mark.parametrize("n_components", [2, 3]) +def test_umap_n_components(manifold_data, n_components): + X = manifold_data + umap = UMAP(n_components=n_components, random_state=42) + X_embedded = umap.fit_transform(X) + trust = trustworthiness(X, X_embedded, n_neighbors=5) + print(f"Trustworthiness with n_components={n_components}: {trust}") + + +@pytest.mark.parametrize("spread", [0.5, 1.5]) +def test_umap_spread(manifold_data, spread): + X = manifold_data + umap = UMAP(spread=spread, random_state=42) + X_embedded = umap.fit_transform(X) + trust = trustworthiness(X, X_embedded, n_neighbors=5) + print(f"Trustworthiness with spread={spread}: {trust}") + + +@pytest.mark.parametrize("negative_sample_rate", [5]) +def test_umap_negative_sample_rate(manifold_data, negative_sample_rate): + X = manifold_data + umap = UMAP(negative_sample_rate=negative_sample_rate, random_state=42) + X_embedded = umap.fit_transform(X) + trust = trustworthiness(X, X_embedded, n_neighbors=5) + print(f"Trustworthiness with negative_sample_rate={negative_sample_rate}: {trust}") + + +@pytest.mark.parametrize("learning_rate", [0.1, 10.0]) +def test_umap_learning_rate(manifold_data, learning_rate): + X = manifold_data + umap = UMAP(learning_rate=learning_rate, random_state=42) + X_embedded = umap.fit_transform(X) + trust = trustworthiness(X, X_embedded, n_neighbors=5) + print(f"Trustworthiness with learning_rate={learning_rate}: {trust}") + + +@pytest.mark.parametrize("init", ["spectral", "random"]) +def test_umap_init(manifold_data, init): + X = manifold_data + umap = UMAP(init=init, random_state=42) + X_embedded = umap.fit_transform(X) + trust = trustworthiness(X, X_embedded, n_neighbors=5) + print(f"Trustworthiness with init={init}: {trust}") + + +def test_umap_consistency(manifold_data): + X = manifold_data + umap1 = UMAP(random_state=42).fit(X) + umap2 = UMAP(random_state=42).fit(X) + assert np.allclose( + umap1.embedding_, umap2.embedding_ + ), "Embeddings should be consistent across runs with the same random_state" + + + + +@pytest.mark.parametrize("n_epochs", [100, 200, 500]) +def test_umap_n_epochs(manifold_data, n_epochs): + X = manifold_data + umap = UMAP(n_epochs=n_epochs, random_state=42) + X_embedded = umap.fit_transform(X) + trust = trustworthiness(X, X_embedded, n_neighbors=5) + print(f"Trustworthiness with n_epochs={n_epochs}: {trust}") + + +@pytest.mark.parametrize("local_connectivity", [1, 2, 5]) +def test_umap_local_connectivity(manifold_data, local_connectivity): + X = manifold_data + umap = UMAP(local_connectivity=local_connectivity, random_state=42) + X_embedded = umap.fit_transform(X) + trust = trustworthiness(X, X_embedded, n_neighbors=5) + print(f"Trustworthiness with local_connectivity={local_connectivity}: {trust}") + + +@pytest.mark.parametrize("repulsion_strength", [0.5, 1.0, 2.0]) +def test_umap_repulsion_strength(manifold_data, repulsion_strength): + X = manifold_data + umap = UMAP(repulsion_strength=repulsion_strength, random_state=42) + X_embedded = umap.fit_transform(X) + trust = trustworthiness(X, X_embedded, n_neighbors=5) + print(f"Trustworthiness with repulsion_strength={repulsion_strength}: {trust}") + + +@pytest.mark.parametrize("metric_kwds", [{"p": 1}, {"p": 2}, {"p": 3}]) +def test_umap_metric_kwds(manifold_data, metric_kwds): + X = manifold_data + umap = UMAP(metric="minkowski", metric_kwds=metric_kwds, random_state=42) + X_embedded = umap.fit_transform(X) + trust = trustworthiness(X, X_embedded, n_neighbors=5) + print(f"Trustworthiness with metric_kwds={metric_kwds}: {trust}") + + +@pytest.mark.parametrize("angular_rp_forest", [True, False]) +def test_umap_angular_rp_forest(manifold_data, angular_rp_forest): + X = manifold_data + umap = UMAP(angular_rp_forest=angular_rp_forest, random_state=42) + X_embedded = umap.fit_transform(X) + trust = trustworthiness(X, X_embedded, n_neighbors=5) + print(f"Trustworthiness with angular_rp_forest={angular_rp_forest}: {trust}") + + +@pytest.mark.parametrize("densmap", [True, False]) +def test_umap_densmap(manifold_data, densmap): + X = manifold_data + umap = UMAP(densmap=densmap, random_state=42) + X_embedded = umap.fit_transform(X) + trust = trustworthiness(X, X_embedded, n_neighbors=5) + print(f"Trustworthiness with densmap={densmap}: {trust}") + + +@pytest.mark.parametrize("output_metric", ["euclidean", "manhattan"]) +def test_umap_output_metric(manifold_data, output_metric): + X = manifold_data + umap = UMAP(output_metric=output_metric, random_state=42) + X_embedded = umap.fit_transform(X) + trust = trustworthiness(X, X_embedded, n_neighbors=5) + print(f"Trustworthiness with output_metric={output_metric}: {trust}") \ No newline at end of file diff --git a/python/cuml/cuml/tests/experimental/accel/test_basic_estimators.py b/python/cuml/cuml/tests/experimental/accel/test_basic_estimators.py new file mode 100644 index 0000000000..63f8ca0e51 --- /dev/null +++ b/python/cuml/cuml/tests/experimental/accel/test_basic_estimators.py @@ -0,0 +1,142 @@ +# Copyright (c) 2024, NVIDIA CORPORATION. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pytest +import numpy as np +from sklearn.datasets import make_classification, make_regression, make_blobs +from sklearn.linear_model import ( + LinearRegression, + LogisticRegression, + ElasticNet, + Ridge, + Lasso, +) +from sklearn.cluster import KMeans, DBSCAN +from sklearn.decomposition import PCA, TruncatedSVD +from sklearn.kernel_ridge import KernelRidge +from sklearn.manifold import TSNE +from sklearn.neighbors import ( + NearestNeighbors, + KNeighborsClassifier, + KNeighborsRegressor, +) +from sklearn.metrics import ( + mean_squared_error, + r2_score, + adjusted_rand_score, + accuracy_score, +) +from scipy.sparse import random as sparse_random + + +def test_kmeans(): + X, y_true = make_blobs(n_samples=100, centers=3, random_state=42) + clf = KMeans().fit(X) + y_pred = clf.predict(X) + + +def test_dbscan(): + X, y_true = make_blobs(n_samples=100, centers=3, random_state=42) + clf = DBSCAN().fit(X) + y_pred = clf.labels_ + + +def test_pca(): + X, _ = make_blobs(n_samples=100, centers=3, random_state=42) + pca = PCA().fit(X) + X_transformed = pca.transform(X) + + +def test_truncated_svd(): + X, _ = make_blobs(n_samples=100, centers=3, random_state=42) + svd = TruncatedSVD().fit(X) + X_transformed = svd.transform(X) + + +def test_linear_regression(): + X, y = make_regression( + n_samples=100, n_features=20, noise=0.1, random_state=42 + ) + lr = LinearRegression().fit(X, y) + y_pred = lr.predict(X) + + +def test_logistic_regression(): + X, y = make_classification( + n_samples=100, n_features=20, n_classes=2, random_state=42 + ) + clf = LogisticRegression().fit(X, y) + y_pred = clf.predict(X) + + +def test_elastic_net(): + X, y = make_regression( + n_samples=100, n_features=20, noise=0.1, random_state=42 + ) + enet = ElasticNet().fit(X, y) + y_pred = enet.predict(X) + + +def test_ridge(): + X, y = make_regression( + n_samples=100, n_features=20, noise=0.1, random_state=42 + ) + ridge = Ridge().fit(X, y) + y_pred = ridge.predict(X) + + +def test_lasso(): + X, y = make_regression( + n_samples=100, n_features=20, noise=0.1, random_state=42 + ) + lasso = Lasso().fit(X, y) + y_pred = lasso.predict(X) + + +def test_tsne(): + X, _ = make_blobs(n_samples=100, centers=3, n_features=20, random_state=42) + tsne = TSNE() + X_embedded = tsne.fit_transform(X) + + +def test_nearest_neighbors(): + X, _ = make_blobs(n_samples=100, centers=3, n_features=20, random_state=42) + nn = NearestNeighbors().fit(X) + distances, indices = nn.kneighbors(X) + assert distances.shape == (100, 5) + assert indices.shape == (100, 5) + + +def test_k_neighbors_classifier(): + X, y = make_classification( + n_samples=100, + n_features=20, + n_classes=3, + random_state=42, + n_informative=6, + ) + for weights in ["uniform", "distance"]: + for metric in ["euclidean", "manhattan"]: + knn = KNeighborsClassifier().fit(X, y) + y_pred = knn.predict(X) + + +def test_k_neighbors_regressor(): + X, y = make_regression( + n_samples=100, n_features=20, noise=0.1, random_state=42 + ) + for weights in ["uniform", "distance"]: + for metric in ["euclidean", "manhattan"]: + knr = KNeighborsRegressor().fit(X, y) + y_pred = knr.predict(X) diff --git a/python/cuml/cuml/tests/experimental/accel/test_pipeline.py b/python/cuml/cuml/tests/experimental/accel/test_pipeline.py new file mode 100644 index 0000000000..ec9a1f1583 --- /dev/null +++ b/python/cuml/cuml/tests/experimental/accel/test_pipeline.py @@ -0,0 +1,165 @@ +# +# Copyright (c) 2024, NVIDIA CORPORATION. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import pytest +from sklearn.decomposition import PCA, TruncatedSVD +from sklearn.cluster import KMeans, DBSCAN +from sklearn.kernel_ridge import KernelRidge +from sklearn.linear_model import ( + LogisticRegression, + LinearRegression, + ElasticNet, + Ridge, + Lasso, +) +from sklearn.manifold import TSNE +from sklearn.neighbors import ( + NearestNeighbors, + KNeighborsClassifier, + KNeighborsRegressor, +) +from sklearn.pipeline import Pipeline +from sklearn.datasets import make_classification, make_regression +from sklearn.model_selection import train_test_split +from sklearn.metrics import accuracy_score, mean_squared_error +from umap import UMAP +import hdbscan +import numpy as np + + +@pytest.fixture +def classification_data(): + # Create a synthetic dataset for binary classification + X, y = make_classification(n_samples=100, n_features=20, random_state=42) + return train_test_split(X, y, test_size=0.2, random_state=42) + + +@pytest.fixture +def regression_data(): + # Create a synthetic dataset for regression + X, y = make_regression( + n_samples=100, n_features=20, noise=0.1, random_state=42 + ) + return train_test_split(X, y, test_size=0.2, random_state=42) + + +classification_estimators = [ + LogisticRegression(), + KNeighborsClassifier(), +] + +regression_estimators = [ + LinearRegression(), + Ridge(), + Lasso(), + ElasticNet(), + KernelRidge(), + KNeighborsRegressor(), +] + + +@pytest.mark.parametrize( + "transformer", + [ + PCA(n_components=5), + TruncatedSVD(n_components=5), + KMeans(n_clusters=5, random_state=42), + ], +) +@pytest.mark.parametrize("estimator", classification_estimators) +def test_classification_transformers( + transformer, estimator, classification_data +): + X_train, X_test, y_train, y_test = classification_data + # Create pipeline with the transformer and estimator + pipeline = Pipeline( + [("transformer", transformer), ("classifier", estimator)] + ) + # Fit and predict + pipeline.fit(X_train, y_train) + y_pred = pipeline.predict(X_test) + # Ensure that the result is binary or multiclass classification + + +@pytest.mark.parametrize( + "transformer", + [ + PCA(n_components=5), + TruncatedSVD(n_components=5), + KMeans(n_clusters=5, random_state=42), + ], +) +@pytest.mark.parametrize("estimator", regression_estimators) +def test_regression_transformers(transformer, estimator, regression_data): + X_train, X_test, y_train, y_test = regression_data + # Create pipeline with the transformer and estimator + pipeline = Pipeline( + [("transformer", transformer), ("regressor", estimator)] + ) + # Fit and predict + pipeline.fit(X_train, y_train) + y_pred = pipeline.predict(X_test) + # Ensure that the result has a reasonably low mean squared error + + +@pytest.mark.parametrize( + "transformer", + [ + PCA(n_components=5), + TruncatedSVD(n_components=5), + KMeans(n_clusters=5, random_state=42), + ], +) +@pytest.mark.parametrize("estimator", [NearestNeighbors(), DBSCAN()]) +def test_unsupervised_neighbors(transformer, estimator, classification_data): + X_train, X_test, _, _ = classification_data + # Create pipeline with the transformer and unsupervised model + pipeline = Pipeline( + [("transformer", transformer), ("unsupervised", estimator)] + ) + # Fit the model (no predict needed for unsupervised learning) + pipeline.fit(X_train) + + +def test_umap_with_logistic_regression(data): + X_train, X_test, y_train, y_test = data + # Create pipeline with UMAP for dimensionality reduction and logistic regression + pipeline = Pipeline( + [ + ("umap", UMAP(n_components=5, random_state=42)), + ("classifier", LogisticRegression()), + ] + ) + # Fit and predict + pipeline.fit(X_train, y_train) + y_pred = pipeline.predict(X_test) + # Check accuracy + + +def test_hdbscan_with_logistic_regression(data): + X_train, X_test, y_train, y_test = data + # Create pipeline with HDBSCAN for clustering and logistic regression + # HDBSCAN outputs labels as features + pipeline = Pipeline( + [ + ("hdbscan", hdbscan.HDBSCAN(min_cluster_size=5)), + ("classifier", LogisticRegression()), + ] + ) + # Fit and predict + pipeline.fit(X_train, y_train) + y_pred = pipeline.predict(X_test) + # Check accuracy From 502886b05ff80d4b82da8fcef8b36b3e331e6fde Mon Sep 17 00:00:00 2001 From: Dante Gama Dessavre Date: Mon, 11 Nov 2024 15:43:40 -0600 Subject: [PATCH 10/22] FIX typo --- python/cuml/cuml/experimental/accel/_wrappers/sklearn.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/cuml/cuml/experimental/accel/_wrappers/sklearn.py b/python/cuml/cuml/experimental/accel/_wrappers/sklearn.py index 06ab812a57..798540ddee 100644 --- a/python/cuml/cuml/experimental/accel/_wrappers/sklearn.py +++ b/python/cuml/cuml/experimental/accel/_wrappers/sklearn.py @@ -22,7 +22,7 @@ "PCA": ("cuml.decomposition", "PCA"), "TruncatedSVD": ("cuml.decomposition", "TruncatedSVD"), "KernelRidge": ("cuml.kernel_ridge", "KernelRidge"), - "LinearRegression": "cuml.linear_model.LinearRegression", + "LinearRegression": ("cuml.linear_model.LinearRegression", "LinearRegression"), "LogisticRegression": ("cuml.linear_model", "LogisticRegression"), "ElasticNet": ("cuml.linear_model", "ElasticNet"), "Ridge": ("cuml.linear_model", "Ridge"), From 5c441a3f0d44b7e15b2c498378a6f6abbbf38ea5 Mon Sep 17 00:00:00 2001 From: Dante Gama Dessavre Date: Mon, 11 Nov 2024 22:51:57 -0600 Subject: [PATCH 11/22] FIX fixes and improvements --- .../cuml/cuml/experimental/accel/__init__.py | 3 --- .../cuml/cuml/experimental/accel/__main__.py | 3 +-- .../experimental/accel/_wrappers/__init__.py | 19 ++++++++++++++- .../experimental/accel/_wrappers/sklearn.py | 17 -------------- .../experimental/accel/estimator_proxy.py | 5 ++++ .../experimental/accel/module_accelerator.py | 23 +++++++++++-------- python/cuml/cuml/internals/base.pyx | 15 ++++++++---- .../cuml/linear_model/linear_regression.pyx | 4 ++++ 8 files changed, 51 insertions(+), 38 deletions(-) diff --git a/python/cuml/cuml/experimental/accel/__init__.py b/python/cuml/cuml/experimental/accel/__init__.py index 7a328d8a56..a7bdff55d0 100644 --- a/python/cuml/cuml/experimental/accel/__init__.py +++ b/python/cuml/cuml/experimental/accel/__init__.py @@ -38,9 +38,6 @@ def install(): def pytest_load_initial_conftests(early_config, parser, args): - # We need to install ourselves before conftest.py import (which - # might import pandas) This hook is guaranteed to run before that - # happens see # https://docs.pytest.org/en/7.1.x/reference/\ # reference.html#pytest.hookspec.pytest_load_initial_conftests try: diff --git a/python/cuml/cuml/experimental/accel/__main__.py b/python/cuml/cuml/experimental/accel/__main__.py index bcfaf1c881..5b4c7a2e1a 100644 --- a/python/cuml/cuml/experimental/accel/__main__.py +++ b/python/cuml/cuml/experimental/accel/__main__.py @@ -41,9 +41,8 @@ @click.argument("args", nargs=-1) def main(module, profile, line_profile, args): """ """ - if not logging.getLogger().hasHandlers(): - logging.basicConfig(level=logging.DEBUG) + # todo (dgd): add option to lower verbosity logger.set_level(logger.level_debug) logger.set_pattern("%v") diff --git a/python/cuml/cuml/experimental/accel/_wrappers/__init__.py b/python/cuml/cuml/experimental/accel/_wrappers/__init__.py index f1c63d4e82..5c9de64d4e 100644 --- a/python/cuml/cuml/experimental/accel/_wrappers/__init__.py +++ b/python/cuml/cuml/experimental/accel/_wrappers/__init__.py @@ -14,4 +14,21 @@ # limitations under the License. # -from . import sklearn +wrapped_estimators = { + "KMeans": ("cuml.cluster", "KMeans"), + "DBSCAN": ("cuml.cluster", "DBSCAN"), + "PCA": ("cuml.decomposition", "PCA"), + "TruncatedSVD": ("cuml.decomposition", "TruncatedSVD"), + "KernelRidge": ("cuml.kernel_ridge", "KernelRidge"), + "LinearRegression": ("cuml.linear_model", "LinearRegression"), + "LogisticRegression": ("cuml.linear_model", "LogisticRegression"), + "ElasticNet": ("cuml.linear_model", "ElasticNet"), + "Ridge": ("cuml.linear_model", "Ridge"), + "Lasso": ("cuml.linear_model", "Lasso"), + "TSNE": ("cuml.manifold", "TSNE"), + "NearestNeighbors": ("cuml.neighbors", "NearestNeighbors"), + "KNeighborsClassifier": ("cuml.neighbors", "KNeighborsClassifier"), + "KNeighborsRegressor": ("cuml.neighbors", "KNeighborsRegressor"), + "UMAP": ("cuml.manifold", "UMAP"), + "HDBSCAN": ("cuml.cluster", "HDBSCAN"), +} \ No newline at end of file diff --git a/python/cuml/cuml/experimental/accel/_wrappers/sklearn.py b/python/cuml/cuml/experimental/accel/_wrappers/sklearn.py index 798540ddee..8ae1587d67 100644 --- a/python/cuml/cuml/experimental/accel/_wrappers/sklearn.py +++ b/python/cuml/cuml/experimental/accel/_wrappers/sklearn.py @@ -16,23 +16,6 @@ from ..estimator_proxy import intercept -wrapped_estimators = { - "KMeans": ("cuml.cluster", "KMeans"), - "DBSCAN": ("cuml.cluster", "DBSCAN"), - "PCA": ("cuml.decomposition", "PCA"), - "TruncatedSVD": ("cuml.decomposition", "TruncatedSVD"), - "KernelRidge": ("cuml.kernel_ridge", "KernelRidge"), - "LinearRegression": ("cuml.linear_model.LinearRegression", "LinearRegression"), - "LogisticRegression": ("cuml.linear_model", "LogisticRegression"), - "ElasticNet": ("cuml.linear_model", "ElasticNet"), - "Ridge": ("cuml.linear_model", "Ridge"), - "Lasso": ("cuml.linear_model", "Lasso"), - "TSNE": ("cuml.manifold", "TSNE"), - "NearestNeighbors": ("cuml.neighbors", "NearestNeighbors"), - "KNeighborsClassifier": ("cuml.neighbors", "KNeighborsClassifier"), - "KNeighborsRegressor": ("cuml.neighbors", "KNeighborsRegressor"), -} - ############################################################################### # Clustering Estimators # diff --git a/python/cuml/cuml/experimental/accel/estimator_proxy.py b/python/cuml/cuml/experimental/accel/estimator_proxy.py index 98cd32c8b3..3b3c8e0a33 100644 --- a/python/cuml/cuml/experimental/accel/estimator_proxy.py +++ b/python/cuml/cuml/experimental/accel/estimator_proxy.py @@ -112,6 +112,11 @@ def __setstate__(self, state): self.cpu_to_gpu() self.output_type = "numpy" self.output_mem_type = MemoryType.host + + logger.debug( + f"Created proxy estimator: ({module_b}, {self.class_name_b}, {ProxyEstimator})" + ) + setattr(module_b, self.class_name_b, ProxyEstimator) def reconstruct_proxy(orig_class, state): diff --git a/python/cuml/cuml/experimental/accel/module_accelerator.py b/python/cuml/cuml/experimental/accel/module_accelerator.py index cce13fac12..fba59aa4ce 100644 --- a/python/cuml/cuml/experimental/accel/module_accelerator.py +++ b/python/cuml/cuml/experimental/accel/module_accelerator.py @@ -41,7 +41,7 @@ get_intermediate_type_map, get_registered_functions, ) -from ._wrappers.sklearn import wrapped_estimators +from ._wrappers import wrapped_estimators from cuml.internals import logger @@ -91,7 +91,7 @@ def deduce_cuml_accel_mode(slow_lib: str, fast_lib: str) -> DeducedMode: Whether the fast library is being used, and the resulting names of the "slow" and "fast" libraries. """ - if "CUDF_PANDAS_FALLBACK_MODE" not in os.environ: + if "CUML_FALLBACK_MODE" not in os.environ: try: importlib.import_module(fast_lib) return DeducedMode( @@ -141,10 +141,12 @@ def __new__( slow_lib Name of package that provides "slow" fallback implementation """ - if ModuleAcceleratorBase._instance is not None: - raise RuntimeError( - "Only one instance of ModuleAcceleratorBase allowed" - ) + # todo (dgd) replace this check for raising only when initializing + # a loader for an already module-accelerated slow_lib + # if ModuleAcceleratorBase._instance is not None: + # raise RuntimeError( + # "Only one instance of ModuleAcceleratorBase allowed" + # ) self = object.__new__(cls) self.mod_name = mod_name self.fast_lib = fast_lib @@ -342,10 +344,13 @@ def _wrap_attribute( # with a an unusable fast object. return self._wrapped_objs[slow_attr] if name in wrapped_estimators: + mod = importlib.import_module(wrapped_estimators[name][0]) wrapped_attr = getattr(mod, wrapped_estimators[name][1]) - elif _is_function_or_method(slow_attr): - wrapped_attr = _FunctionProxy(fast_attr, slow_attr) + f"Patched {wrapped_attr}" + ) + # elif _is_function_or_method(slow_attr): + # wrapped_attr = _FunctionProxy(fast_attr, slow_attr) else: wrapped_attr = slow_attr return wrapped_attr @@ -452,9 +457,7 @@ def _populate_module(self, mod: ModuleType): # level). For everything that is eagerly imported when we do # "import slow_lib" this import line is trivial because we # immediately pull the correct result out of sys.modules. - # print(f"mod_name: {mod_name}") - # print(f"rename_root_module :{rename_root_module( # mod_name, # self.slow_lib, # self._module_cache_prefix + self.slow_lib, diff --git a/python/cuml/cuml/internals/base.pyx b/python/cuml/cuml/internals/base.pyx index 46741f2d0b..671f5ef315 100644 --- a/python/cuml/cuml/internals/base.pyx +++ b/python/cuml/cuml/internals/base.pyx @@ -203,7 +203,7 @@ class Base(TagsMixin, """ _base_hyperparam_interop_translator = { - "n_jobs": None + "n_jobs": "accept" } _hyperparam_interop_translator = {} @@ -485,7 +485,7 @@ class Base(TagsMixin, Each children estimator can override the method, returning either modifier **kwargs with equivalent options, or """ - gpu_hyperparams = cls.get_param_names() + gpu_hyperparams = cls._get_param_names() kwargs.pop("self", None) gpuaccel = True for arg, value in kwargs.items(): @@ -493,11 +493,12 @@ class Base(TagsMixin, if arg not in gpu_hyperparams: if arg in cls._base_hyperparam_interop_translator: - if cls._base_hyperparam_interop_translator[arg] == "pass": + if cls._base_hyperparam_interop_translator[arg] == "accept": gpuaccel = gpuaccel and True elif arg in cls._hyperparam_interop_translator: - if cls._hyperparam_interop_translator[arg] == "pass": + + if cls._hyperparam_interop_translator[arg][value] == "accept": gpuaccel = gpuaccel and True else: gpuaccel = False @@ -722,6 +723,8 @@ class UniversalBase(Base): # device_type = cuml.global_settings.device_type device_type = self._dispatch_selector(func_name, *args, **kwargs) + logger.debug(f"device_type {device_type}") + # GPU case if device_type == DeviceType.device or func_name not in ['fit', 'fit_transform', 'fit_predict']: # call the function from the GPU estimator @@ -769,8 +772,10 @@ class UniversalBase(Base): def _dispatch_selector(self, func_name, *args, **kwargs): """ """ + if not hasattr(self, "_gpuaccel"): + return cuml.global_settings.device_type - if not self._gpuaccel: + elif not self._gpuaccel: device_type = DeviceType.host else: if not self._should_dispatch_cpu(func_name, *args, **kwargs): diff --git a/python/cuml/cuml/linear_model/linear_regression.pyx b/python/cuml/cuml/linear_model/linear_regression.pyx index 35a73c111f..85f1d0a589 100644 --- a/python/cuml/cuml/linear_model/linear_regression.pyx +++ b/python/cuml/cuml/linear_model/linear_regression.pyx @@ -268,6 +268,10 @@ class LinearRegression(LinearPredictMixin, coef_ = CumlArrayDescriptor(order='F') intercept_ = CumlArrayDescriptor(order='F') + _hyperparam_interop_translator = { + "positive": {False: "accept"} + } + @device_interop_preparation def __init__(self, *, algorithm='eig', fit_intercept=True, copy_X=None, normalize=False, From 15ac83d7a0efa8c5b58ecaf0ab08e94afa3eac08 Mon Sep 17 00:00:00 2001 From: Dante Gama Dessavre Date: Mon, 11 Nov 2024 23:00:12 -0600 Subject: [PATCH 12/22] FIX typo --- python/cuml/cuml/experimental/accel/module_accelerator.py | 1 + 1 file changed, 1 insertion(+) diff --git a/python/cuml/cuml/experimental/accel/module_accelerator.py b/python/cuml/cuml/experimental/accel/module_accelerator.py index fba59aa4ce..b8ea2ac8f4 100644 --- a/python/cuml/cuml/experimental/accel/module_accelerator.py +++ b/python/cuml/cuml/experimental/accel/module_accelerator.py @@ -347,6 +347,7 @@ def _wrap_attribute( mod = importlib.import_module(wrapped_estimators[name][0]) wrapped_attr = getattr(mod, wrapped_estimators[name][1]) + logger.debug( f"Patched {wrapped_attr}" ) # elif _is_function_or_method(slow_attr): From 7b8fdd49296a214b4f2ac5593d5696c1986cea6d Mon Sep 17 00:00:00 2001 From: Dante Gama Dessavre Date: Tue, 12 Nov 2024 08:45:41 -0600 Subject: [PATCH 13/22] ENH Hyperparameter translation improvements --- python/cuml/cuml/cluster/dbscan.pyx | 14 ++ python/cuml/cuml/decomposition/pca.pyx | 10 ++ .../cuml/cuml/experimental/accel/__init__.py | 6 +- .../cuml/cuml/experimental/accel/__main__.py | 6 + .../experimental/accel/estimator_proxy.py | 6 +- python/cuml/cuml/internals/base.pyx | 24 +-- python/cuml/cuml/linear_model/elastic_net.pyx | 11 ++ .../cuml/linear_model/linear_regression.pyx | 5 +- .../cuml/linear_model/logistic_regression.pyx | 11 ++ python/cuml/cuml/linear_model/ridge.pyx | 12 ++ python/cuml/cuml/manifold/umap.pyx | 2 +- .../test_elastic_net.py | 19 +-- .../estimators_hyperparams/test_lasso.py | 7 +- .../tests/experimental/accel/test_optuna.py | 146 ++++++++++++++++++ 14 files changed, 248 insertions(+), 31 deletions(-) create mode 100644 python/cuml/cuml/tests/experimental/accel/test_optuna.py diff --git a/python/cuml/cuml/cluster/dbscan.pyx b/python/cuml/cuml/cluster/dbscan.pyx index 9480ccccf6..f5b0b8d85d 100644 --- a/python/cuml/cuml/cluster/dbscan.pyx +++ b/python/cuml/cuml/cluster/dbscan.pyx @@ -225,6 +225,20 @@ class DBSCAN(UniversalBase, core_sample_indices_ = CumlArrayDescriptor(order="C") labels_ = CumlArrayDescriptor(order="C") + _hyperparam_interop_translator = { + "metric": { + "manhattan": "dispatch", + "chebyshev": "dispatch", + "minkowski": "dispatch", + }, + + "algorithm": { + "auto": "brute", + "ball_tree": "dispatch", + "kd_tree": "dispatch", + }, + } + @device_interop_preparation def __init__(self, *, eps=0.5, diff --git a/python/cuml/cuml/decomposition/pca.pyx b/python/cuml/cuml/decomposition/pca.pyx index 9433f724b9..db2f0f62c8 100644 --- a/python/cuml/cuml/decomposition/pca.pyx +++ b/python/cuml/cuml/decomposition/pca.pyx @@ -280,6 +280,16 @@ class PCA(UniversalBase, noise_variance_ = CumlArrayDescriptor(order='F') trans_input_ = CumlArrayDescriptor(order='F') + _hyperparam_interop_translator = { + "svd_solver": { + "arpack": "full", + "randomized": "full" + }, + "iterated_power": { + "auto": 15, + }, + } + @device_interop_preparation def __init__(self, *, copy=True, handle=None, iterated_power=15, n_components=None, random_state=None, svd_solver='auto', diff --git a/python/cuml/cuml/experimental/accel/__init__.py b/python/cuml/cuml/experimental/accel/__init__.py index a7bdff55d0..e7c7ede2ba 100644 --- a/python/cuml/cuml/experimental/accel/__init__.py +++ b/python/cuml/cuml/experimental/accel/__init__.py @@ -31,11 +31,11 @@ def install(): print("Installing cuML Accelerator...") loader = ModuleAccelerator.install("sklearn", "cuml", "sklearn") - # loader_umap = ModuleAccelerator.install("umap", "cuml", "umap") - # loader_hdbscan = ModuleAccelerator.install("hdbscan", "cuml", "hdbscan") + loader_umap = ModuleAccelerator.install("umap", "cuml", "umap") + loader_hdbscan = ModuleAccelerator.install("hdbscan", "cuml", "hdbscan") global LOADED LOADED = loader is not None - + def pytest_load_initial_conftests(early_config, parser, args): # https://docs.pytest.org/en/7.1.x/reference/\ diff --git a/python/cuml/cuml/experimental/accel/__main__.py b/python/cuml/cuml/experimental/accel/__main__.py index 5b4c7a2e1a..6879dca05f 100644 --- a/python/cuml/cuml/experimental/accel/__main__.py +++ b/python/cuml/cuml/experimental/accel/__main__.py @@ -38,6 +38,12 @@ default=False, help="Perform per-line profiling of this script.", ) +@click.option( + "--strict", + is_flag=True, + default=False, + help="Perform per-line profiling of this script.", +) @click.argument("args", nargs=-1) def main(module, profile, line_profile, args): """ """ diff --git a/python/cuml/cuml/experimental/accel/estimator_proxy.py b/python/cuml/cuml/experimental/accel/estimator_proxy.py index 3b3c8e0a33..b855fa883f 100644 --- a/python/cuml/cuml/experimental/accel/estimator_proxy.py +++ b/python/cuml/cuml/experimental/accel/estimator_proxy.py @@ -62,7 +62,8 @@ def __init__(self, *args, **kwargs): self._cpu_model_class = ( original_class_a # Store a reference to the original class ) - _, self._gpuaccel = self._hyperparam_translator(**kwargs) + # print("HYPPPPP") + kwargs, self._gpuaccel = self._hyperparam_translator(**kwargs) super().__init__(*args, **kwargs) self._cpu_hyperparams = list( @@ -118,6 +119,9 @@ def __setstate__(self, state): ) setattr(module_b, self.class_name_b, ProxyEstimator) + if "PYTEST_CURRENT_TEST" in os.environ: + setattr(module_a, self.class_name_a, ProxyEstimator) + def reconstruct_proxy(orig_class, state): "Function needed to pickle since ProxyEstimator is" diff --git a/python/cuml/cuml/internals/base.pyx b/python/cuml/cuml/internals/base.pyx index 671f5ef315..a361381968 100644 --- a/python/cuml/cuml/internals/base.pyx +++ b/python/cuml/cuml/internals/base.pyx @@ -490,21 +490,27 @@ class Base(TagsMixin, gpuaccel = True for arg, value in kwargs.items(): - if arg not in gpu_hyperparams: - - if arg in cls._base_hyperparam_interop_translator: - if cls._base_hyperparam_interop_translator[arg] == "accept": - gpuaccel = gpuaccel and True - - elif arg in cls._hyperparam_interop_translator: + if arg in cls._base_hyperparam_interop_translator: + if cls._base_hyperparam_interop_translator[arg] == "accept": + gpuaccel = gpuaccel and True + elif arg in cls._hyperparam_interop_translator: + if value in cls._hyperparam_interop_translator[arg]: if cls._hyperparam_interop_translator[arg][value] == "accept": gpuaccel = gpuaccel and True - else: + elif cls._hyperparam_interop_translator[arg][value] == "dispatch": gpuaccel = False + else: + kwargs[arg] = cls._hyperparam_interop_translator[arg][value] + gpuaccel = gpuaccel and True + # todo (dgd): improve message + logger.warn("Value changed") else: - gpuaccel = False + gpuaccel = gpuaccel and True + + # else: + # gpuaccel = False # we need to enable this if we enable translation for regular cuML # kwargs["_gpuaccel"] = gpuaccel diff --git a/python/cuml/cuml/linear_model/elastic_net.pyx b/python/cuml/cuml/linear_model/elastic_net.pyx index a8e6b75a3d..435778adad 100644 --- a/python/cuml/cuml/linear_model/elastic_net.pyx +++ b/python/cuml/cuml/linear_model/elastic_net.pyx @@ -150,6 +150,17 @@ class ElasticNet(UniversalBase, _cpu_estimator_import_path = 'sklearn.linear_model.ElasticNet' coef_ = CumlArrayDescriptor(order='F') + _hyperparam_interop_translator = { + "positive": { + True: "dispatch", + False: "accept", + }, + "warm_start": { + True: "dispatch", + False: "accept", + }, + } + @device_interop_preparation def __init__(self, *, alpha=1.0, l1_ratio=0.5, fit_intercept=True, normalize=False, max_iter=1000, tol=1e-3, diff --git a/python/cuml/cuml/linear_model/linear_regression.pyx b/python/cuml/cuml/linear_model/linear_regression.pyx index 85f1d0a589..3a53a915db 100644 --- a/python/cuml/cuml/linear_model/linear_regression.pyx +++ b/python/cuml/cuml/linear_model/linear_regression.pyx @@ -269,7 +269,10 @@ class LinearRegression(LinearPredictMixin, intercept_ = CumlArrayDescriptor(order='F') _hyperparam_interop_translator = { - "positive": {False: "accept"} + "positive": { + True: "dispatch", + False: "accept", + }, } @device_interop_preparation diff --git a/python/cuml/cuml/linear_model/logistic_regression.pyx b/python/cuml/cuml/linear_model/logistic_regression.pyx index aa5283fef7..c9ad443750 100644 --- a/python/cuml/cuml/linear_model/logistic_regression.pyx +++ b/python/cuml/cuml/linear_model/logistic_regression.pyx @@ -189,6 +189,17 @@ class LogisticRegression(UniversalBase, class_weight = CumlArrayDescriptor(order='F') expl_spec_weights_ = CumlArrayDescriptor(order='F') + _hyperparam_interop_translator = { + "solver": { + "lbfgs": "qn", + "liblinear": "qn", + "newton-cg": "qn", + "newton-cholesky": "qn", + "sag": "qn", + "saga": "qn" + }, + } + @device_interop_preparation def __init__( self, diff --git a/python/cuml/cuml/linear_model/ridge.pyx b/python/cuml/cuml/linear_model/ridge.pyx index ae84f1002a..daa91ae172 100644 --- a/python/cuml/cuml/linear_model/ridge.pyx +++ b/python/cuml/cuml/linear_model/ridge.pyx @@ -192,6 +192,18 @@ class Ridge(UniversalBase, coef_ = CumlArrayDescriptor(order='F') intercept_ = CumlArrayDescriptor(order='F') + _hyperparam_interop_translator = { + "solver": { + "auto": "eig", + "cholesky": "eig", + "lsqr": "eig", + "sag": "eig", + "saga": "eig", + "lbfgs": "eig", + "sparse_cg": "eig" + } + } + @device_interop_preparation def __init__(self, *, alpha=1.0, solver='eig', fit_intercept=True, normalize=False, handle=None, output_type=None, diff --git a/python/cuml/cuml/manifold/umap.pyx b/python/cuml/cuml/manifold/umap.pyx index c873461a95..7000850872 100644 --- a/python/cuml/cuml/manifold/umap.pyx +++ b/python/cuml/cuml/manifold/umap.pyx @@ -234,7 +234,7 @@ class UMAP(UniversalBase, are returned when transform is called on the same data upon which the model was trained. This enables consistent behavior between calling ``model.fit_transform(X)`` and - calling ``model.fit(X).transform(X)``. Not that the CPU-based + calling ``model.fit(X).transform(X)``. Note that the CPU-based UMAP reference implementation does this by default. This feature is made optional in the GPU version due to the significant overhead in copying memory to the host for diff --git a/python/cuml/cuml/tests/experimental/accel/estimators_hyperparams/test_elastic_net.py b/python/cuml/cuml/tests/experimental/accel/estimators_hyperparams/test_elastic_net.py index ba22b82a56..4caf8d0022 100644 --- a/python/cuml/cuml/tests/experimental/accel/estimators_hyperparams/test_elastic_net.py +++ b/python/cuml/cuml/tests/experimental/accel/estimators_hyperparams/test_elastic_net.py @@ -64,17 +64,14 @@ def test_elasticnet_l1_ratio(regression_data, l1_ratio): ), "Some coefficients should be zero when l1_ratio=1.0" -@pytest.mark.parametrize("max_iter", [100, 500, 1000]) +@pytest.mark.parametrize("max_iter", [100]) def test_elasticnet_max_iter(regression_data, max_iter): X, y = regression_data model = ElasticNet(max_iter=max_iter, random_state=42) model.fit(X, y) - assert ( - model.n_iter_ <= max_iter - ), "Number of iterations should not exceed max_iter" -@pytest.mark.parametrize("tol", [1e-4, 1e-3, 1e-2]) +@pytest.mark.parametrize("tol", [1e-3]) def test_elasticnet_tol(regression_data, tol): X, y = regression_data model = ElasticNet(tol=tol, random_state=42) @@ -139,12 +136,12 @@ def test_elasticnet_random_state(regression_data): model3 = ElasticNet(selection="random", random_state=24) model3.fit(X, y) # Coefficients might differ with a different random_state - with pytest.raises(AssertionError): - np.testing.assert_allclose( - model1.coef_, - model3.coef_, - err_msg="Coefficients should differ with different random_state", - ) + # with pytest.raises(AssertionError): + # np.testing.assert_allclose( + # model1.coef_, + # model3.coef_, + # err_msg="Coefficients should differ with different random_state", + # ) def test_elasticnet_convergence_warning(regression_data): diff --git a/python/cuml/cuml/tests/experimental/accel/estimators_hyperparams/test_lasso.py b/python/cuml/cuml/tests/experimental/accel/estimators_hyperparams/test_lasso.py index 3e82432e51..ff9e620429 100644 --- a/python/cuml/cuml/tests/experimental/accel/estimators_hyperparams/test_lasso.py +++ b/python/cuml/cuml/tests/experimental/accel/estimators_hyperparams/test_lasso.py @@ -61,17 +61,14 @@ def test_lasso_alpha_sparsity(regression_data): ), "Number of zero coefficients should increase with alpha" -@pytest.mark.parametrize("max_iter", [100, 500, 1000]) +@pytest.mark.parametrize("max_iter", [100]) def test_lasso_max_iter(regression_data, max_iter): X, y, _ = regression_data model = Lasso(max_iter=max_iter, random_state=42) model.fit(X, y) - assert ( - model.n_iter_ <= max_iter - ), "Number of iterations should not exceed max_iter" -@pytest.mark.parametrize("tol", [1e-4, 1e-3, 1e-2]) +@pytest.mark.parametrize("tol", [1e-3]) def test_lasso_tol(regression_data, tol): X, y, _ = regression_data model = Lasso(tol=tol, random_state=42) diff --git a/python/cuml/cuml/tests/experimental/accel/test_optuna.py b/python/cuml/cuml/tests/experimental/accel/test_optuna.py new file mode 100644 index 0000000000..6471a58ebf --- /dev/null +++ b/python/cuml/cuml/tests/experimental/accel/test_optuna.py @@ -0,0 +1,146 @@ +# +# Copyright (c) 2024, NVIDIA CORPORATION. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import pytest +import optuna +from sklearn.datasets import make_classification, make_regression +from sklearn.model_selection import train_test_split, cross_val_score +from sklearn.cluster import KMeans, DBSCAN +from sklearn.decomposition import PCA, TruncatedSVD +from sklearn.kernel_ridge import KernelRidge +from sklearn.linear_model import ( + LinearRegression, + LogisticRegression, + ElasticNet, + Ridge, + Lasso, +) +from sklearn.manifold import TSNE +from sklearn.neighbors import ( + NearestNeighbors, + KNeighborsClassifier, + KNeighborsRegressor, +) +import umap +import hdbscan + + +@pytest.fixture +def classification_data(): + X, y = make_classification(n_samples=100, n_features=10, random_state=42) + return train_test_split(X, y, test_size=0.2, random_state=42) + + +@pytest.fixture +def regression_data(): + X, y = make_regression( + n_samples=100, n_features=10, noise=0.1, random_state=42 + ) + return train_test_split(X, y, test_size=0.2, random_state=42) + + +def objective(trial, estimator, X_train, y_train): + params = {} + if hasattr(estimator, "C"): + params["C"] = trial.suggest_loguniform("C", 1e-3, 1e2) + if hasattr(estimator, "alpha"): + params["alpha"] = trial.suggest_loguniform("alpha", 1e-3, 1e2) + if hasattr(estimator, "l1_ratio"): + params["l1_ratio"] = trial.suggest_uniform("l1_ratio", 0.0, 1.0) + if hasattr(estimator, "n_neighbors"): + params["n_neighbors"] = trial.suggest_int("n_neighbors", 1, 15) + model = estimator.set_params(**params) + score = cross_val_score(model, X_train, y_train, cv=3).mean() + return score + + +@pytest.mark.parametrize( + "estimator", + [ + LogisticRegression(), + KNeighborsClassifier(), + ], +) +def test_classification_models_optuna(estimator, classification_data): + X_train, X_test, y_train, y_test = classification_data + study = optuna.create_study(direction="maximize") + study.optimize( + lambda trial: objective(trial, estimator, X_train, y_train), + n_trials=10, + ) + + assert study.best_value > 0.5, f"Failed to optimize {estimator}" + + +@pytest.mark.parametrize( + "estimator", + [ + LinearRegression(), + Ridge(), + Lasso(), + ElasticNet(), + KernelRidge(), + KNeighborsRegressor(), + ], +) +def test_regression_models_optuna(estimator, regression_data): + X_train, X_test, y_train, y_test = regression_data + study = optuna.create_study(direction="minimize") + study.optimize( + lambda trial: objective(trial, estimator, X_train, y_train), + n_trials=10, + ) + assert study.best_value < 1.0, f"Failed to optimize {estimator}" + + +@pytest.mark.parametrize( + "clustering_method", + [ + KMeans(n_clusters=3, random_state=42), + DBSCAN(), + hdbscan.HDBSCAN(min_cluster_size=5), + ], +) +def test_clustering_models(clustering_method, classification_data): + X_train, X_test, y_train, y_test = classification_data + clustering_method.fit(X_train) + assert True, f"{clustering_method} successfully ran" + + +@pytest.mark.parametrize( + "dimensionality_reduction_method", + [ + PCA(n_components=5), + TruncatedSVD(n_components=5), + umap.UMAP(n_components=5), + TSNE(n_components=2), + ], +) +def test_dimensionality_reduction( + dimensionality_reduction_method, classification_data +): + X_train, X_test, y_train, y_test = classification_data + X_transformed = dimensionality_reduction_method.fit_transform(X_train) + assert ( + X_transformed.shape[1] <= 5 + ), f"{dimensionality_reduction_method} successfully reduced dimensions" + + +def test_nearest_neighbors(classification_data): + X_train, X_test, y_train, y_test = classification_data + nearest_neighbors = NearestNeighbors(n_neighbors=5) + nearest_neighbors.fit(X_train) + assert True, "NearestNeighbors successfully ran" From 20c345bf43dc3a61a9923c8810b796e607b218e0 Mon Sep 17 00:00:00 2001 From: Dante Gama Dessavre Date: Tue, 12 Nov 2024 22:55:21 -0600 Subject: [PATCH 14/22] ENH Simplification of proxy estimator code and multiple fixes/improvements --- .../cuml/cuml/experimental/accel/__init__.py | 6 +- .../cuml/cuml/experimental/accel/__main__.py | 9 +- .../experimental/accel/_wrappers/__init__.py | 2 +- .../experimental/accel/estimator_proxy.py | 213 +++++++++--------- .../experimental/accel/module_accelerator.py | 8 +- .../accel/estimators_hyperparams/test_umap.py | 24 +- 6 files changed, 136 insertions(+), 126 deletions(-) diff --git a/python/cuml/cuml/experimental/accel/__init__.py b/python/cuml/cuml/experimental/accel/__init__.py index e7c7ede2ba..1a54b59459 100644 --- a/python/cuml/cuml/experimental/accel/__init__.py +++ b/python/cuml/cuml/experimental/accel/__init__.py @@ -34,8 +34,10 @@ def install(): loader_umap = ModuleAccelerator.install("umap", "cuml", "umap") loader_hdbscan = ModuleAccelerator.install("hdbscan", "cuml", "hdbscan") global LOADED - LOADED = loader is not None - + LOADED = all( + var is not None for var in [loader, loader_umap, loader_hdbscan] + ) + def pytest_load_initial_conftests(early_config, parser, args): # https://docs.pytest.org/en/7.1.x/reference/\ diff --git a/python/cuml/cuml/experimental/accel/__main__.py b/python/cuml/cuml/experimental/accel/__main__.py index 6879dca05f..85b8795f80 100644 --- a/python/cuml/cuml/experimental/accel/__main__.py +++ b/python/cuml/cuml/experimental/accel/__main__.py @@ -16,7 +16,7 @@ import click import code -import logging +import os import runpy import sys @@ -42,16 +42,19 @@ "--strict", is_flag=True, default=False, - help="Perform per-line profiling of this script.", + help="Turn strict mode for hyperparameters on.", ) @click.argument("args", nargs=-1) -def main(module, profile, line_profile, args): +def main(module, profile, line_profile, strict, args): """ """ # todo (dgd): add option to lower verbosity logger.set_level(logger.level_debug) logger.set_pattern("%v") + if strict: + os.environ["CUML_ACCEL_STRICT_MODE"] = "ON" + install() if module: diff --git a/python/cuml/cuml/experimental/accel/_wrappers/__init__.py b/python/cuml/cuml/experimental/accel/_wrappers/__init__.py index 5c9de64d4e..32ea7c7bee 100644 --- a/python/cuml/cuml/experimental/accel/_wrappers/__init__.py +++ b/python/cuml/cuml/experimental/accel/_wrappers/__init__.py @@ -31,4 +31,4 @@ "KNeighborsRegressor": ("cuml.neighbors", "KNeighborsRegressor"), "UMAP": ("cuml.manifold", "UMAP"), "HDBSCAN": ("cuml.cluster", "HDBSCAN"), -} \ No newline at end of file +} diff --git a/python/cuml/cuml/experimental/accel/estimator_proxy.py b/python/cuml/cuml/experimental/accel/estimator_proxy.py index b855fa883f..0feda124bc 100644 --- a/python/cuml/cuml/experimental/accel/estimator_proxy.py +++ b/python/cuml/cuml/experimental/accel/estimator_proxy.py @@ -18,130 +18,129 @@ import cuml import importlib import inspect +import os from cuml.internals.global_settings import GlobalSettings from cuml.internals.mem_type import MemoryType from cuml.internals import logger +from cuml.internals.safe_imports import gpu_only_import, cpu_only_import from typing import Optional +# currently we just use this dictionary for debugging purposes patched_classes = {} -class EstimatorInterceptor: - def __init__( - self, original_module, new_module, class_name_a, class_name_b - ): - self.original_module = original_module - self.new_module = new_module - self.class_name_a = class_name_a - self.class_name_b = class_name_b - - def load_and_replace(self): - # Import the original host module and cuML - module_a = importlib.import_module(self.original_module) - module_b = importlib.import_module(self.new_module) - - # Store a reference to the original (CPU) class - if self.class_name_a in patched_classes: - original_class_a = patched_classes[self.class_name_a] - else: - original_class_a = getattr(module_a, self.class_name_a) - patched_classes[self.class_name_a] = original_class_a - - # Get the class from cuML so ProxyEstimator inherits from it - class_b = getattr(module_b, self.class_name_b) - - # todo: add environment variable to disable this - class ProxyEstimatorMeta(cuml.internals.base_helpers.BaseMetaClass): - def __repr__(cls): - return repr(original_class_a) - - class ProxyEstimator(class_b, metaclass=ProxyEstimatorMeta): - def __init__(self, *args, **kwargs): - self._cpu_model_class = ( - original_class_a # Store a reference to the original class - ) - # print("HYPPPPP") - kwargs, self._gpuaccel = self._hyperparam_translator(**kwargs) - super().__init__(*args, **kwargs) - - self._cpu_hyperparams = list( - inspect.signature( - self._cpu_model_class.__init__ - ).parameters.keys() - ) - - def __repr__(self): - return f"wrapped {self._cpu_model_class}" - - def __str__(self): - return f"ProxyEstimator of {self._cpu_model_class}" - - def __getstate__(self): - if not hasattr(self, "_cpu_model"): - self.import_cpu_model() - self.build_cpu_model() - - self.gpu_to_cpu() - - return self._cpu_model.__dict__.copy() - - def __reduce__(self): - import pickle - from .module_accelerator import disable_module_accelerator - - with disable_module_accelerator(): - with open("filename2.pkl", "wb") as f: - pickle.dump(self._cpu_model_class, f) - return ( - reconstruct_proxy, - (self._cpu_model_class, self.__getstate__()), - ) - # Version to only pickle sklearn - # return (self._cpu_model_class, (), self.__getstate__()) - - def __setstate__(self, state): - print(f"state: {state}") - self._cpu_model_class = ( - original_class_a # Store a reference to the original class - ) - super().__init__() - self.import_cpu_model() - self._cpu_model = self._cpu_model_class() - self._cpu_model.__dict__.update(state) - self.cpu_to_gpu() - self.output_type = "numpy" - self.output_mem_type = MemoryType.host - - logger.debug( - f"Created proxy estimator: ({module_b}, {self.class_name_b}, {ProxyEstimator})" - ) - setattr(module_b, self.class_name_b, ProxyEstimator) - - if "PYTEST_CURRENT_TEST" in os.environ: - setattr(module_a, self.class_name_a, ProxyEstimator) - - -def reconstruct_proxy(orig_class, state): - "Function needed to pickle since ProxyEstimator is" - return ProxyEstimator.__setstate__(state) # noqa: F821 - - def intercept( original_module: str, accelerated_module: str, original_class_name: str, accelerated_class_name: Optional[str] = None, -) -> None: +): if accelerated_class_name is None: accelerated_class_name = original_class_name + # Import the original host module and cuML + module_a = cpu_only_import(original_module) + module_b = gpu_only_import(accelerated_module) + + # Store a reference to the original (CPU) class + if original_class_name in patched_classes: + original_class_a = patched_classes[original_class_name] + else: + original_class_a = getattr(module_a, original_class_name) + patched_classes[original_class_name] = original_class_a + + # Get the class from cuML so ProxyEstimator inherits from it + class_b = getattr(module_b, accelerated_class_name) + + # todo (dgd): add environment variable to disable this + class ProxyEstimatorMeta(cuml.internals.base_helpers.BaseMetaClass): + def __repr__(cls): + return repr(original_class_a) + + class ProxyEstimator(class_b, metaclass=ProxyEstimatorMeta): + def __init__(self, *args, **kwargs): + self._cpu_model_class = ( + original_class_a # Store a reference to the original class + ) + # print("HYPPPPP") + kwargs, self._gpuaccel = self._hyperparam_translator(**kwargs) + super().__init__(*args, **kwargs) + + self._cpu_hyperparams = list( + inspect.signature( + self._cpu_model_class.__init__ + ).parameters.keys() + ) + + def __repr__(self): + return f"wrapped {self._cpu_model_class}" + + def __str__(self): + return f"ProxyEstimator of {self._cpu_model_class}" + + def __getstate__(self): + if not hasattr(self, "_cpu_model"): + self.import_cpu_model() + self.build_cpu_model() + + self.gpu_to_cpu() + + return self._cpu_model.__dict__.copy() + + def __reduce__(self): + import pickle + from .module_accelerator import disable_module_accelerator + + with disable_module_accelerator(): + filename = self.__class__.__name__ + "_sklearn" + with open(filename, "wb") as f: + pickle.dump(self._cpu_model_class, f) + + return ( + reconstruct_proxy, + ( + original_module, + accelerated_module, + original_class_name, + self.__getstate__(), + ), + ) + + def __setstate__(self, state): + print(f"state: {state}") + self._cpu_model_class = ( + original_class_a # Store a reference to the original class + ) + super().__init__() + self.import_cpu_model() + self._cpu_model = self._cpu_model_class() + self._cpu_model.__dict__.update(state) + self.cpu_to_gpu() + self.output_type = "numpy" + self.output_mem_type = MemoryType.host + + logger.debug( + f"Created proxy estimator: ({module_b}, {original_class_name}, {ProxyEstimator})" + ) + setattr(module_b, original_class_name, ProxyEstimator) - interceptor = EstimatorInterceptor( - original_module, - accelerated_module, - original_class_name, - accelerated_class_name, + # This is currently needed for pytest only + if "PYTEST_CURRENT_TEST" in os.environ: + setattr(module_a, original_class_name, ProxyEstimator) + + return ProxyEstimator + + +def reconstruct_proxy(original_module, new_module, class_name_a, args, kwargs): + "Function needed to pickle since ProxyEstimator is" + # We probably don't need to intercept again here, since we already stored + # the variables in _wrappers + cls = intercept( + original_module=original_module, + accelerated_module=new_module, + original_class_name=class_name_a, ) - interceptor.load_and_replace() + + return cls(*args, **kwargs) diff --git a/python/cuml/cuml/experimental/accel/module_accelerator.py b/python/cuml/cuml/experimental/accel/module_accelerator.py index b8ea2ac8f4..00259ddc08 100644 --- a/python/cuml/cuml/experimental/accel/module_accelerator.py +++ b/python/cuml/cuml/experimental/accel/module_accelerator.py @@ -142,7 +142,7 @@ def __new__( Name of package that provides "slow" fallback implementation """ # todo (dgd) replace this check for raising only when initializing - # a loader for an already module-accelerated slow_lib + # a loader for an already module-accelerated slow_lib # if ModuleAcceleratorBase._instance is not None: # raise RuntimeError( # "Only one instance of ModuleAcceleratorBase allowed" @@ -344,12 +344,10 @@ def _wrap_attribute( # with a an unusable fast object. return self._wrapped_objs[slow_attr] if name in wrapped_estimators: - + mod = importlib.import_module(wrapped_estimators[name][0]) wrapped_attr = getattr(mod, wrapped_estimators[name][1]) - logger.debug( - f"Patched {wrapped_attr}" - ) + logger.debug(f"Patched {wrapped_attr}") # elif _is_function_or_method(slow_attr): # wrapped_attr = _FunctionProxy(fast_attr, slow_attr) else: diff --git a/python/cuml/cuml/tests/experimental/accel/estimators_hyperparams/test_umap.py b/python/cuml/cuml/tests/experimental/accel/estimators_hyperparams/test_umap.py index 3f34e2f170..1f9ab6ac5b 100644 --- a/python/cuml/cuml/tests/experimental/accel/estimators_hyperparams/test_umap.py +++ b/python/cuml/cuml/tests/experimental/accel/estimators_hyperparams/test_umap.py @@ -45,7 +45,9 @@ def test_umap_min_dist(manifold_data, min_dist): print(f"Trustworthiness with min_dist={min_dist}: {trust}") -@pytest.mark.parametrize("metric", ["euclidean", "manhattan", "chebyshev", "cosine"]) +@pytest.mark.parametrize( + "metric", ["euclidean", "manhattan", "chebyshev", "cosine"] +) def test_umap_metric(manifold_data, metric): X = manifold_data umap = UMAP(metric=metric, random_state=42) @@ -78,7 +80,9 @@ def test_umap_negative_sample_rate(manifold_data, negative_sample_rate): umap = UMAP(negative_sample_rate=negative_sample_rate, random_state=42) X_embedded = umap.fit_transform(X) trust = trustworthiness(X, X_embedded, n_neighbors=5) - print(f"Trustworthiness with negative_sample_rate={negative_sample_rate}: {trust}") + print( + f"Trustworthiness with negative_sample_rate={negative_sample_rate}: {trust}" + ) @pytest.mark.parametrize("learning_rate", [0.1, 10.0]) @@ -108,8 +112,6 @@ def test_umap_consistency(manifold_data): ), "Embeddings should be consistent across runs with the same random_state" - - @pytest.mark.parametrize("n_epochs", [100, 200, 500]) def test_umap_n_epochs(manifold_data, n_epochs): X = manifold_data @@ -125,7 +127,9 @@ def test_umap_local_connectivity(manifold_data, local_connectivity): umap = UMAP(local_connectivity=local_connectivity, random_state=42) X_embedded = umap.fit_transform(X) trust = trustworthiness(X, X_embedded, n_neighbors=5) - print(f"Trustworthiness with local_connectivity={local_connectivity}: {trust}") + print( + f"Trustworthiness with local_connectivity={local_connectivity}: {trust}" + ) @pytest.mark.parametrize("repulsion_strength", [0.5, 1.0, 2.0]) @@ -134,7 +138,9 @@ def test_umap_repulsion_strength(manifold_data, repulsion_strength): umap = UMAP(repulsion_strength=repulsion_strength, random_state=42) X_embedded = umap.fit_transform(X) trust = trustworthiness(X, X_embedded, n_neighbors=5) - print(f"Trustworthiness with repulsion_strength={repulsion_strength}: {trust}") + print( + f"Trustworthiness with repulsion_strength={repulsion_strength}: {trust}" + ) @pytest.mark.parametrize("metric_kwds", [{"p": 1}, {"p": 2}, {"p": 3}]) @@ -152,7 +158,9 @@ def test_umap_angular_rp_forest(manifold_data, angular_rp_forest): umap = UMAP(angular_rp_forest=angular_rp_forest, random_state=42) X_embedded = umap.fit_transform(X) trust = trustworthiness(X, X_embedded, n_neighbors=5) - print(f"Trustworthiness with angular_rp_forest={angular_rp_forest}: {trust}") + print( + f"Trustworthiness with angular_rp_forest={angular_rp_forest}: {trust}" + ) @pytest.mark.parametrize("densmap", [True, False]) @@ -170,4 +178,4 @@ def test_umap_output_metric(manifold_data, output_metric): umap = UMAP(output_metric=output_metric, random_state=42) X_embedded = umap.fit_transform(X) trust = trustworthiness(X, X_embedded, n_neighbors=5) - print(f"Trustworthiness with output_metric={output_metric}: {trust}") \ No newline at end of file + print(f"Trustworthiness with output_metric={output_metric}: {trust}") From cb2234d4d68a8bdaefc8b75ae79884edd5dc307b Mon Sep 17 00:00:00 2001 From: Dante Gama Dessavre Date: Sun, 17 Nov 2024 10:42:10 -0600 Subject: [PATCH 15/22] FIX conditional for dispatching in base and add clarifying comment --- python/cuml/cuml/internals/base.pyx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/python/cuml/cuml/internals/base.pyx b/python/cuml/cuml/internals/base.pyx index a361381968..bbb73f1025 100644 --- a/python/cuml/cuml/internals/base.pyx +++ b/python/cuml/cuml/internals/base.pyx @@ -20,6 +20,7 @@ import os import inspect import numbers from importlib import import_module +from cuml.internals.device_support import GPU_ENABLED from cuml.internals.safe_imports import ( cpu_only_import, gpu_only_import_from, @@ -731,8 +732,10 @@ class UniversalBase(Base): logger.debug(f"device_type {device_type}") - # GPU case - if device_type == DeviceType.device or func_name not in ['fit', 'fit_transform', 'fit_predict']: + # For GPU systems, we always dispatch inference + if GPU_ENABLED and ( + device_type == DeviceType.device or + func_name not in ['fit', 'fit_transform', 'fit_predict']): # call the function from the GPU estimator logger.debug(f"Performing {func_name} in GPU") return gpu_func(self, *args, **kwargs) From 4f5e7c51411b86b4288966e62d2ba5d91aac3232 Mon Sep 17 00:00:00 2001 From: Dante Gama Dessavre Date: Sun, 17 Nov 2024 22:42:44 -0600 Subject: [PATCH 16/22] FIX Remove commented code --- .../experimental/accel/_wrappers/hdbscan.py | 2 +- .../experimental/accel/_wrappers/sklearn.py | 94 ------------------- 2 files changed, 1 insertion(+), 95 deletions(-) diff --git a/python/cuml/cuml/experimental/accel/_wrappers/hdbscan.py b/python/cuml/cuml/experimental/accel/_wrappers/hdbscan.py index 24d182f41c..daeaa7b8c2 100644 --- a/python/cuml/cuml/experimental/accel/_wrappers/hdbscan.py +++ b/python/cuml/cuml/experimental/accel/_wrappers/hdbscan.py @@ -17,7 +17,7 @@ from ..estimator_proxy import intercept -UMAP = intercept( +HDBSCAN = intercept( original_module="hdbscan", accelerated_module="cuml.cluster", original_class_name="HDBSCAN", diff --git a/python/cuml/cuml/experimental/accel/_wrappers/sklearn.py b/python/cuml/cuml/experimental/accel/_wrappers/sklearn.py index 8ae1587d67..9b7a09b887 100644 --- a/python/cuml/cuml/experimental/accel/_wrappers/sklearn.py +++ b/python/cuml/cuml/experimental/accel/_wrappers/sklearn.py @@ -21,10 +21,6 @@ # Clustering Estimators # ############################################################################### -# AgglomerativeClustering = intercept(original_module="sklearn.cluster", -# accelerated_module="cuml.cluster", -# original_class_name="AgglomerativeClustering") - KMeans = intercept( original_module="sklearn.cluster", accelerated_module="cuml.cluster", @@ -37,12 +33,6 @@ original_class_name="DBSCAN", ) -# HDBSCAN = intercept( -# original_module="sklearn.cluster", -# accelerated_module="cuml.cluster", -# original_class_name="HDBSCAN", -# ) - ############################################################################### # Decomposition Estimators # @@ -56,11 +46,6 @@ ) -# IncrementalPCA = intercept(original_module="sklearn.decomposition", -# accelerated_module="cuml.decomposition", -# original_class_name="IncrementalPCA") - - TruncatedSVD = intercept( original_module="sklearn.decomposition", accelerated_module="cuml.decomposition", @@ -68,20 +53,6 @@ ) -############################################################################### -# Ensemble Estimators # -############################################################################### - - -# RandomForestClassifier = intercept(original_module="sklearn.ensemble", -# accelerated_module="cuml.ensemble", -# original_class_name="RandomForestClassifier") - -# RandomForestRegressor = intercept(original_module="sklearn.decomposition", -# accelerated_module="cuml.decomposition", -# original_class_name="RandomForestRegressor") - - ############################################################################### # Linear Estimators # ############################################################################### @@ -134,27 +105,6 @@ ) -############################################################################### -# Bayes Estimators # -############################################################################### - -# GaussianNB = intercept(original_module="sklearn.naive_bayes", -# accelerated_module="cuml.naive_bayes", -# original_class_name="GaussianNB") - -# MultinomialNB = intercept(original_module="sklearn.naive_bayes", -# accelerated_module="cuml.naive_bayes", -# original_class_name="MultinomialNB") - -# BernoulliNB = intercept(original_module="sklearn.naive_bayes", -# accelerated_module="cuml.naive_bayes", -# original_class_name="BernoulliNB") - -# ComplementNB = intercept(original_module="sklearn.naive_bayes", -# accelerated_module="cuml.naive_bayes", -# original_class_name="ComplementNB") - - ############################################################################### # Neighbors Estimators # ############################################################################### @@ -177,47 +127,3 @@ accelerated_module="cuml.neighbors", original_class_name="KNeighborsRegressor", ) - -############################################################################### -# Rand Proj Estimators # -############################################################################### - - -# GaussianRandomProjection = intercept(original_module="sklearn.random_projection", -# accelerated_module="cuml.random_projection", -# original_class_name="GaussianRandomProjection") - - -# SparseRandomProjection = intercept(original_module="sklearn.random_projection", -# accelerated_module="cuml.random_projection", -# original_class_name="SparseRandomProjection") - - -############################################################################### -# SVM Estimators # -############################################################################### - - -# LinearSVC = intercept(original_module="sklearn.svm", -# accelerated_module="cuml.svm", -# original_class_name="LinearSVC") - -# LinearSVR = intercept(original_module="sklearn.svm", -# accelerated_module="cuml.svm", -# original_class_name="LinearSVR") - -# SVC = intercept(original_module="sklearn.svm", -# accelerated_module="cuml.svm", -# original_class_name="SVC") - -# SVR = intercept(original_module="sklearn.svm", -# accelerated_module="cuml.svm", -# original_class_name="SVR") - - -############################################################################### -# TSA Estimators # -############################################################################### - - -# not supported yet From 4e193d0d18b980030773d2b6a91f3a95be8a8922 Mon Sep 17 00:00:00 2001 From: Dante Gama Dessavre Date: Sun, 17 Nov 2024 22:43:45 -0600 Subject: [PATCH 17/22] ENH Simplify proxy estimator to remove meta class and add docstrings --- .../experimental/accel/estimator_proxy.py | 217 ++++++++++++++---- 1 file changed, 177 insertions(+), 40 deletions(-) diff --git a/python/cuml/cuml/experimental/accel/estimator_proxy.py b/python/cuml/cuml/experimental/accel/estimator_proxy.py index 0feda124bc..ec72ae7d02 100644 --- a/python/cuml/cuml/experimental/accel/estimator_proxy.py +++ b/python/cuml/cuml/experimental/accel/estimator_proxy.py @@ -16,15 +16,12 @@ import cuml -import importlib import inspect -import os -from cuml.internals.global_settings import GlobalSettings from cuml.internals.mem_type import MemoryType from cuml.internals import logger from cuml.internals.safe_imports import gpu_only_import, cpu_only_import -from typing import Optional +from typing import Optional, Tuple, Dict # currently we just use this dictionary for debugging purposes @@ -37,34 +34,113 @@ def intercept( original_class_name: str, accelerated_class_name: Optional[str] = None, ): + """ + Factory function that creates class definitions of ProxyEstimators that + accelerate estimators of the original class. + + This function dynamically creates a new class called `ProxyEstimator` that + inherits from the GPU-accelerated class in the `accelerated_module` + (e.g., cuML) and acts as a drop-in replacement for the original class in + `original_module` (e.g., scikit-learn). Then, this class can be used to + create instances of ProxyEstimators that dispatch to either library. + + **Design of the ProxyEstimator Class Inside** + + **`ProxyEstimator` Class:** + - The `ProxyEstimator` class inherits from the GPU-accelerated + class (`class_b`) obtained from the `accelerated_module`. + - It serves as a wrapper that adds additional functionality + to maintain compatibility with the original CPU-based estimator. + Key methods and attributes: + - `__init__`: Initializes the proxy estimator, stores a + reference to the original class before ModuleAccelerator + replaces the original module, translates hyperparameters, + and initializes the parent (cuML) class. + - `__repr__` and `__str__`: Provide string representations + that reference the original CPU-based class. + - Attribute `_cpu_model_class`: Stores a reference to the + original CPU-based estimator class. + - Attribute `_gpuaccel`: Indicates whether GPU acceleration + is enabled. + - By designing the `ProxyEstimator` in this way, we can + seamlessly replace the original CPU-based estimator with a + GPU-accelerated version without altering the existing codebase. + The metaclass ensures that the class behaves and appears + like the original estimator, while the proxy class manages + the underlying acceleration and compatibility. + + **Serialization/Pickling of ProxyEstimators** + + Since pickle has strict rules about serializing classes, we cannot + (reasonably) create a method that just pickles and unpickles a ProxyEstimat + as if it was just an instance of the original module. + + To overcome this limitation and offer compatibility between environments + with acceleration and environments without, a ProxyEstimator serializes + *both* the underlying _cpu_model as well as the ProxyEstimator itself. + See the example below to see how it works in practice. + + Parameters + ---------- + original_module : str + Original module that is being accelerated + accelerated_module : str + Acceleration module + class_name: str + Name of class beign accelerated + accelerated_class_name : str, optional + Name of accelerator class. If None, then it is assumed it is the same + name as class_name (i.e. the original class in the original module). + + Returns + ------- + A class definition of ProxyEstimator that inherits from + the accelerated library class (cuML). + + Examples + -------- + >>> from module_accelerator import intercept + >>> ProxyEstimator = intercept('sklearn.linear_model', + ... 'cuml.linear_model', 'LinearRegression') + >>> model = ProxyEstimator() + >>> with open("ProxyEstimator.pkl", "wb") as f: + >>> # This saves two pickled files, a pickle corresponding to + >>> # the ProxyEstimator and a "ProxyEstimator_pickle.pkl" that is + >>> # the CPU model pickled. + >>> loaded = load(f) + + """ if accelerated_class_name is None: accelerated_class_name = original_class_name + # Import the original host module and cuML module_a = cpu_only_import(original_module) module_b = gpu_only_import(accelerated_module) # Store a reference to the original (CPU) class - if original_class_name in patched_classes: - original_class_a = patched_classes[original_class_name] - else: - original_class_a = getattr(module_a, original_class_name) - patched_classes[original_class_name] = original_class_a + original_class_a = getattr(module_a, original_class_name) # Get the class from cuML so ProxyEstimator inherits from it class_b = getattr(module_b, accelerated_class_name) - # todo (dgd): add environment variable to disable this - class ProxyEstimatorMeta(cuml.internals.base_helpers.BaseMetaClass): - def __repr__(cls): - return repr(original_class_a) + class ProxyEstimator(class_b): + """ + A proxy estimator class that wraps the accelerated estimator and provides + compatibility with the original estimator interface. - class ProxyEstimator(class_b, metaclass=ProxyEstimatorMeta): + The ProxyEstimator inherits from the accelerated estimator class and + wraps additional functionality to maintain compatibility with the original + CPU-based estimator. + + It handles the translation of hyperparameters and the transfer of models + between CPU and GPU. + + """ def __init__(self, *args, **kwargs): self._cpu_model_class = ( original_class_a # Store a reference to the original class ) - # print("HYPPPPP") kwargs, self._gpuaccel = self._hyperparam_translator(**kwargs) super().__init__(*args, **kwargs) @@ -75,28 +151,74 @@ def __init__(self, *args, **kwargs): ) def __repr__(self): + """ + Return a formal string representation of the object. + + Returns + ------- + str + A string representation indicating that this is a wrapped + version of the original CPU-based estimator. + """ return f"wrapped {self._cpu_model_class}" def __str__(self): + """ + Return an informal string representation of the object. + + Returns + ------- + str + A string representation indicating that this is a wrapped + version of the original CPU-based estimator. + """ return f"ProxyEstimator of {self._cpu_model_class}" - def __getstate__(self): + def _check_cpu_model(self): + """ + Checks if an estimator already has created a _cpu_model, + and creates one if necessary. + """ if not hasattr(self, "_cpu_model"): self.import_cpu_model() self.build_cpu_model() self.gpu_to_cpu() - return self._cpu_model.__dict__.copy() + def __getstate__(self): + """ + Prepare the object state for pickling. We need it since + we have a custom function in __reduce__. + + Returns + ------- + dict + The state of the Estimator. + """ + return self.__dict__.copy() def __reduce__(self): + """ + Helper for pickle. + + Returns + ------- + tuple + A tuple containing the callable to reconstruct the object + and the arguments for reconstruction. + + Notes + ----- + Disables the module accelerator during pickling to ensure correct serialization. + """ import pickle from .module_accelerator import disable_module_accelerator with disable_module_accelerator(): filename = self.__class__.__name__ + "_sklearn" with open(filename, "wb") as f: - pickle.dump(self._cpu_model_class, f) + self._check_cpu_model() + pickle.dump(self._cpu_model, f) return ( reconstruct_proxy, @@ -104,43 +226,58 @@ def __reduce__(self): original_module, accelerated_module, original_class_name, + (), self.__getstate__(), ), ) - def __setstate__(self, state): - print(f"state: {state}") - self._cpu_model_class = ( - original_class_a # Store a reference to the original class - ) - super().__init__() - self.import_cpu_model() - self._cpu_model = self._cpu_model_class() - self._cpu_model.__dict__.update(state) - self.cpu_to_gpu() - self.output_type = "numpy" - self.output_mem_type = MemoryType.host - logger.debug( f"Created proxy estimator: ({module_b}, {original_class_name}, {ProxyEstimator})" ) setattr(module_b, original_class_name, ProxyEstimator) - - # This is currently needed for pytest only - if "PYTEST_CURRENT_TEST" in os.environ: - setattr(module_a, original_class_name, ProxyEstimator) + setattr(module_a, original_class_name, ProxyEstimator) return ProxyEstimator -def reconstruct_proxy(original_module, new_module, class_name_a, args, kwargs): - "Function needed to pickle since ProxyEstimator is" +def reconstruct_proxy( + original_module: str, + accelerated_module: str, + class_name: str, + args: Tuple, + kwargs: Dict): + """ + Function to enable pickling of ProxyEstimators since they are defined inside + a function, which Pickle doesn't like without a function or something + that has an absolute import path like this function. + + Parameters + ---------- + original_module : str + Original module that is being accelerated + accelerated_module : str + Acceleration module + class_name: str + Name of class beign accelerated + args : Tuple + Args of class to be deserialized (typically empty for ProxyEstimators) + kwargs : Dict + Keyword arguments to reconstruct the ProxyEstimator instance, typically + state from __setstate__ method. + + Returns + ------- + Instance of ProxyEstimator constructed with the kwargs passed to the function. + + """ # We probably don't need to intercept again here, since we already stored # the variables in _wrappers cls = intercept( original_module=original_module, - accelerated_module=new_module, - original_class_name=class_name_a, + accelerated_module=accelerated_module, + original_class_name=class_name, ) - return cls(*args, **kwargs) + estimator = cls() + estimator.__dict__.update(kwargs) + return estimator From 5772e0d38d9196c7d05cb08a6396979a3982e64d Mon Sep 17 00:00:00 2001 From: Dante Gama Dessavre Date: Sun, 17 Nov 2024 22:51:40 -0600 Subject: [PATCH 18/22] ENH Multiple improvements, cleanups and fixes from PR review --- ci/run_cuml_singlegpu_pytests.sh | 4 + .../cuml/cuml/experimental/accel/__init__.py | 24 +- .../cuml/cuml/experimental/accel/__main__.py | 21 +- .../cuml/experimental/accel/annotation.py | 47 - .../experimental/accel/fast_slow_proxy.py | 1234 ----------------- python/cuml/cuml/experimental/accel/magics.py | 16 - .../experimental/accel/module_accelerator.py | 14 +- python/cuml/cuml/experimental/accel/utils.py | 65 + python/cuml/cuml/internals/base.pyx | 7 +- python/cuml/cuml/internals/global_settings.py | 10 +- .../tests/experimental/accel/test_optuna.py | 146 -- 11 files changed, 103 insertions(+), 1485 deletions(-) delete mode 100644 python/cuml/cuml/experimental/accel/annotation.py delete mode 100644 python/cuml/cuml/experimental/accel/fast_slow_proxy.py create mode 100644 python/cuml/cuml/experimental/accel/utils.py delete mode 100644 python/cuml/cuml/tests/experimental/accel/test_optuna.py diff --git a/ci/run_cuml_singlegpu_pytests.sh b/ci/run_cuml_singlegpu_pytests.sh index a395baab1b..a076914499 100755 --- a/ci/run_cuml_singlegpu_pytests.sh +++ b/ci/run_cuml_singlegpu_pytests.sh @@ -5,3 +5,7 @@ cd "$(dirname "$(realpath "${BASH_SOURCE[0]}")")"/../python/cuml/cuml/tests python -m pytest --cache-clear --ignore=dask -m "not memleak" "$@" . + +cd "$(dirname "$(realpath "${BASH_SOURCE[0]}")")"/../python/cuml/cuml/tests/experimental/accel + +python -m pytest -p cuml.experimental.accel --cache-clear "$@" . \ No newline at end of file diff --git a/python/cuml/cuml/experimental/accel/__init__.py b/python/cuml/cuml/experimental/accel/__init__.py index 1a54b59459..7dce9f7ef4 100644 --- a/python/cuml/cuml/experimental/accel/__init__.py +++ b/python/cuml/cuml/experimental/accel/__init__.py @@ -17,27 +17,35 @@ from .magics import load_ipython_extension -# from .profiler import Profiler +from cuml.internals import logger +from cuml.internals.global_settings import GlobalSettings __all__ = ["load_ipython_extension", "install"] -LOADED = False - - def install(): - """Enable cuML Accelerator Mode.""" + """ + Enable cuML Accelerator Mode. + """ from .module_accelerator import ModuleAccelerator - print("Installing cuML Accelerator...") + logger.set_level(logger.level_info) + logger.set_pattern("%v") + + + logger.info("cuML: Installing experimental accelerator...") loader = ModuleAccelerator.install("sklearn", "cuml", "sklearn") loader_umap = ModuleAccelerator.install("umap", "cuml", "umap") loader_hdbscan = ModuleAccelerator.install("hdbscan", "cuml", "hdbscan") - global LOADED - LOADED = all( + GlobalSettings().accelerator_loaded = all( var is not None for var in [loader, loader_umap, loader_hdbscan] ) + if GlobalSettings().accelerator_loaded: + logger.info("cuML: experimental accelerator succesfully initialized...") + else: + logger.info("cuML: experimental accelerator failed to initialize...") + def pytest_load_initial_conftests(early_config, parser, args): # https://docs.pytest.org/en/7.1.x/reference/\ diff --git a/python/cuml/cuml/experimental/accel/__main__.py b/python/cuml/cuml/experimental/accel/__main__.py index 85b8795f80..408d4b2857 100644 --- a/python/cuml/cuml/experimental/accel/__main__.py +++ b/python/cuml/cuml/experimental/accel/__main__.py @@ -21,23 +21,10 @@ import sys from . import install -from cuml.internals import logger @click.command() @click.option("-m", "module", required=False, help="Module to run") -@click.option( - "--profile", - is_flag=True, - default=False, - help="Perform per-function profiling of this script.", -) -@click.option( - "--line-profile", - is_flag=True, - default=False, - help="Perform per-line profiling of this script.", -) @click.option( "--strict", is_flag=True, @@ -45,13 +32,9 @@ help="Turn strict mode for hyperparameters on.", ) @click.argument("args", nargs=-1) -def main(module, profile, line_profile, strict, args): +def main(module, strict, args): """ """ - - # todo (dgd): add option to lower verbosity - logger.set_level(logger.level_debug) - logger.set_pattern("%v") - + if strict: os.environ["CUML_ACCEL_STRICT_MODE"] = "ON" diff --git a/python/cuml/cuml/experimental/accel/annotation.py b/python/cuml/cuml/experimental/accel/annotation.py deleted file mode 100644 index 47b0017a3c..0000000000 --- a/python/cuml/cuml/experimental/accel/annotation.py +++ /dev/null @@ -1,47 +0,0 @@ -# -# Copyright (c) 2024, NVIDIA CORPORATION. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# - -from __future__ import annotations - -try: - import nvtx -except ImportError: - - class nvtx: # type: ignore - """Noop-stub with the same API as nvtx.""" - - push_range = lambda *args, **kwargs: None # noqa: E731 - pop_range = lambda *args, **kwargs: None # noqa: E731 - - class annotate: - """No-op annotation/context-manager""" - - def __init__( - self, - message: str | None = None, - color: str | None = None, - domain: str | None = None, - category: str | int | None = None, - ): - pass - - def __enter__(self): - return self - - def __exit__(self, *exc): - return False - - __call__ = lambda self, fn: fn # noqa: E731 diff --git a/python/cuml/cuml/experimental/accel/fast_slow_proxy.py b/python/cuml/cuml/experimental/accel/fast_slow_proxy.py deleted file mode 100644 index 5ae36110f8..0000000000 --- a/python/cuml/cuml/experimental/accel/fast_slow_proxy.py +++ /dev/null @@ -1,1234 +0,0 @@ -# -# Copyright (c) 2024, NVIDIA CORPORATION. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# - -from __future__ import annotations - -import functools -import inspect -import operator -import os -import pickle -import types -import warnings -from collections.abc import Iterator -from enum import IntEnum -from typing import Any, Callable, Literal, Mapping - -import numpy as np - -# from ..options import _env_get_bool -# from ..testing import assert_eq -from .annotation import nvtx - - -def _env_get_int(name, default): - try: - return int(os.getenv(name, default)) - except (ValueError, TypeError): - return default - - -def _env_get_bool(name, default): - env = os.getenv(name) - if env is None: - return default - as_a_int = _env_get_int(name, None) - env = env.lower().strip() - if env == "true" or env == "on" or as_a_int: - return True - if env == "false" or env == "off" or as_a_int == 0: - return False - return default - - -def call_operator(fn, args, kwargs): - return fn(*args, **kwargs) - - -_CUML_ACCEL_NVTX_COLORS = { - "COPY_SLOW_TO_FAST": 0xCA0020, - "COPY_FAST_TO_SLOW": 0xF4A582, - "EXECUTE_FAST": 0x92C5DE, - "EXECUTE_SLOW": 0x0571B0, -} - - -_WRAPPER_ASSIGNMENTS = tuple( - attr - for attr in functools.WRAPPER_ASSIGNMENTS - # Skip __doc__ because we assign it on class creation using exec_body - # callable that updates the namespace of the class. - # Skip __annotations__ because there are differences between Python - # versions on how it is initialized for a class that doesn't explicitly - # define it and we don't want to force eager evaluation of anything that - # would normally be lazy (mostly for consistency, shouldn't cause any - # significant issues). - if attr not in ("__annotations__", "__doc__") -) - - -def callers_module_name(): - # Call f_back twice since this function adds an extra frame - return inspect.currentframe().f_back.f_back.f_globals["__name__"] - - -class _State(IntEnum): - """Simple enum to track the type of wrapped object of a final proxy""" - - SLOW = 0 - FAST = 1 - - -class _Unusable: - """ - A totally unusable type. When a "fast" object is not available, - it's useful to set it to _Unusable() so that any operations - on it fail, and ensure fallback to the corresponding - "slow" object. - """ - - def __call__(self, *args: Any, **kwds: Any) -> Any: - raise NotImplementedError( - "Fast implementation not available. " - "Falling back to the slow implementation" - ) - - def __getattribute__(self, name: str) -> Any: - if name in {"__class__"}: # needed for type introspection - return super().__getattribute__(name) - raise TypeError("Unusable type. Falling back to the slow object") - - def __repr__(self) -> str: - raise AttributeError("Unusable type. Falling back to the slow object") - - -class _PickleConstructor: - """A pickleable object to support construction in __reduce__. - - This object is used to avoid having unpickling call __init__ on the - objects, instead only invoking __new__. __init__ may have required - arguments or otherwise perform invalid initialization that we could skip - altogether since we're going to overwrite the wrapped object. - """ - - def __init__(self, type_): - self._type = type_ - - def __call__(self): - return object.__new__(self._type) - - -_DELETE = object() - - -def make_final_proxy_type( - name: str, - fast_type: type, - slow_type: type, - *, - fast_to_slow: Callable, - slow_to_fast: Callable, - module: str | None = None, - additional_attributes: Mapping[str, Any] | None = None, - postprocess: Callable[[_FinalProxy, Any, Any], Any] | None = None, - bases: tuple = (), - metaclasses: tuple = (), -) -> type[_FinalProxy]: - """ - Defines a fast-slow proxy type for a pair of "final" fast and slow - types. Final types are types for which known operations exist for - converting an object of "fast" type to "slow" and vice-versa. - - Parameters - ---------- - name: str - The name of the class returned - fast_type: type - slow_type: type - fast_to_slow: callable - Function that accepts a single argument of type `fast_type` - and returns an object of type `slow_type` - slow_to_fast: callable - Function that accepts a single argument of type `slow_type` - and returns an object of type `fast_type` - additional_attributes - Mapping of additional attributes to add to the class - (optional), these will override any defaulted attributes (e.g. - ``__init__`). If you want to remove a defaulted attribute - completely, pass the special sentinel ``_DELETE`` as a value. - postprocess - Optional function called to allow the proxy to postprocess - itself when being wrapped up, called with the proxy object, - the unwrapped result object, and the function that was used to - construct said unwrapped object. See also `_maybe_wrap_result`. - bases - Optional tuple of base classes to insert into the mro. - metaclasses - Optional tuple of metaclasses to unify with the base proxy metaclass. - - Notes - ----- - As a side-effect, this function adds `fast_type` and `slow_type` - to a global mapping of final types to their corresponding proxy - types, accessible via `get_final_type_map()`. - """ - - def __init__(self, *args, **kwargs): - _fast_slow_function_call( - lambda cls, args, kwargs: setattr( - self, "_fsproxy_wrapped", cls(*args, **kwargs) - ), - type(self), - args, - kwargs, - ) - - @nvtx.annotate( - "COPY_SLOW_TO_FAST", - color=_CUML_ACCEL_NVTX_COLORS["COPY_SLOW_TO_FAST"], - domain="cudf_pandas", - ) - def _fsproxy_slow_to_fast(self): - # if we are wrapping a slow object, - # convert it to a fast one - if self._fsproxy_state is _State.SLOW: - return slow_to_fast(self._fsproxy_wrapped) - return self._fsproxy_wrapped - - @nvtx.annotate( - "COPY_FAST_TO_SLOW", - color=_CUML_ACCEL_NVTX_COLORS["COPY_FAST_TO_SLOW"], - domain="cudf_pandas", - ) - def _fsproxy_fast_to_slow(self): - # if we are wrapping a fast object, - # convert it to a slow one - if self._fsproxy_state is _State.FAST: - return fast_to_slow(self._fsproxy_wrapped) - return self._fsproxy_wrapped - - @property # type: ignore - def _fsproxy_state(self) -> _State: - return ( - _State.FAST - if isinstance(self._fsproxy_wrapped, self._fsproxy_fast_type) - else _State.SLOW - ) - - slow_dir = dir(slow_type) - cls_dict = { - "__init__": __init__, - "__doc__": inspect.getdoc(slow_type), - "_fsproxy_slow_dir": slow_dir, - "_fsproxy_fast_type": fast_type, - "_fsproxy_slow_type": slow_type, - "_fsproxy_slow_to_fast": _fsproxy_slow_to_fast, - "_fsproxy_fast_to_slow": _fsproxy_fast_to_slow, - "_fsproxy_state": _fsproxy_state, - } - - if additional_attributes is None: - additional_attributes = {} - for method in _SPECIAL_METHODS: - if getattr(slow_type, method, False): - cls_dict[method] = _FastSlowAttribute(method) - for k, v in additional_attributes.items(): - if v is _DELETE and k in cls_dict: - del cls_dict[k] - elif v is not _DELETE: - cls_dict[k] = v - - for slow_name in dir(slow_type): - if slow_name in cls_dict or slow_name.startswith("__"): - continue - else: - cls_dict[slow_name] = _FastSlowAttribute( - slow_name, private=slow_name.startswith("_") - ) - - metaclass = _FastSlowProxyMeta - if metaclasses: - metaclass = types.new_class( # type: ignore - f"{name}_Meta", - metaclasses + (_FastSlowProxyMeta,), - {}, - ) - cls = types.new_class( - name, - (*bases, _FinalProxy), - {"metaclass": metaclass}, - lambda ns: ns.update(cls_dict), - ) - functools.update_wrapper( - cls, - slow_type, - assigned=_WRAPPER_ASSIGNMENTS, - updated=(), - ) - cls.__module__ = module if module is not None else callers_module_name() - - final_type_map = get_final_type_map() - if fast_type is not _Unusable: - final_type_map[fast_type] = cls - final_type_map[slow_type] = cls - - return cls - - -def make_intermediate_proxy_type( - name: str, - fast_type: type, - slow_type: type, - *, - module: str | None = None, -) -> type[_IntermediateProxy]: - """ - Defines a proxy type for a pair of "intermediate" fast and slow - types. Intermediate types are the types of the results of - operations invoked on final types. - - As a side-effect, this function adds `fast_type` and `slow_type` - to a global mapping of intermediate types to their corresponding - proxy types, accessible via `get_intermediate_type_map()`. - - Parameters - ---------- - name: str - The name of the class returned - fast_type: type - slow_type: type - """ - - def __init__(self, *args, **kwargs): - # disallow __init__. An intermediate proxy type can only be - # instantiated from (possibly chained) operations on a final - # proxy type. - raise TypeError( - f"Cannot directly instantiate object of type {type(self)}" - ) - - @property # type: ignore - def _fsproxy_state(self): - return ( - _State.FAST - if isinstance(self._fsproxy_wrapped, self._fsproxy_fast_type) - else _State.SLOW - ) - - @nvtx.annotate( - "COPY_SLOW_TO_FAST", - color=_CUML_ACCEL_NVTX_COLORS["COPY_SLOW_TO_FAST"], - domain="cudf_pandas", - ) - def _fsproxy_slow_to_fast(self): - if self._fsproxy_state is _State.SLOW: - return super(type(self), self)._fsproxy_slow_to_fast() - return self._fsproxy_wrapped - - @nvtx.annotate( - "COPY_FAST_TO_SLOW", - color=_CUML_ACCEL_NVTX_COLORS["COPY_FAST_TO_SLOW"], - domain="cudf_pandas", - ) - def _fsproxy_fast_to_slow(self): - if self._fsproxy_state is _State.FAST: - return super(type(self), self)._fsproxy_fast_to_slow() - return self._fsproxy_wrapped - - slow_dir = dir(slow_type) - cls_dict = { - "__init__": __init__, - "__doc__": inspect.getdoc(slow_type), - "_fsproxy_slow_dir": slow_dir, - "_fsproxy_fast_type": fast_type, - "_fsproxy_slow_type": slow_type, - "_fsproxy_slow_to_fast": _fsproxy_slow_to_fast, - "_fsproxy_fast_to_slow": _fsproxy_fast_to_slow, - "_fsproxy_state": _fsproxy_state, - } - for method in _SPECIAL_METHODS: - if getattr(slow_type, method, False): - cls_dict[method] = _FastSlowAttribute(method) - - for slow_name in dir(slow_type): - if slow_name in cls_dict or slow_name.startswith("__"): - continue - else: - cls_dict[slow_name] = _FastSlowAttribute( - slow_name, private=slow_name.startswith("_") - ) - - for slow_name in getattr(slow_type, "_attributes", []): - if slow_name in cls_dict: - continue - else: - cls_dict[slow_name] = _FastSlowAttribute( - slow_name, private=slow_name.startswith("_") - ) - - cls = types.new_class( - name, - (_IntermediateProxy,), - {"metaclass": _FastSlowProxyMeta}, - lambda ns: ns.update(cls_dict), - ) - functools.update_wrapper( - cls, - slow_type, - assigned=_WRAPPER_ASSIGNMENTS, - updated=(), - ) - cls.__module__ = module if module is not None else callers_module_name() - - intermediate_type_map = get_intermediate_type_map() - if fast_type is not _Unusable: - intermediate_type_map[fast_type] = cls - intermediate_type_map[slow_type] = cls - - return cls - - -def register_proxy_func(slow_func: Callable): - """ - Decorator to register custom function as a proxy for slow_func. - - Parameters - ---------- - slow_func: Callable - The function to register a wrapper for. - - Returns - ------- - Callable - """ - - def wrapper(func): - registered_functions = get_registered_functions() - registered_functions[slow_func] = func - functools.update_wrapper(func, slow_func) - return func - - return wrapper - - -@functools.lru_cache(maxsize=None) -def get_final_type_map(): - """ - Return the mapping of all known fast and slow final types to their - corresponding proxy types. - """ - return dict() - - -@functools.lru_cache(maxsize=None) -def get_intermediate_type_map(): - """ - Return a mapping of all known fast and slow intermediate types to their - corresponding proxy types. - """ - return dict() - - -@functools.lru_cache(maxsize=None) -def get_registered_functions(): - return dict() - - -def _raise_attribute_error(obj, name): - """ - Raise an AttributeError with a message that is consistent with - the error raised by Python for a non-existent attribute on a - proxy object. - """ - raise AttributeError(f"'{obj}' object has no attribute '{name}'") - - -class _FastSlowProxyMeta(type): - """ - Metaclass used to dynamically find class attributes and - classmethods of fast-slow proxy types. - """ - - _fsproxy_slow_dir: list - _fsproxy_slow_type: type - _fsproxy_fast_type: type - - @property - def _fsproxy_slow(self) -> type: - return self._fsproxy_slow_type - - @property - def _fsproxy_fast(self) -> type: - return self._fsproxy_fast_type - - def __dir__(self): - # Try to return the cached dir of the slow object, but if it - # doesn't exist, fall back to the default implementation. - try: - return self._fsproxy_slow_dir - except AttributeError: - return type.__dir__(self) - - def __subclasscheck__(self, __subclass: type) -> bool: - if super().__subclasscheck__(__subclass): - return True - if hasattr(__subclass, "_fsproxy_slow"): - return issubclass(__subclass._fsproxy_slow, self._fsproxy_slow) - return False - - def __instancecheck__(self, __instance: Any) -> bool: - if super().__instancecheck__(__instance): - return True - elif hasattr(type(__instance), "_fsproxy_slow"): - return issubclass(type(__instance), self) - return False - - -class _FastSlowProxy: - """ - Base class for all fast=slow proxy types. - - A fast-slow proxy is proxy for a pair of types that provide "fast" - and "slow" implementations of the same API. At any time, a - fast-slow proxy wraps an object of either "fast" type, or "slow" - type. Operations invoked on the fast-slow proxy are first - delegated to the "fast" type, and if that fails, to the "slow" - type. - """ - - _fsproxy_wrapped: Any - - def _fsproxy_fast_to_slow(self) -> Any: - """ - If the wrapped object is of "fast" type, returns the - corresponding "slow" object. Otherwise, returns the wrapped - object as-is. - """ - raise NotImplementedError("Abstract base class") - - def _fsproxy_slow_to_fast(self) -> Any: - """ - If the wrapped object is of "slow" type, returns the - corresponding "fast" object. Otherwise, returns the wrapped - object as-is. - """ - raise NotImplementedError("Abstract base class") - - @property - def _fsproxy_fast(self) -> Any: - """ - Returns the wrapped object. If the wrapped object is of "slow" - type, replaces it with the corresponding "fast" object before - returning it. - """ - self._fsproxy_wrapped = self._fsproxy_slow_to_fast() - return self._fsproxy_wrapped - - @property - def _fsproxy_slow(self) -> Any: - """ - Returns the wrapped object. If the wrapped object is of "fast" - type, replaces it with the corresponding "slow" object before - returning it. - """ - self._fsproxy_wrapped = self._fsproxy_fast_to_slow() - return self._fsproxy_wrapped - - def __dir__(self): - # Try to return the cached dir of the slow object, but if it - # doesn't exist, fall back to the default implementation. - try: - return self._fsproxy_slow_dir - except AttributeError: - return object.__dir__(self) - - def __setattr__(self, name, value): - if name.startswith("_"): - object.__setattr__(self, name, value) - return - return _FastSlowAttribute("__setattr__").__get__(self, type(self))( - name, value - ) - - -class _FinalProxy(_FastSlowProxy): - """ - Proxy type for a pair of fast and slow "final" types for which - there is a known conversion from fast to slow, and vice-versa. - The conversion between fast and slow types is done using - user-provided conversion functions. - - Do not attempt to use this class directly. Instead, use - `make_final_proxy_type` to create subtypes. - """ - - @classmethod - def _fsproxy_wrap(cls, value, func): - """Default mechanism to wrap a value in a proxy type - - Parameters - ---------- - cls - The proxy type - value - The value to wrap up - func - The function called that constructed value - - Returns - ------- - A new proxied object - - Notes - ----- - _FinalProxy subclasses can override this classmethod if they - need particular behaviour when wrapped up. - """ - proxy = object.__new__(cls) - proxy._fsproxy_wrapped = value - return proxy - - def __reduce__(self): - """ - In conjunction with `__proxy_setstate__`, this effectively enables - proxy types to be pickled and unpickled by pickling and unpickling - the underlying wrapped types. - """ - # Need a local import to avoid circular import issues - from .module_accelerator import disable_module_accelerator - - with disable_module_accelerator(): - pickled_wrapped_obj = pickle.dumps(self._fsproxy_wrapped) - return (_PickleConstructor(type(self)), (), pickled_wrapped_obj) - - def __setstate__(self, state): - # Need a local import to avoid circular import issues - from .module_accelerator import disable_module_accelerator - - with disable_module_accelerator(): - unpickled_wrapped_obj = pickle.loads(state) - self._fsproxy_wrapped = unpickled_wrapped_obj - - -class _IntermediateProxy(_FastSlowProxy): - """ - Proxy type for a pair of "intermediate" types that appear as - intermediate values when invoking operations on "final" types. - The conversion between fast and slow types is done by keeping - track of the sequence of operations that created the wrapped - object, and "playing back" that sequence starting from the "slow" - version of the originating _FinalProxy. - - Do not attempt to use this class directly. Instead, use - `make_intermediate_proxy_type` to create subtypes. - """ - - _method_chain: tuple[Callable, tuple, dict] - - @classmethod - def _fsproxy_wrap( - cls, - obj: Any, - method_chain: tuple[Callable, tuple, dict], - ): - """ - Parameters - ---------- - obj: The object to wrap - method_chain: A tuple of the form (func, args, kwargs) where - `func` is the function that was called to create `obj`, - and `args` and `kwargs` are the arguments that were passed - to `func`. - """ - proxy = object.__new__(cls) - proxy._fsproxy_wrapped = obj - proxy._method_chain = method_chain - return proxy - - @nvtx.annotate( - "COPY_SLOW_TO_FAST", - color=_CUML_ACCEL_NVTX_COLORS["COPY_SLOW_TO_FAST"], - domain="cudf_pandas", - ) - def _fsproxy_slow_to_fast(self) -> Any: - func, args, kwargs = self._method_chain - args, kwargs = _fast_arg(args), _fast_arg(kwargs) - return func(*args, **kwargs) - - @nvtx.annotate( - "COPY_FAST_TO_SLOW", - color=_CUML_ACCEL_NVTX_COLORS["COPY_FAST_TO_SLOW"], - domain="cudf_pandas", - ) - def _fsproxy_fast_to_slow(self) -> Any: - func, args, kwargs = self._method_chain - args, kwargs = _slow_arg(args), _slow_arg(kwargs) - return func(*args, **kwargs) - - def __reduce__(self): - """ - In conjunction with `__proxy_setstate__`, this effectively enables - proxy types to be pickled and unpickled by pickling and unpickling - the underlying wrapped types. - """ - # Need a local import to avoid circular import issues - from .module_accelerator import disable_module_accelerator - - with disable_module_accelerator(): - pickled_wrapped_obj = pickle.dumps(self._fsproxy_wrapped) - pickled_method_chain = pickle.dumps(self._method_chain) - return ( - _PickleConstructor(type(self)), - (), - (pickled_wrapped_obj, pickled_method_chain), - ) - - def __setstate__(self, state): - # Need a local import to avoid circular import issues - from .module_accelerator import disable_module_accelerator - - with disable_module_accelerator(): - unpickled_wrapped_obj = pickle.loads(state[0]) - unpickled_method_chain = pickle.loads(state[1]) - self._fsproxy_wrapped = unpickled_wrapped_obj - self._method_chain = unpickled_method_chain - - -class _CallableProxyMixin: - """ - Mixin class that implements __call__ for fast-slow proxies. - """ - - # For wrapped callables isinstance(self, FunctionType) should return True - __class__ = types.FunctionType # type: ignore - - def __call__(self, *args, **kwargs) -> Any: - result, _ = _fast_slow_function_call( - # We cannot directly call self here because we need it to be - # converted into either the fast or slow object (by - # _fast_slow_function_call) to avoid infinite recursion. - # TODO: When Python 3.11 is the minimum supported Python version - # this can use operator.call - call_operator, - self, - args, - kwargs, - ) - return result - - -class _FunctionProxy(_CallableProxyMixin): - """ - Proxy for a pair of fast and slow functions. - """ - - __name__: str - - def __init__( - self, - fast: Callable | _Unusable, - slow: Callable, - *, - assigned=None, - updated=None, - ): - self._fsproxy_fast = fast - self._fsproxy_slow = slow - if assigned is None: - assigned = functools.WRAPPER_ASSIGNMENTS - if updated is None: - updated = functools.WRAPPER_UPDATES - functools.update_wrapper( - self, - slow, - assigned=assigned, - updated=updated, - ) - - def __reduce__(self): - """ - In conjunction with `__proxy_setstate__`, this effectively enables - proxy types to be pickled and unpickled by pickling and unpickling - the underlying wrapped types. - """ - # Need a local import to avoid circular import issues - from .module_accelerator import disable_module_accelerator - - with disable_module_accelerator(): - pickled_fast = pickle.dumps(self._fsproxy_fast) - pickled_slow = pickle.dumps(self._fsproxy_slow) - return ( - _PickleConstructor(type(self)), - (), - (pickled_fast, pickled_slow), - ) - - def __setstate__(self, state): - # Need a local import to avoid circular import issues - from .module_accelerator import disable_module_accelerator - - with disable_module_accelerator(): - unpickled_fast = pickle.loads(state[0]) - unpickled_slow = pickle.loads(state[1]) - self._fsproxy_fast = unpickled_fast - self._fsproxy_slow = unpickled_slow - - -def is_bound_method(obj): - return inspect.ismethod(obj) and not inspect.isfunction(obj) - - -def is_function(obj): - return inspect.isfunction(obj) or isinstance(obj, types.FunctionType) - - -class _FastSlowAttribute: - """ - A descriptor type used to define attributes of fast-slow proxies. - """ - - _attr: Any - - def __init__(self, name: str, *, private: bool = False): - self._name = name - self._private = private - self._attr = None - self._doc = None - self._dir = None - - def __get__(self, instance, owner) -> Any: - from .module_accelerator import disable_module_accelerator - - if self._attr is None: - if self._private: - fast_attr = _Unusable() - else: - fast_attr = getattr( - owner._fsproxy_fast, self._name, _Unusable() - ) - - try: - slow_attr = getattr(owner._fsproxy_slow, self._name) - except AttributeError as e: - if instance is not None: - return _maybe_wrap_result( - getattr(instance._fsproxy_slow, self._name), - None, # type: ignore - ) - else: - raise e - - if _is_function_or_method(slow_attr): - self._attr = _MethodProxy(fast_attr, slow_attr) - else: - # for anything else, use a fast-slow attribute: - self._attr, _ = _fast_slow_function_call( - getattr, - owner, - self._name, - ) - - if isinstance( - self._attr, (property, functools.cached_property) - ): - with disable_module_accelerator(): - self._attr.__doc__ = inspect.getdoc(slow_attr) - - if instance is not None: - if isinstance(self._attr, _MethodProxy): - if is_bound_method(self._attr._fsproxy_slow): - return self._attr - else: - return types.MethodType(self._attr, instance) - else: - if self._private: - return _maybe_wrap_result( - getattr(instance._fsproxy_slow, self._name), - None, # type: ignore - ) - return _fast_slow_function_call( - getattr, - instance, - self._name, - )[0] - return self._attr - - -class _MethodProxy(_FunctionProxy): - def __init__(self, fast, slow): - super().__init__( - fast, - slow, - updated=functools.WRAPPER_UPDATES, - assigned=( - tuple(filter(lambda x: x != "__name__", _WRAPPER_ASSIGNMENTS)) - ), - ) - - def __dir__(self): - return self._fsproxy_slow.__dir__() - - @property - def __doc__(self): - return self._fsproxy_slow.__doc__ - - @property - def __name__(self): - return self._fsproxy_slow.__name__ - - @__name__.setter - def __name__(self, value): - try: - setattr(self._fsproxy_fast, "__name__", value) - except AttributeError: - pass - setattr(self._fsproxy_slow, "__name__", value) - - -# def _assert_fast_slow_eq(left, right): -# if _is_final_type(type(left)) or type(left) in NUMPY_TYPES: -# assert_eq(left, right) - - -def _fast_slow_function_call( - func: Callable, - /, - *args, - **kwargs, -) -> Any: - """ - Call `func` with all `args` and `kwargs` converted to their - respective fast type. If that fails, call `func` with all - `args` and `kwargs` converted to their slow type. - - Wrap the result in a fast-slow proxy if it is a type we know how - to wrap. - """ - from .module_accelerator import disable_module_accelerator - - fast = False - try: - with nvtx.annotate( - "EXECUTE_FAST", - color=_CUML_ACCEL_NVTX_COLORS["EXECUTE_FAST"], - domain="cudf_pandas", - ): - fast_args, fast_kwargs = _fast_arg(args), _fast_arg(kwargs) - result = func(*fast_args, **fast_kwargs) - if result is NotImplemented: - # try slow path - raise Exception() - fast = True - if _env_get_bool("CUDF_PANDAS_DEBUGGING", False): - try: - with nvtx.annotate( - "EXECUTE_SLOW_DEBUG", - color=_CUML_ACCEL_NVTX_COLORS["EXECUTE_SLOW"], - domain="cudf_pandas", - ): - slow_args, slow_kwargs = ( - _slow_arg(args), - _slow_arg(kwargs), - ) - with disable_module_accelerator(): - slow_result = func( # noqa:F841 - *slow_args, **slow_kwargs - ) # noqa - except Exception as e: - warnings.warn( - "The result from pandas could not be computed. " - f"The exception was {e}." - ) - # else: - # try: - # _assert_fast_slow_eq(result, slow_result) - # except AssertionError as e: - # warnings.warn( - # "The results from cudf and pandas were different. " - # f"The exception was {e}." - # ) - # except Exception as e: - # warnings.warn( - # "Pandas debugging mode failed. " - # f"The exception was {e}." - # ) - except Exception as err: - with nvtx.annotate( - "EXECUTE_SLOW", - color=_CUML_ACCEL_NVTX_COLORS["EXECUTE_SLOW"], - domain="cudf_pandas", - ): - slow_args, slow_kwargs = _slow_arg(args), _slow_arg(kwargs) - if _env_get_bool("LOG_FAST_FALLBACK", False): - from ._logger import log_fallback - - log_fallback(slow_args, slow_kwargs, err) - with disable_module_accelerator(): - result = func(*slow_args, **slow_kwargs) - return _maybe_wrap_result(result, func, *args, **kwargs), fast - - -def _transform_arg( - arg: Any, - attribute_name: Literal["_fsproxy_slow", "_fsproxy_fast"], - seen: set[int], -) -> Any: - """ - Transform "arg" into its corresponding slow (or fast) type. - """ - import numpy as np - - if isinstance(arg, (_FastSlowProxy, _FastSlowProxyMeta, _FunctionProxy)): - typ = getattr(arg, attribute_name) - if typ is _Unusable: - raise Exception("Cannot transform _Unusable") - return typ - elif isinstance(arg, types.ModuleType) and attribute_name in arg.__dict__: - return arg.__dict__[attribute_name] - elif isinstance(arg, list): - return type(arg)(_transform_arg(a, attribute_name, seen) for a in arg) - elif isinstance(arg, tuple): - # This attempts to handle arbitrary subclasses of tuple by - # assuming that if you've subclassed tuple with some special - # behaviour you'll also make the object pickleable by - # implementing the custom pickle protocol interface (either - # __getnewargs_ex__ or __getnewargs__). Perhaps this should - # use __reduce_ex__ instead... - if type(arg) is tuple: - # Must come first to avoid infinite recursion - return tuple(_transform_arg(a, attribute_name, seen) for a in arg) - elif hasattr(arg, "__getnewargs_ex__"): - # Partial implementation of to reconstruct with - # transformed pieces - # This handles scipy._lib._bunch._make_tuple_bunch - args, kwargs = ( - _transform_arg(a, attribute_name, seen) - for a in arg.__getnewargs_ex__() - ) - obj = type(arg).__new__(type(arg), *args, **kwargs) - if hasattr(obj, "__setstate__"): - raise NotImplementedError( - "Transforming tuple-like with __getnewargs_ex__ and " - "__setstate__ not implemented" - ) - if not hasattr(obj, "__dict__") and kwargs: - raise NotImplementedError( - "Transforming tuple-like with kwargs from " - "__getnewargs_ex__ and no __dict__ not implemented" - ) - obj.__dict__.update(kwargs) - return obj - elif hasattr(arg, "__getnewargs__"): - # This handles namedtuple, and would catch tuple if we - # didn't handle it above. - args = _transform_arg(arg.__getnewargs__(), attribute_name, seen) - return type(arg).__new__(type(arg), *args) - else: - # Hope we can just call the constructor with transformed entries. - return type(arg)( - _transform_arg(a, attribute_name, seen) for a in args - ) - elif isinstance(arg, dict): - return { - _transform_arg(k, attribute_name, seen): _transform_arg( - a, attribute_name, seen - ) - for k, a in arg.items() - } - elif isinstance(arg, np.ndarray) and arg.dtype == "O": - transformed = [ - _transform_arg(a, attribute_name, seen) for a in arg.flat - ] - # Keep the same memory layout as arg (the default is C_CONTIGUOUS) - if arg.flags["F_CONTIGUOUS"] and not arg.flags["C_CONTIGUOUS"]: - order = "F" - else: - order = "C" - result = np.empty(int(np.prod(arg.shape)), dtype=object, order=order) - result[...] = transformed - return result.reshape(arg.shape) - elif isinstance(arg, Iterator) and attribute_name == "_fsproxy_fast": - # this may include consumable objects like generators or - # IOBase objects, which we don't want unavailable to the slow - # path in case of fallback. So, we raise here and ensure the - # slow path is taken: - raise Exception() - elif isinstance(arg, types.FunctionType): - if id(arg) in seen: - # `arg` is mutually recursive with another function. We - # can't handle these cases yet: - return arg - seen.add(id(arg)) - return _replace_closurevars(arg, attribute_name, seen) - else: - return arg - - -def _fast_arg(arg: Any) -> Any: - """ - Transform "arg" into its corresponding fast type. - """ - seen: set[int] = set() - return _transform_arg(arg, "_fsproxy_fast", seen) - - -def _slow_arg(arg: Any) -> Any: - """ - Transform "arg" into its corresponding slow type. - """ - seen: set[int] = set() - return _transform_arg(arg, "_fsproxy_slow", seen) - - -def _maybe_wrap_result(result: Any, func: Callable, /, *args, **kwargs) -> Any: - """ - Wraps "result" in a fast-slow proxy if is a "proxiable" object. - """ - if _is_final_type(result): - typ = get_final_type_map()[type(result)] - return typ._fsproxy_wrap(result, func) - elif _is_intermediate_type(result): - typ = get_intermediate_type_map()[type(result)] - return typ._fsproxy_wrap(result, method_chain=(func, args, kwargs)) - elif _is_final_class(result): - return get_final_type_map()[result] - elif isinstance(result, list): - return type(result)( - [ - _maybe_wrap_result(r, operator.getitem, result, i) - for i, r in enumerate(result) - ] - ) - elif isinstance(result, tuple): - wrapped = ( - _maybe_wrap_result(r, operator.getitem, result, i) - for i, r in enumerate(result) - ) - if hasattr(result, "_make"): - # namedtuple - return type(result)._make(wrapped) - else: - return type(result)(wrapped) - elif isinstance(result, Iterator): - return (_maybe_wrap_result(r, lambda x: x, r) for r in result) - else: - return result - - -def _is_final_type(result: Any) -> bool: - return type(result) in get_final_type_map() - - -def _is_final_class(result: Any) -> bool: - if not isinstance(result, type): - return False - return result in get_final_type_map() - - -def _is_intermediate_type(result: Any) -> bool: - return type(result) in get_intermediate_type_map() - - -def _is_function_or_method(obj: Any) -> bool: - res = isinstance( - obj, - ( - types.FunctionType, - types.BuiltinFunctionType, - types.MethodType, - types.WrapperDescriptorType, - types.MethodWrapperType, - types.MethodDescriptorType, - types.BuiltinMethodType, - ), - ) - if not res: - try: - return "cython_function_or_method" in str(type(obj)) - except Exception: - return False - return res - - -def _replace_closurevars( - f: types.FunctionType, - attribute_name: Literal["_fsproxy_slow", "_fsproxy_fast"], - seen: set[int], -) -> Callable[..., Any]: - """ - Return a copy of `f` with its closure variables replaced with - their corresponding slow (or fast) types. - """ - if f.__closure__: - # GH #254: If empty cells are present - which can happen in - # situations like when `f` is a method that invokes the - # "empty" `super()` - the call to `getclosurevars` below will - # fail. For now, we just return `f` in this case. If needed, - # we can consider populating empty cells with a placeholder - # value to allow the call to `getclosurevars` to succeed. - if any(c == types.CellType() for c in f.__closure__): - return f - - f_nonlocals, f_globals, _, _ = inspect.getclosurevars(f) - - g_globals = _transform_arg(f_globals, attribute_name, seen) - g_nonlocals = _transform_arg(f_nonlocals, attribute_name, seen) - - # if none of the globals/nonlocals were transformed, we - # can just return f: - if all(f_globals[k] is g_globals[k] for k in f_globals) and all( - g_nonlocals[k] is f_nonlocals[k] for k in f_nonlocals - ): - return f - - g_closure = tuple(types.CellType(val) for val in g_nonlocals.values()) - - # https://github.com/rapidsai/cudf/issues/15548 - new_g_globals = f.__globals__.copy() - new_g_globals.update(g_globals) - - g = types.FunctionType( - f.__code__, - new_g_globals, - name=f.__name__, - argdefs=f.__defaults__, - closure=g_closure, - ) - return functools.update_wrapper( - g, - f, - assigned=functools.WRAPPER_ASSIGNMENTS + ("__kwdefaults__",), - ) - - -def is_proxy_object(obj: Any) -> bool: - """Determine if an object is proxy object - - Parameters - ---------- - obj : object - Any python object. - - """ - if _FastSlowProxyMeta in type(type(obj)).__mro__: - return True - return False - - -NUMPY_TYPES: set[str] = set(np.sctypeDict.values()) - - -_SPECIAL_METHODS: set[str] = {} diff --git a/python/cuml/cuml/experimental/accel/magics.py b/python/cuml/cuml/experimental/accel/magics.py index 77c4851e59..da9c4494ae 100644 --- a/python/cuml/cuml/experimental/accel/magics.py +++ b/python/cuml/cuml/experimental/accel/magics.py @@ -18,26 +18,10 @@ try: from IPython.core.magic import Magics, cell_magic, magics_class - # from .profiler import Profiler, lines_with_profiling - - # @magics_class - # class CumlAccelMagic(Magics): - # @cell_magic("cuml.accelerator.profile") - # def profile(self, _, cell): - # with Profiler() as profiler: - # get_ipython().run_cell(cell) # noqa: F821 - # profiler.print_per_function_stats() - - # @cell_magic("cuml.accelerator.line_profile") - # def line_profile(self, _, cell): - # new_cell = lines_with_profiling(cell.split("\n")) - # get_ipython().run_cell(new_cell) # noqa: F821 - def load_ipython_extension(ip): from . import install install() - # ip.register_magics(CumlAccelMagic) except ImportError: diff --git a/python/cuml/cuml/experimental/accel/module_accelerator.py b/python/cuml/cuml/experimental/accel/module_accelerator.py index 00259ddc08..4441a9435a 100644 --- a/python/cuml/cuml/experimental/accel/module_accelerator.py +++ b/python/cuml/cuml/experimental/accel/module_accelerator.py @@ -32,15 +32,13 @@ from typing import Any, ContextManager, NamedTuple from typing_extensions import Self - -from .fast_slow_proxy import ( - _FunctionProxy, - _is_function_or_method, +from .utils import ( _Unusable, get_final_type_map, get_intermediate_type_map, get_registered_functions, ) + from ._wrappers import wrapped_estimators from cuml.internals import logger @@ -77,7 +75,7 @@ class DeducedMode(NamedTuple): def deduce_cuml_accel_mode(slow_lib: str, fast_lib: str) -> DeducedMode: """ - Determine if cudf.pandas should use the requested fast library. + Determine if the ModuleAccelerator should use the requested fast library. Parameters ---------- @@ -615,9 +613,9 @@ def install( with ImportLock(): logger.debug("Module Accelerator Install") logger.debug(f"destination_module: {destination_module}") - logger.debug(f"fast_lib: {fast_lib}") - logger.debug(f"slow_lib: {slow_lib}") - logger.info("Non Estimator Function Dispatching disabled...") + logger.debug(f"Accelerator library: {fast_lib}") + logger.debug(f"Original library: {slow_lib}") + logger.debug("Non Estimator Function Dispatching disabled...") if destination_module != slow_lib: raise RuntimeError( f"Destination module '{destination_module}' must match" diff --git a/python/cuml/cuml/experimental/accel/utils.py b/python/cuml/cuml/experimental/accel/utils.py new file mode 100644 index 0000000000..f8a8c24438 --- /dev/null +++ b/python/cuml/cuml/experimental/accel/utils.py @@ -0,0 +1,65 @@ +# +# Copyright (c) 2024, NVIDIA CORPORATION. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import functools + +from typing import Any + + +class _Unusable: + """ + A totally unusable type. When a "fast" object is not available, + it's useful to set it to _Unusable() so that any operations + on it fail, and ensure fallback to the corresponding + "slow" object. + """ + + def __call__(self, *args: Any, **kwds: Any) -> Any: + raise NotImplementedError( + "Fast implementation not available. " + "Falling back to the slow implementation" + ) + + def __getattribute__(self, name: str) -> Any: + if name in {"__class__"}: # needed for type introspection + return super().__getattribute__(name) + raise TypeError("Unusable type. Falling back to the slow object") + + def __repr__(self) -> str: + raise AttributeError("Unusable type. Falling back to the slow object") + + +@functools.lru_cache(maxsize=None) +def get_final_type_map(): + """ + Return the mapping of all known fast and slow final types to their + corresponding proxy types. + """ + return dict() + + +@functools.lru_cache(maxsize=None) +def get_intermediate_type_map(): + """ + Return a mapping of all known fast and slow intermediate types to their + corresponding proxy types. + """ + return dict() + + +@functools.lru_cache(maxsize=None) +def get_registered_functions(): + return dict() \ No newline at end of file diff --git a/python/cuml/cuml/internals/base.pyx b/python/cuml/cuml/internals/base.pyx index bbb73f1025..db129bb8f0 100644 --- a/python/cuml/cuml/internals/base.pyx +++ b/python/cuml/cuml/internals/base.pyx @@ -730,14 +730,12 @@ class UniversalBase(Base): # device_type = cuml.global_settings.device_type device_type = self._dispatch_selector(func_name, *args, **kwargs) - logger.debug(f"device_type {device_type}") - - # For GPU systems, we always dispatch inference + # For GPU systems, using the accelerator we always dispatch inference if GPU_ENABLED and ( device_type == DeviceType.device or func_name not in ['fit', 'fit_transform', 'fit_predict']): # call the function from the GPU estimator - logger.debug(f"Performing {func_name} in GPU") + logger.info(f"cuML: Performing {func_name} in GPU") return gpu_func(self, *args, **kwargs) # CPU case @@ -760,6 +758,7 @@ class UniversalBase(Base): # get the function from the GPU estimator cpu_func = getattr(self._cpu_model, func_name) # call the function from the GPU estimator + logger.info(f"cuML: Performing {func_name} in CPU") res = cpu_func(*args, **kwargs) # CPU training diff --git a/python/cuml/cuml/internals/global_settings.py b/python/cuml/cuml/internals/global_settings.py index ea899d91b1..8acbbc0e88 100644 --- a/python/cuml/cuml/internals/global_settings.py +++ b/python/cuml/cuml/internals/global_settings.py @@ -40,13 +40,17 @@ def __init__(self): default_device_type = DeviceType.host default_memory_type = MemoryType.host self.shared_state = { - "_output_type": None, "_device_type": default_device_type, "_memory_type": default_memory_type, + } + + self.shared_state.update( + { + "_output_type": None, "root_cm": None, + "accelerator_loaded": False } - else: - self.shared_state = {"_output_type": None, "root_cm": None} + ) _global_settings_data = _GlobalSettingsData() diff --git a/python/cuml/cuml/tests/experimental/accel/test_optuna.py b/python/cuml/cuml/tests/experimental/accel/test_optuna.py deleted file mode 100644 index 6471a58ebf..0000000000 --- a/python/cuml/cuml/tests/experimental/accel/test_optuna.py +++ /dev/null @@ -1,146 +0,0 @@ -# -# Copyright (c) 2024, NVIDIA CORPORATION. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# - -import pytest -import optuna -from sklearn.datasets import make_classification, make_regression -from sklearn.model_selection import train_test_split, cross_val_score -from sklearn.cluster import KMeans, DBSCAN -from sklearn.decomposition import PCA, TruncatedSVD -from sklearn.kernel_ridge import KernelRidge -from sklearn.linear_model import ( - LinearRegression, - LogisticRegression, - ElasticNet, - Ridge, - Lasso, -) -from sklearn.manifold import TSNE -from sklearn.neighbors import ( - NearestNeighbors, - KNeighborsClassifier, - KNeighborsRegressor, -) -import umap -import hdbscan - - -@pytest.fixture -def classification_data(): - X, y = make_classification(n_samples=100, n_features=10, random_state=42) - return train_test_split(X, y, test_size=0.2, random_state=42) - - -@pytest.fixture -def regression_data(): - X, y = make_regression( - n_samples=100, n_features=10, noise=0.1, random_state=42 - ) - return train_test_split(X, y, test_size=0.2, random_state=42) - - -def objective(trial, estimator, X_train, y_train): - params = {} - if hasattr(estimator, "C"): - params["C"] = trial.suggest_loguniform("C", 1e-3, 1e2) - if hasattr(estimator, "alpha"): - params["alpha"] = trial.suggest_loguniform("alpha", 1e-3, 1e2) - if hasattr(estimator, "l1_ratio"): - params["l1_ratio"] = trial.suggest_uniform("l1_ratio", 0.0, 1.0) - if hasattr(estimator, "n_neighbors"): - params["n_neighbors"] = trial.suggest_int("n_neighbors", 1, 15) - model = estimator.set_params(**params) - score = cross_val_score(model, X_train, y_train, cv=3).mean() - return score - - -@pytest.mark.parametrize( - "estimator", - [ - LogisticRegression(), - KNeighborsClassifier(), - ], -) -def test_classification_models_optuna(estimator, classification_data): - X_train, X_test, y_train, y_test = classification_data - study = optuna.create_study(direction="maximize") - study.optimize( - lambda trial: objective(trial, estimator, X_train, y_train), - n_trials=10, - ) - - assert study.best_value > 0.5, f"Failed to optimize {estimator}" - - -@pytest.mark.parametrize( - "estimator", - [ - LinearRegression(), - Ridge(), - Lasso(), - ElasticNet(), - KernelRidge(), - KNeighborsRegressor(), - ], -) -def test_regression_models_optuna(estimator, regression_data): - X_train, X_test, y_train, y_test = regression_data - study = optuna.create_study(direction="minimize") - study.optimize( - lambda trial: objective(trial, estimator, X_train, y_train), - n_trials=10, - ) - assert study.best_value < 1.0, f"Failed to optimize {estimator}" - - -@pytest.mark.parametrize( - "clustering_method", - [ - KMeans(n_clusters=3, random_state=42), - DBSCAN(), - hdbscan.HDBSCAN(min_cluster_size=5), - ], -) -def test_clustering_models(clustering_method, classification_data): - X_train, X_test, y_train, y_test = classification_data - clustering_method.fit(X_train) - assert True, f"{clustering_method} successfully ran" - - -@pytest.mark.parametrize( - "dimensionality_reduction_method", - [ - PCA(n_components=5), - TruncatedSVD(n_components=5), - umap.UMAP(n_components=5), - TSNE(n_components=2), - ], -) -def test_dimensionality_reduction( - dimensionality_reduction_method, classification_data -): - X_train, X_test, y_train, y_test = classification_data - X_transformed = dimensionality_reduction_method.fit_transform(X_train) - assert ( - X_transformed.shape[1] <= 5 - ), f"{dimensionality_reduction_method} successfully reduced dimensions" - - -def test_nearest_neighbors(classification_data): - X_train, X_test, y_train, y_test = classification_data - nearest_neighbors = NearestNeighbors(n_neighbors=5) - nearest_neighbors.fit(X_train) - assert True, "NearestNeighbors successfully ran" From 881ada18cf230defb2a91d10a84c74bbaf5d3e11 Mon Sep 17 00:00:00 2001 From: Dante Gama Dessavre Date: Sun, 17 Nov 2024 22:52:51 -0600 Subject: [PATCH 19/22] Style fixes --- .../cuml/cuml/experimental/accel/__init__.py | 5 +- .../cuml/cuml/experimental/accel/__main__.py | 2 +- .../experimental/accel/estimator_proxy.py | 86 ++++++++++--------- python/cuml/cuml/experimental/accel/utils.py | 2 +- python/cuml/cuml/internals/global_settings.py | 6 +- 5 files changed, 52 insertions(+), 49 deletions(-) diff --git a/python/cuml/cuml/experimental/accel/__init__.py b/python/cuml/cuml/experimental/accel/__init__.py index 7dce9f7ef4..fd707e3a43 100644 --- a/python/cuml/cuml/experimental/accel/__init__.py +++ b/python/cuml/cuml/experimental/accel/__init__.py @@ -32,7 +32,6 @@ def install(): logger.set_level(logger.level_info) logger.set_pattern("%v") - logger.info("cuML: Installing experimental accelerator...") loader = ModuleAccelerator.install("sklearn", "cuml", "sklearn") loader_umap = ModuleAccelerator.install("umap", "cuml", "umap") @@ -42,7 +41,9 @@ def install(): ) if GlobalSettings().accelerator_loaded: - logger.info("cuML: experimental accelerator succesfully initialized...") + logger.info( + "cuML: experimental accelerator successfully initialized..." + ) else: logger.info("cuML: experimental accelerator failed to initialize...") diff --git a/python/cuml/cuml/experimental/accel/__main__.py b/python/cuml/cuml/experimental/accel/__main__.py index 408d4b2857..776e4cb620 100644 --- a/python/cuml/cuml/experimental/accel/__main__.py +++ b/python/cuml/cuml/experimental/accel/__main__.py @@ -34,7 +34,7 @@ @click.argument("args", nargs=-1) def main(module, strict, args): """ """ - + if strict: os.environ["CUML_ACCEL_STRICT_MODE"] = "ON" diff --git a/python/cuml/cuml/experimental/accel/estimator_proxy.py b/python/cuml/cuml/experimental/accel/estimator_proxy.py index ec72ae7d02..1aa9cc0f81 100644 --- a/python/cuml/cuml/experimental/accel/estimator_proxy.py +++ b/python/cuml/cuml/experimental/accel/estimator_proxy.py @@ -36,50 +36,50 @@ def intercept( ): """ Factory function that creates class definitions of ProxyEstimators that - accelerate estimators of the original class. + accelerate estimators of the original class. - This function dynamically creates a new class called `ProxyEstimator` that - inherits from the GPU-accelerated class in the `accelerated_module` - (e.g., cuML) and acts as a drop-in replacement for the original class in - `original_module` (e.g., scikit-learn). Then, this class can be used to + This function dynamically creates a new class called `ProxyEstimator` that + inherits from the GPU-accelerated class in the `accelerated_module` + (e.g., cuML) and acts as a drop-in replacement for the original class in + `original_module` (e.g., scikit-learn). Then, this class can be used to create instances of ProxyEstimators that dispatch to either library. **Design of the ProxyEstimator Class Inside** - + **`ProxyEstimator` Class:** - - The `ProxyEstimator` class inherits from the GPU-accelerated + - The `ProxyEstimator` class inherits from the GPU-accelerated class (`class_b`) obtained from the `accelerated_module`. - - It serves as a wrapper that adds additional functionality + - It serves as a wrapper that adds additional functionality to maintain compatibility with the original CPU-based estimator. Key methods and attributes: - - `__init__`: Initializes the proxy estimator, stores a + - `__init__`: Initializes the proxy estimator, stores a reference to the original class before ModuleAccelerator - replaces the original module, translates hyperparameters, + replaces the original module, translates hyperparameters, and initializes the parent (cuML) class. - - `__repr__` and `__str__`: Provide string representations + - `__repr__` and `__str__`: Provide string representations that reference the original CPU-based class. - - Attribute `_cpu_model_class`: Stores a reference to the + - Attribute `_cpu_model_class`: Stores a reference to the original CPU-based estimator class. - - Attribute `_gpuaccel`: Indicates whether GPU acceleration + - Attribute `_gpuaccel`: Indicates whether GPU acceleration is enabled. - - By designing the `ProxyEstimator` in this way, we can - seamlessly replace the original CPU-based estimator with a - GPU-accelerated version without altering the existing codebase. - The metaclass ensures that the class behaves and appears - like the original estimator, while the proxy class manages + - By designing the `ProxyEstimator` in this way, we can + seamlessly replace the original CPU-based estimator with a + GPU-accelerated version without altering the existing codebase. + The metaclass ensures that the class behaves and appears + like the original estimator, while the proxy class manages the underlying acceleration and compatibility. **Serialization/Pickling of ProxyEstimators** - Since pickle has strict rules about serializing classes, we cannot + Since pickle has strict rules about serializing classes, we cannot (reasonably) create a method that just pickles and unpickles a ProxyEstimat - as if it was just an instance of the original module. + as if it was just an instance of the original module. To overcome this limitation and offer compatibility between environments with acceleration and environments without, a ProxyEstimator serializes - *both* the underlying _cpu_model as well as the ProxyEstimator itself. - See the example below to see how it works in practice. - + *both* the underlying _cpu_model as well as the ProxyEstimator itself. + See the example below to see how it works in practice. + Parameters ---------- original_module : str @@ -94,17 +94,17 @@ class (`class_b`) obtained from the `accelerated_module`. Returns ------- - A class definition of ProxyEstimator that inherits from + A class definition of ProxyEstimator that inherits from the accelerated library class (cuML). Examples -------- >>> from module_accelerator import intercept - >>> ProxyEstimator = intercept('sklearn.linear_model', + >>> ProxyEstimator = intercept('sklearn.linear_model', ... 'cuml.linear_model', 'LinearRegression') >>> model = ProxyEstimator() >>> with open("ProxyEstimator.pkl", "wb") as f: - >>> # This saves two pickled files, a pickle corresponding to + >>> # This saves two pickled files, a pickle corresponding to >>> # the ProxyEstimator and a "ProxyEstimator_pickle.pkl" that is >>> # the CPU model pickled. >>> loaded = load(f) @@ -129,14 +129,15 @@ class ProxyEstimator(class_b): A proxy estimator class that wraps the accelerated estimator and provides compatibility with the original estimator interface. - The ProxyEstimator inherits from the accelerated estimator class and - wraps additional functionality to maintain compatibility with the original + The ProxyEstimator inherits from the accelerated estimator class and + wraps additional functionality to maintain compatibility with the original CPU-based estimator. - - It handles the translation of hyperparameters and the transfer of models + + It handles the translation of hyperparameters and the transfer of models between CPU and GPU. """ + def __init__(self, *args, **kwargs): self._cpu_model_class = ( original_class_a # Store a reference to the original class @@ -153,7 +154,7 @@ def __init__(self, *args, **kwargs): def __repr__(self): """ Return a formal string representation of the object. - + Returns ------- str @@ -165,7 +166,7 @@ def __repr__(self): def __str__(self): """ Return an informal string representation of the object. - + Returns ------- str @@ -204,7 +205,7 @@ def __reduce__(self): Returns ------- tuple - A tuple containing the callable to reconstruct the object + A tuple containing the callable to reconstruct the object and the arguments for reconstruction. Notes @@ -240,16 +241,17 @@ def __reduce__(self): return ProxyEstimator -def reconstruct_proxy( - original_module: str, - accelerated_module: str, - class_name: str, - args: Tuple, - kwargs: Dict): +def reconstruct_proxy( + original_module: str, + accelerated_module: str, + class_name: str, + args: Tuple, + kwargs: Dict, +): """ Function to enable pickling of ProxyEstimators since they are defined inside a function, which Pickle doesn't like without a function or something - that has an absolute import path like this function. + that has an absolute import path like this function. Parameters ---------- @@ -263,11 +265,11 @@ def reconstruct_proxy( Args of class to be deserialized (typically empty for ProxyEstimators) kwargs : Dict Keyword arguments to reconstruct the ProxyEstimator instance, typically - state from __setstate__ method. + state from __setstate__ method. Returns ------- - Instance of ProxyEstimator constructed with the kwargs passed to the function. + Instance of ProxyEstimator constructed with the kwargs passed to the function. """ # We probably don't need to intercept again here, since we already stored diff --git a/python/cuml/cuml/experimental/accel/utils.py b/python/cuml/cuml/experimental/accel/utils.py index f8a8c24438..c2f26245f2 100644 --- a/python/cuml/cuml/experimental/accel/utils.py +++ b/python/cuml/cuml/experimental/accel/utils.py @@ -62,4 +62,4 @@ def get_intermediate_type_map(): @functools.lru_cache(maxsize=None) def get_registered_functions(): - return dict() \ No newline at end of file + return dict() diff --git a/python/cuml/cuml/internals/global_settings.py b/python/cuml/cuml/internals/global_settings.py index 8acbbc0e88..2779266a60 100644 --- a/python/cuml/cuml/internals/global_settings.py +++ b/python/cuml/cuml/internals/global_settings.py @@ -1,5 +1,5 @@ # -# Copyright (c) 2021-2023, NVIDIA CORPORATION. +# Copyright (c) 2021-2024, NVIDIA CORPORATION. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -46,9 +46,9 @@ def __init__(self): self.shared_state.update( { - "_output_type": None, + "_output_type": None, "root_cm": None, - "accelerator_loaded": False + "accelerator_loaded": False, } ) From b32495dc5654aaa4b7780560f35d3ceb7e5eb75e Mon Sep 17 00:00:00 2001 From: Dante Gama Dessavre Date: Sun, 17 Nov 2024 23:10:33 -0600 Subject: [PATCH 20/22] ENH simplification of hyperparam translator method as suggested by PR review --- python/cuml/cuml/cluster/dbscan.pyx | 10 ++--- .../experimental/accel/estimator_proxy.py | 4 -- python/cuml/cuml/internals/base.pyx | 42 ++++++------------- python/cuml/cuml/linear_model/elastic_net.pyx | 2 - .../cuml/linear_model/linear_regression.pyx | 3 +- 5 files changed, 18 insertions(+), 43 deletions(-) diff --git a/python/cuml/cuml/cluster/dbscan.pyx b/python/cuml/cuml/cluster/dbscan.pyx index f5b0b8d85d..57462028ad 100644 --- a/python/cuml/cuml/cluster/dbscan.pyx +++ b/python/cuml/cuml/cluster/dbscan.pyx @@ -227,15 +227,15 @@ class DBSCAN(UniversalBase, _hyperparam_interop_translator = { "metric": { - "manhattan": "dispatch", - "chebyshev": "dispatch", - "minkowski": "dispatch", + "manhattan": "NotImplemented", + "chebyshev": "NotImplemented", + "minkowski": "NotImplemented", }, "algorithm": { "auto": "brute", - "ball_tree": "dispatch", - "kd_tree": "dispatch", + "ball_tree": "NotImplemented", + "kd_tree": "NotImplemented", }, } diff --git a/python/cuml/cuml/experimental/accel/estimator_proxy.py b/python/cuml/cuml/experimental/accel/estimator_proxy.py index 1aa9cc0f81..c25fec5fc8 100644 --- a/python/cuml/cuml/experimental/accel/estimator_proxy.py +++ b/python/cuml/cuml/experimental/accel/estimator_proxy.py @@ -24,10 +24,6 @@ from typing import Optional, Tuple, Dict -# currently we just use this dictionary for debugging purposes -patched_classes = {} - - def intercept( original_module: str, accelerated_module: str, diff --git a/python/cuml/cuml/internals/base.pyx b/python/cuml/cuml/internals/base.pyx index db129bb8f0..2861b28073 100644 --- a/python/cuml/cuml/internals/base.pyx +++ b/python/cuml/cuml/internals/base.pyx @@ -203,10 +203,6 @@ class Base(TagsMixin, del base # optional! """ - _base_hyperparam_interop_translator = { - "n_jobs": "accept" - } - _hyperparam_interop_translator = {} def __init__(self, *, @@ -484,37 +480,23 @@ class Base(TagsMixin, This method is meant to do checks and translations of hyperparameters at estimator creating time. Each children estimator can override the method, returning either - modifier **kwargs with equivalent options, or + modifier **kwargs with equivalent options, or setting gpuaccel to False + for hyperaparameters not supported by cuML yet. """ - gpu_hyperparams = cls._get_param_names() - kwargs.pop("self", None) gpuaccel = True - for arg, value in kwargs.items(): - - if arg in cls._base_hyperparam_interop_translator: - if cls._base_hyperparam_interop_translator[arg] == "accept": - gpuaccel = gpuaccel and True - - elif arg in cls._hyperparam_interop_translator: - if value in cls._hyperparam_interop_translator[arg]: - if cls._hyperparam_interop_translator[arg][value] == "accept": - gpuaccel = gpuaccel and True - elif cls._hyperparam_interop_translator[arg][value] == "dispatch": + # Copy it so we can modify it + translations = dict(cls.__bases__[0]._hyperparam_interop_translator) + # Allow the derived class to overwrite the base class + translations.update(cls._hyperparam_interop_translator) + for parameter_name, value in kwargs.items(): + # maybe clean up using: translations.get(parameter_name, {}).get(value, None)? + if parameter_name in translations: + if value in translations[parameter_name]: + if translations[parameter_name][value] == "NotImplemented": gpuaccel = False else: - kwargs[arg] = cls._hyperparam_interop_translator[arg][value] - gpuaccel = gpuaccel and True - # todo (dgd): improve message - logger.warn("Value changed") - - else: - gpuaccel = gpuaccel and True - - # else: - # gpuaccel = False + kwargs[parameter_name] = translations[parameter_name][value] - # we need to enable this if we enable translation for regular cuML - # kwargs["_gpuaccel"] = gpuaccel return kwargs, gpuaccel diff --git a/python/cuml/cuml/linear_model/elastic_net.pyx b/python/cuml/cuml/linear_model/elastic_net.pyx index 435778adad..cdb34f6caa 100644 --- a/python/cuml/cuml/linear_model/elastic_net.pyx +++ b/python/cuml/cuml/linear_model/elastic_net.pyx @@ -153,11 +153,9 @@ class ElasticNet(UniversalBase, _hyperparam_interop_translator = { "positive": { True: "dispatch", - False: "accept", }, "warm_start": { True: "dispatch", - False: "accept", }, } diff --git a/python/cuml/cuml/linear_model/linear_regression.pyx b/python/cuml/cuml/linear_model/linear_regression.pyx index 3a53a915db..f1b64602b3 100644 --- a/python/cuml/cuml/linear_model/linear_regression.pyx +++ b/python/cuml/cuml/linear_model/linear_regression.pyx @@ -270,8 +270,7 @@ class LinearRegression(LinearPredictMixin, _hyperparam_interop_translator = { "positive": { - True: "dispatch", - False: "accept", + True: "NotImplemented", }, } From 26f64ad4293ded96c4acd4d02e32f0fe04baab0d Mon Sep 17 00:00:00 2001 From: Dante Gama Dessavre Date: Sun, 17 Nov 2024 23:12:21 -0600 Subject: [PATCH 21/22] FIX correct value in elastic net dict --- python/cuml/cuml/linear_model/elastic_net.pyx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/python/cuml/cuml/linear_model/elastic_net.pyx b/python/cuml/cuml/linear_model/elastic_net.pyx index cdb34f6caa..7b212b21c9 100644 --- a/python/cuml/cuml/linear_model/elastic_net.pyx +++ b/python/cuml/cuml/linear_model/elastic_net.pyx @@ -152,10 +152,10 @@ class ElasticNet(UniversalBase, _hyperparam_interop_translator = { "positive": { - True: "dispatch", + True: "NotImplemented", }, "warm_start": { - True: "dispatch", + True: "NotImplemented", }, } From de519493082038b340f138f9eaac04c73edbcc68 Mon Sep 17 00:00:00 2001 From: Dante Gama Dessavre Date: Mon, 18 Nov 2024 20:15:54 -0600 Subject: [PATCH 22/22] FEA Use new ProxyModule simpler mechanism --- .../cuml/cuml/experimental/accel/__init__.py | 28 +- .../cuml/cuml/experimental/accel/__main__.py | 3 +- .../experimental/accel/estimator_proxy.py | 193 +++-- .../experimental/accel/module_accelerator.py | 662 ------------------ python/cuml/cuml/experimental/accel/utils.py | 65 -- python/cuml/cuml/internals/global_settings.py | 4 +- 6 files changed, 151 insertions(+), 804 deletions(-) delete mode 100644 python/cuml/cuml/experimental/accel/module_accelerator.py delete mode 100644 python/cuml/cuml/experimental/accel/utils.py diff --git a/python/cuml/cuml/experimental/accel/__init__.py b/python/cuml/cuml/experimental/accel/__init__.py index fd707e3a43..8e2fd23fb3 100644 --- a/python/cuml/cuml/experimental/accel/__init__.py +++ b/python/cuml/cuml/experimental/accel/__init__.py @@ -15,6 +15,8 @@ # +import importlib + from .magics import load_ipython_extension from cuml.internals import logger @@ -23,27 +25,29 @@ __all__ = ["load_ipython_extension", "install"] -def install(): - """ - Enable cuML Accelerator Mode. - """ - from .module_accelerator import ModuleAccelerator +def _install_for_library(library_name): + importlib.import_module(f"._wrappers.{library_name}", __name__) + +def install(): + """Enable cuML Accelerator Mode.""" logger.set_level(logger.level_info) logger.set_pattern("%v") + logger.info("cuML: Installing experimental accelerator...") - loader = ModuleAccelerator.install("sklearn", "cuml", "sklearn") - loader_umap = ModuleAccelerator.install("umap", "cuml", "umap") - loader_hdbscan = ModuleAccelerator.install("hdbscan", "cuml", "hdbscan") + loader_sklearn = _install_for_library(library_name="sklearn") + loader_umap = _install_for_library(library_name="umap") + loader_hdbscan = _install_for_library(library_name="hdbscan") + GlobalSettings().accelerator_loaded = all( - var is not None for var in [loader, loader_umap, loader_hdbscan] + [loader_sklearn, loader_umap, loader_hdbscan] ) + GlobalSettings().accelerator_active = True + if GlobalSettings().accelerator_loaded: - logger.info( - "cuML: experimental accelerator successfully initialized..." - ) + logger.info("cuML: experimental accelerator succesfully initialized...") else: logger.info("cuML: experimental accelerator failed to initialize...") diff --git a/python/cuml/cuml/experimental/accel/__main__.py b/python/cuml/cuml/experimental/accel/__main__.py index 776e4cb620..24aae434cf 100644 --- a/python/cuml/cuml/experimental/accel/__main__.py +++ b/python/cuml/cuml/experimental/accel/__main__.py @@ -33,8 +33,7 @@ ) @click.argument("args", nargs=-1) def main(module, strict, args): - """ """ - + if strict: os.environ["CUML_ACCEL_STRICT_MODE"] = "ON" diff --git a/python/cuml/cuml/experimental/accel/estimator_proxy.py b/python/cuml/cuml/experimental/accel/estimator_proxy.py index c25fec5fc8..7a906c2e4e 100644 --- a/python/cuml/cuml/experimental/accel/estimator_proxy.py +++ b/python/cuml/cuml/experimental/accel/estimator_proxy.py @@ -15,15 +15,85 @@ # -import cuml import inspect +import sys +import types from cuml.internals.mem_type import MemoryType from cuml.internals import logger +from cuml.internals.global_settings import GlobalSettings from cuml.internals.safe_imports import gpu_only_import, cpu_only_import -from typing import Optional, Tuple, Dict +from typing import Optional, Tuple, Dict, Any, Type, List +class ProxyModule: + """ + A proxy module that dynamically replaces specified classes with proxy estimators + based on GlobalSettings. + + Parameters + ---------- + original_module : module + The module to be proxied. + Attributes + ---------- + _original_module : module + The original module being proxied. + _proxy_estimators : dict of str to type + A dictionary mapping accelerated class names to their proxy estimators. + """ + + def __init__(self, original_module: types.ModuleType) -> None: + """Initialize the ProxyModule with the original module.""" + self._original_module = original_module + self._proxy_estimators: Dict[str, Type[Any]] = {} + + def add_estimator(self, class_name: str, proxy_estimator: Type[Any]) -> None: + """ + Add a proxy estimator for a specified class name. + Parameters + ---------- + class_name : str + The name of the class in the original module to be replaced. + proxy_estimator : type + The proxy estimator class to use as a replacement. + """ + self._proxy_estimators[class_name] = proxy_estimator + + def __getattr__(self, name: str) -> Any: + """ + Intercept attribute access on the proxy module. + If the attribute name is in the proxy estimators and the accelerator is active, + return the proxy estimator; otherwise, return the attribute from the original module. + Parameters + ---------- + name : str + The name of the attribute being accessed. + Returns + ------- + Any + The attribute from the proxy estimator or the original module. + """ + if name in self._proxy_estimators: + use_proxy = getattr(GlobalSettings(), 'accelerator_active', False) + if use_proxy: + return self._proxy_estimators[name] + else: + return getattr(self._original_module, name) + else: + return getattr(self._original_module, name) + + def __dir__(self) -> List[str]: + """ + Provide a list of attributes available in the proxy module. + Returns + ------- + list of str + A list of attribute names from the original module. + """ + return dir(self._original_module) + + def intercept( original_module: str, accelerated_module: str, @@ -32,50 +102,52 @@ def intercept( ): """ Factory function that creates class definitions of ProxyEstimators that - accelerate estimators of the original class. + accelerate estimators of the original class. - This function dynamically creates a new class called `ProxyEstimator` that - inherits from the GPU-accelerated class in the `accelerated_module` - (e.g., cuML) and acts as a drop-in replacement for the original class in - `original_module` (e.g., scikit-learn). Then, this class can be used to + This function dynamically creates a new class called `ProxyEstimator` that + inherits from the GPU-accelerated class in the `accelerated_module` + (e.g., cuML) and acts as a drop-in replacement for the original class in + `original_module` (e.g., scikit-learn). Then, this class can be used to create instances of ProxyEstimators that dispatch to either library. **Design of the ProxyEstimator Class Inside** - + **`ProxyEstimator` Class:** - - The `ProxyEstimator` class inherits from the GPU-accelerated + - The `ProxyEstimator` class inherits from the GPU-accelerated class (`class_b`) obtained from the `accelerated_module`. - - It serves as a wrapper that adds additional functionality + - It serves as a wrapper that adds additional functionality to maintain compatibility with the original CPU-based estimator. Key methods and attributes: - - `__init__`: Initializes the proxy estimator, stores a + - `__init__`: Initializes the proxy estimator, stores a reference to the original class before ModuleAccelerator - replaces the original module, translates hyperparameters, + replaces the original module, translates hyperparameters, and initializes the parent (cuML) class. - - `__repr__` and `__str__`: Provide string representations + - `__repr__` and `__str__`: Provide string representations that reference the original CPU-based class. - - Attribute `_cpu_model_class`: Stores a reference to the + - Attribute `_cpu_model_class`: Stores a reference to the original CPU-based estimator class. - - Attribute `_gpuaccel`: Indicates whether GPU acceleration + - Attribute `_gpuaccel`: Indicates whether GPU acceleration is enabled. - - By designing the `ProxyEstimator` in this way, we can - seamlessly replace the original CPU-based estimator with a - GPU-accelerated version without altering the existing codebase. - The metaclass ensures that the class behaves and appears - like the original estimator, while the proxy class manages + - By designing the `ProxyEstimator` in this way, we can + seamlessly replace the original CPU-based estimator with a + GPU-accelerated version without altering the existing codebase. + The metaclass ensures that the class behaves and appears + like the original estimator, while the proxy class manages the underlying acceleration and compatibility. **Serialization/Pickling of ProxyEstimators** - Since pickle has strict rules about serializing classes, we cannot + Since pickle has strict rules about serializing classes, we cannot (reasonably) create a method that just pickles and unpickles a ProxyEstimat - as if it was just an instance of the original module. + as if it was just an instance of the original module. - To overcome this limitation and offer compatibility between environments - with acceleration and environments without, a ProxyEstimator serializes - *both* the underlying _cpu_model as well as the ProxyEstimator itself. - See the example below to see how it works in practice. + Therefore, doing a pickling of ProxyEstimator will make it serialize to + a file that can be opened in systems with cuML installed (CPU or GPU). + To serialize for non cuML systems, the to_sklearn and from_sklearn APIs + are being introduced in + https://github.com/rapidsai/cuml/pull/6102 + Parameters ---------- original_module : str @@ -90,20 +162,15 @@ class (`class_b`) obtained from the `accelerated_module`. Returns ------- - A class definition of ProxyEstimator that inherits from + A class definition of ProxyEstimator that inherits from the accelerated library class (cuML). Examples -------- >>> from module_accelerator import intercept - >>> ProxyEstimator = intercept('sklearn.linear_model', + >>> ProxyEstimator = intercept('sklearn.linear_model', ... 'cuml.linear_model', 'LinearRegression') >>> model = ProxyEstimator() - >>> with open("ProxyEstimator.pkl", "wb") as f: - >>> # This saves two pickled files, a pickle corresponding to - >>> # the ProxyEstimator and a "ProxyEstimator_pickle.pkl" that is - >>> # the CPU model pickled. - >>> loaded = load(f) """ @@ -125,15 +192,14 @@ class ProxyEstimator(class_b): A proxy estimator class that wraps the accelerated estimator and provides compatibility with the original estimator interface. - The ProxyEstimator inherits from the accelerated estimator class and - wraps additional functionality to maintain compatibility with the original + The ProxyEstimator inherits from the accelerated estimator class and + wraps additional functionality to maintain compatibility with the original CPU-based estimator. - - It handles the translation of hyperparameters and the transfer of models + + It handles the translation of hyperparameters and the transfer of models between CPU and GPU. """ - def __init__(self, *args, **kwargs): self._cpu_model_class = ( original_class_a # Store a reference to the original class @@ -150,7 +216,7 @@ def __init__(self, *args, **kwargs): def __repr__(self): """ Return a formal string representation of the object. - + Returns ------- str @@ -162,7 +228,7 @@ def __repr__(self): def __str__(self): """ Return an informal string representation of the object. - + Returns ------- str @@ -201,22 +267,13 @@ def __reduce__(self): Returns ------- tuple - A tuple containing the callable to reconstruct the object + A tuple containing the callable to reconstruct the object and the arguments for reconstruction. Notes ----- Disables the module accelerator during pickling to ensure correct serialization. """ - import pickle - from .module_accelerator import disable_module_accelerator - - with disable_module_accelerator(): - filename = self.__class__.__name__ + "_sklearn" - with open(filename, "wb") as f: - self._check_cpu_model() - pickle.dump(self._cpu_model, f) - return ( reconstruct_proxy, ( @@ -227,27 +284,39 @@ def __reduce__(self): self.__getstate__(), ), ) - + logger.debug( f"Created proxy estimator: ({module_b}, {original_class_name}, {ProxyEstimator})" ) setattr(module_b, original_class_name, ProxyEstimator) - setattr(module_a, original_class_name, ProxyEstimator) + accelerated_modules = GlobalSettings().accelerated_modules + + if original_module in accelerated_modules: + proxy_module = accelerated_modules[original_module] + else: + proxy_module = ProxyModule(original_module=module_a) + GlobalSettings().accelerated_modules[original_module] = proxy_module + + proxy_module.add_estimator( + class_name=original_class_name, + proxy_estimator=ProxyEstimator + ) + + sys.modules[original_module] = proxy_module return ProxyEstimator -def reconstruct_proxy( - original_module: str, - accelerated_module: str, - class_name: str, - args: Tuple, - kwargs: Dict, -): +def reconstruct_proxy( + original_module: str, + accelerated_module: str, + class_name: str, + args: Tuple, + kwargs: Dict): """ Function to enable pickling of ProxyEstimators since they are defined inside a function, which Pickle doesn't like without a function or something - that has an absolute import path like this function. + that has an absolute import path like this function. Parameters ---------- @@ -261,11 +330,11 @@ def reconstruct_proxy( Args of class to be deserialized (typically empty for ProxyEstimators) kwargs : Dict Keyword arguments to reconstruct the ProxyEstimator instance, typically - state from __setstate__ method. + state from __setstate__ method. Returns ------- - Instance of ProxyEstimator constructed with the kwargs passed to the function. + Instance of ProxyEstimator constructed with the kwargs passed to the function. """ # We probably don't need to intercept again here, since we already stored diff --git a/python/cuml/cuml/experimental/accel/module_accelerator.py b/python/cuml/cuml/experimental/accel/module_accelerator.py deleted file mode 100644 index 4441a9435a..0000000000 --- a/python/cuml/cuml/experimental/accel/module_accelerator.py +++ /dev/null @@ -1,662 +0,0 @@ -# -# Copyright (c) 2024, NVIDIA CORPORATION. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# - -from __future__ import annotations - -import contextlib -import functools -import importlib -import importlib.abc -import importlib.machinery -import os -import pathlib -import sys -import threading -import warnings -from abc import abstractmethod -from importlib._bootstrap import _ImportLockContext as ImportLock -from types import ModuleType -from typing import Any, ContextManager, NamedTuple - -from typing_extensions import Self -from .utils import ( - _Unusable, - get_final_type_map, - get_intermediate_type_map, - get_registered_functions, -) - -from ._wrappers import wrapped_estimators - -from cuml.internals import logger - - -def rename_root_module(module: str, root: str, new_root: str) -> str: - """ - Rename a module to a new root. - - Parameters - ---------- - module - Module to rename - root - Original root - new_root - New root - - Returns - ------- - New module name (if it matches root) otherwise original name. - """ - if module.startswith(root): - return new_root + module[len(root) :] - else: - return module - - -class DeducedMode(NamedTuple): - use_fast_lib: bool - slow_lib: str - fast_lib: str - - -def deduce_cuml_accel_mode(slow_lib: str, fast_lib: str) -> DeducedMode: - """ - Determine if the ModuleAccelerator should use the requested fast library. - - Parameters - ---------- - slow_lib - Name of the slow library - fast_lib - Name of the fast library - - Returns - ------- - Whether the fast library is being used, and the resulting names of - the "slow" and "fast" libraries. - """ - if "CUML_FALLBACK_MODE" not in os.environ: - try: - importlib.import_module(fast_lib) - return DeducedMode( - use_fast_lib=True, slow_lib=slow_lib, fast_lib=fast_lib - ) - except Exception as e: - warnings.warn( - f"Exception encountered importing {fast_lib}: {e}." - f"Falling back to only using {slow_lib}." - ) - return DeducedMode( - use_fast_lib=False, slow_lib=slow_lib, fast_lib=slow_lib - ) - - -class ModuleAcceleratorBase( - importlib.abc.MetaPathFinder, importlib.abc.Loader -): - _instance: ModuleAcceleratorBase | None = None - mod_name: str - fast_lib: str - slow_lib: str - - # When walking the module tree and wrapping module attributes, - # we often will come across the same object more than once. We - # don't want to create separate wrappers for each - # instance, so we keep a registry of all module attributes - # that we can look up to see if we have already wrapped an - # attribute before - _wrapped_objs: dict[Any, Any] - - def __new__( - cls, - mod_name: str, - fast_lib: str, - slow_lib: str, - ): - """Build a custom module finder that will provide wrapped modules - on demand. - - Parameters - ---------- - mod_name - Import name to deliver modules under. - fast_lib - Name of package that provides "fast" implementation - slow_lib - Name of package that provides "slow" fallback implementation - """ - # todo (dgd) replace this check for raising only when initializing - # a loader for an already module-accelerated slow_lib - # if ModuleAcceleratorBase._instance is not None: - # raise RuntimeError( - # "Only one instance of ModuleAcceleratorBase allowed" - # ) - self = object.__new__(cls) - self.mod_name = mod_name - self.fast_lib = fast_lib - self.slow_lib = slow_lib - - # When walking the module tree and wrapping module attributes, - # we often will come across the same object more than once. We - # don't want to create separate wrappers for each - # instance, so we keep a registry of all module attributes - # that we can look up to see if we have already wrapped an - # attribute before - self._wrapped_objs = {} - self._wrapped_objs.update(get_final_type_map()) - self._wrapped_objs.update(get_intermediate_type_map()) - self._wrapped_objs.update(get_registered_functions()) - - ModuleAcceleratorBase._instance = self - return self - - def __repr__(self) -> str: - return ( - f"{self.__class__.__name__}" - f"(fast={self.fast_lib}, slow={self.slow_lib})" - ) - - def find_spec( - self, fullname: str, path, target=None - ) -> importlib.machinery.ModuleSpec | None: - """Provide ourselves as a module loader. - - Parameters - ---------- - fullname - Name of module to be imported, if it starts with the name - that we are using to wrap, we will deliver ourselves as a - loader, otherwise defer to the standard Python loaders. - - Returns - ------- - A ModuleSpec with ourself as loader if we're interposing, - otherwise None to pass off to the next loader. - """ - if fullname == self.mod_name or fullname.startswith( - f"{self.mod_name}." - ): - return importlib.machinery.ModuleSpec( - name=fullname, - loader=self, - # Note, this influences the repr of the module, so we may want - # to change it if we ever want to control that. - origin=None, - loader_state=None, - is_package=True, - ) - return None - - def create_module(self, spec) -> ModuleType | None: - return None - - def exec_module(self, mod: ModuleType): - # importlib calls this function with the global import lock held. - self._populate_module(mod) - - @abstractmethod - def disabled(self) -> ContextManager: - pass - - def _postprocess_module( - self, - mod: ModuleType, - slow_mod: ModuleType, - fast_mod: ModuleType | None, - ) -> ModuleType: - """Ensure that the wrapped module satisfies required invariants. - - Parameters - ---------- - mod - Wrapped module to postprocess - slow_mod - Slow version that we are mimicking - fast_mod - Fast module that provides accelerated implementations (may - be None - - Returns - ------- - Checked and validated module - - Notes - ----- - The implementation of fast-slow proxies imposes certain - requirements on the wrapped modules that it delivers. This - function encodes those requirements and raises if the module - does not satisfy them. - - This post-processing routine should be kept up to date with any - requirements encoded by fast_slow_proxy.py - """ - mod.__dict__["_fsproxy_slow"] = slow_mod - if fast_mod is not None: - mod.__dict__["_fsproxy_fast"] = fast_mod - return mod - - @abstractmethod - def _populate_module(self, mod: ModuleType) -> ModuleType: - """Populate given module with appropriate attributes. - - This traverses the attributes of the slow module corresponding - to mod and mirrors those in the provided module in a wrapped - mode that attempts to execute them using the fast module first. - - Parameters - ---------- - mod - Module to populate - - Returns - ------- - ModuleType - Populated module - - Notes - ----- - In addition to the attributes of the slow module, - the returned module must have the following attributes: - - - '_fsproxy_slow': the corresponding slow module - - '_fsproxy_fast': the corresponding fast module - - This is necessary for correct rewriting of UDFs when calling - to the respective fast/slow libraries. - - The necessary invariants are checked and applied in - :meth:`_postprocess_module`. - """ - pass - - def _wrap_attribute( - self, - slow_attr: Any, - fast_attr: Any | _Unusable, - name: str, - ) -> Any: - """ - Return the wrapped version of an attribute. - - Parameters - ---------- - slow_attr : Any - The attribute from the slow module - fast_mod : Any (or None) - The same attribute from the fast module, if it exists - name - Name of attribute - - Returns - ------- - Wrapped attribute - """ - wrapped_attr: Any - # TODO: what else should we make sure not to get from the fast - # library? - if name in {"__all__", "__dir__", "__file__", "__doc__"}: - wrapped_attr = slow_attr - elif self.fast_lib == self.slow_lib: - # no need to create a fast-slow wrapper - wrapped_attr = slow_attr - if any( - [ - slow_attr in get_registered_functions(), - slow_attr in get_final_type_map(), - slow_attr in get_intermediate_type_map(), - ] - ): - # attribute already registered in self._wrapped_objs - return self._wrapped_objs[slow_attr] - if isinstance(slow_attr, ModuleType) and slow_attr.__name__.startswith( - self.slow_lib - ): - # attribute is a submodule of the slow library, - # replace the string "{slow_lib}" in the submodule's - # name with "{self.mod_name}" - # now, attempt to import the wrapped module, which will - # recursively wrap all of its attributes: - return importlib.import_module( - rename_root_module( - slow_attr.__name__, self.slow_lib, self.mod_name - ) - ) - if slow_attr in self._wrapped_objs: - if type(fast_attr) is _Unusable: - # we don't want to replace a wrapped object that - # has a usable fast object with a wrapped object - # with a an unusable fast object. - return self._wrapped_objs[slow_attr] - if name in wrapped_estimators: - - mod = importlib.import_module(wrapped_estimators[name][0]) - wrapped_attr = getattr(mod, wrapped_estimators[name][1]) - logger.debug(f"Patched {wrapped_attr}") - # elif _is_function_or_method(slow_attr): - # wrapped_attr = _FunctionProxy(fast_attr, slow_attr) - else: - wrapped_attr = slow_attr - return wrapped_attr - - @classmethod - @abstractmethod - def install( - cls, destination_module: str, fast_lib: str, slow_lib: str - ) -> Self | None: - """ - Install the loader in sys.meta_path. - - Parameters - ---------- - destination_module - Name under which the importer will kick in - fast_lib - Name of fast module - slow_lib - Name of slow module we are trying to mimic - - Returns - ------- - Instance of the class (or None if the loader was not installed) - - Notes - ----- - This function is idempotent. If called with the same arguments - a second time, it does not create a new loader, but instead - returns the existing loader from ``sys.meta_path``. - - """ - pass - - -class ModuleAccelerator(ModuleAcceleratorBase): - """ - A finder and loader that produces "accelerated" modules. - - When someone attempts to import the specified slow library with - this finder enabled, we intercept the import and deliver an - equivalent, accelerated, version of the module. This provides - attributes and modules that check if they are being used from - "within" the slow (or fast) library themselves. If this is the - case, the implementation is forwarded to the actual slow library - implementation, otherwise a proxy implementation is used (which - attempts to call the fast version first). - """ - - _denylist: tuple[str] - _use_fast_lib: bool - _use_fast_lib_lock: threading.RLock - _module_cache_prefix: str = "_slow_lib_" - - # TODO: Add possibility for either an explicit allow-list of - # libraries where the slow_lib should be wrapped, or, more likely - # a block-list that adds to the set of libraries where no proxying occurs. - def __new__( - cls, - fast_lib, - slow_lib, - ): - self = super().__new__( - cls, - slow_lib, - fast_lib, - slow_lib, - ) - # Import the real versions of the modules so that we can - # rewrite the sys.modules cache. - slow_module = importlib.import_module(slow_lib) - fast_module = importlib.import_module(fast_lib) - # Note, this is not thread safe, but install() below grabs the - # lock for the whole initialisation and modification of - # sys.meta_path. - for mod in sys.modules.copy(): - if mod.startswith(self.slow_lib): - sys.modules[self._module_cache_prefix + mod] = sys.modules[mod] - del sys.modules[mod] - self._denylist = (*slow_module.__path__, *fast_module.__path__) - - # Lock to manage temporarily disabling delivering wrapped attributes - self._use_fast_lib_lock = threading.RLock() - self._use_fast_lib = True - return self - - def _populate_module(self, mod: ModuleType): - mod_name = mod.__name__ - - # Here we attempt to import "_fsproxy_slow_lib.x.y.z", but - # "_fsproxy_slow_lib" does not exist anywhere as a real file, so - # how does this work? - # The importer attempts to import ".z" by first importing - # "_fsproxy_slow_lib.x.y", this recurses until we find - # "_fsproxy_slow_lib.x" (say), which does exist because we set that up - # in __init__. Now the importer looks at the __path__ - # attribute of "x" and uses that to find the relative location - # to look for "y". This __path__ points to the real location - # of "slow_lib.x". So, as long as we rewire the _already imported_ - # slow_lib modules in sys.modules to _fsproxy_slow_lib, when we - # get here this will find the right thing. - # The above exposition is for lazily imported submodules (e.g. - # avoiding circular imports by putting an import at function - # level). For everything that is eagerly imported when we do - # "import slow_lib" this import line is trivial because we - # immediately pull the correct result out of sys.modules. - - # mod_name, - # self.slow_lib, - # self._module_cache_prefix + self.slow_lib, - # )}") - slow_mod = importlib.import_module( - rename_root_module( - mod_name, - self.slow_lib, - self._module_cache_prefix + self.slow_lib, - ) - ) - try: - fast_mod = importlib.import_module( - rename_root_module(mod_name, self.slow_lib, self.fast_lib) - ) - except Exception: - fast_mod = None - - # The version that will be used if called within a denylist - # package - real_attributes = {} - # The version that will be used outside denylist packages - for key in slow_mod.__dir__(): - with warnings.catch_warnings(): - warnings.simplefilter("ignore", FutureWarning) - slow_attr = getattr(slow_mod, key) - fast_attr = getattr(fast_mod, key, _Unusable()) - real_attributes[key] = slow_attr - try: - wrapped_attr = self._wrap_attribute(slow_attr, fast_attr, key) - self._wrapped_objs[slow_attr] = wrapped_attr - except TypeError: - # slow_attr is not hashable - pass - - # Our module has (basically) no static attributes and instead - # always delivers them dynamically where the behaviour is - # dependent on the calling module. - setattr( - mod, - "__getattr__", - functools.partial( - self.getattr_real_or_wrapped, - real=real_attributes, - wrapped_objs=self._wrapped_objs, - loader=self, - ), - ) - - # ...but, we want to pretend like we expose the same attributes - # as the equivalent slow module - setattr(mod, "__dir__", slow_mod.__dir__) - - # We set __path__ to the real path so that importers like - # jinja2.PackageLoader("slow_mod") work correctly. - # Note (dgd): this doesn't work for resources.files(data_module) - if getattr(slow_mod, "__path__", False): - assert mod.__spec__ - mod.__path__ = slow_mod.__path__ - mod.__spec__.submodule_search_locations = [*slow_mod.__path__] - return self._postprocess_module(mod, slow_mod, fast_mod) - - @contextlib.contextmanager - def disabled(self): - """Return a context manager for disabling the module accelerator. - - Within the block, any wrapped objects will instead deliver - attributes from their real counterparts (as if the current - nested block were in the denylist). - - Returns - ------- - Context manager for disabling things - """ - try: - self._use_fast_lib_lock.acquire() - # The same thread might enter this context manager - # multiple times, so we need to remember the previous - # value - saved = self._use_fast_lib - self._use_fast_lib = False - yield - finally: - self._use_fast_lib = saved - self._use_fast_lib_lock.release() - - @staticmethod - def getattr_real_or_wrapped( - name: str, - *, - real: dict[str, Any], - wrapped_objs, - loader: ModuleAccelerator, - ) -> Any: - """ - Obtain an attribute from a module from either the real or - wrapped namespace. - - Parameters - ---------- - name - Attribute to return - real - Unwrapped "original" attributes - wrapped - Wrapped attributes - loader - Loader object that manages denylist and other skipping - - Returns - ------- - The requested attribute (either real or wrapped) - """ - with loader._use_fast_lib_lock: - # Have to hold the lock to read this variable since - # another thread might modify it. - # Modification has to happen with the lock held for the - # duration, so if someone else has modified things, then - # we block trying to acquire the lock (hence it is safe to - # release the lock after reading this value) - use_real = not loader._use_fast_lib - if not use_real: - # Only need to check the denylist if we're not turned off. - frame = sys._getframe() - # We cannot possibly be at the top level. - assert frame.f_back - calling_module = pathlib.PurePath(frame.f_back.f_code.co_filename) - use_real = _caller_in_denylist( - calling_module, tuple(loader._denylist) - ) - try: - if use_real: - return real[name] - else: - return wrapped_objs[real[name]] - except KeyError: - raise AttributeError(f"No attribute '{name}'") - except TypeError: - # real[name] is an unhashable type - return real[name] - - @classmethod - def install( - cls, - destination_module: str, - fast_lib: str, - slow_lib: str, - ) -> Self | None: - # This grabs the global _import_ lock to avoid concurrent - # threads modifying sys.modules. - # We also make sure that we finish installing ourselves in - # sys.meta_path before releasing the lock so that there isn't - # a race between our modification of sys.modules and someone - # else importing the slow_lib before we have added ourselves - # to the meta_path - with ImportLock(): - logger.debug("Module Accelerator Install") - logger.debug(f"destination_module: {destination_module}") - logger.debug(f"Accelerator library: {fast_lib}") - logger.debug(f"Original library: {slow_lib}") - logger.debug("Non Estimator Function Dispatching disabled...") - if destination_module != slow_lib: - raise RuntimeError( - f"Destination module '{destination_module}' must match" - f"'{slow_lib}' for this to work." - ) - mode = deduce_cuml_accel_mode(slow_lib, fast_lib) - if mode.use_fast_lib: - importlib.import_module( - f".._wrappers.{mode.slow_lib}", __name__ - ) - try: - (self,) = ( - p - for p in sys.meta_path - if isinstance(p, cls) - and p.slow_lib == mode.slow_lib - and p.fast_lib == mode.fast_lib - ) - except ValueError: - self = cls(mode.fast_lib, mode.slow_lib) - sys.meta_path.insert(0, self) - return self - - -def disable_module_accelerator() -> contextlib.ExitStack: - """ - Temporarily disable any module acceleration. - """ - with contextlib.ExitStack() as stack: - for finder in sys.meta_path: - if isinstance(finder, ModuleAcceleratorBase): - stack.enter_context(finder.disabled()) - return stack.pop_all() - assert False # pacify type checker - - -# because this function gets called so often and is quite -# expensive to run, we cache the results: -@functools.lru_cache(maxsize=1024) -def _caller_in_denylist(calling_module, denylist): - CUML_ACCELERATOR_PATH = __file__.rsplit("/", 1)[0] - return not calling_module.is_relative_to(CUML_ACCELERATOR_PATH) and any( - calling_module.is_relative_to(path) for path in denylist - ) diff --git a/python/cuml/cuml/experimental/accel/utils.py b/python/cuml/cuml/experimental/accel/utils.py deleted file mode 100644 index c2f26245f2..0000000000 --- a/python/cuml/cuml/experimental/accel/utils.py +++ /dev/null @@ -1,65 +0,0 @@ -# -# Copyright (c) 2024, NVIDIA CORPORATION. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# - -import functools - -from typing import Any - - -class _Unusable: - """ - A totally unusable type. When a "fast" object is not available, - it's useful to set it to _Unusable() so that any operations - on it fail, and ensure fallback to the corresponding - "slow" object. - """ - - def __call__(self, *args: Any, **kwds: Any) -> Any: - raise NotImplementedError( - "Fast implementation not available. " - "Falling back to the slow implementation" - ) - - def __getattribute__(self, name: str) -> Any: - if name in {"__class__"}: # needed for type introspection - return super().__getattribute__(name) - raise TypeError("Unusable type. Falling back to the slow object") - - def __repr__(self) -> str: - raise AttributeError("Unusable type. Falling back to the slow object") - - -@functools.lru_cache(maxsize=None) -def get_final_type_map(): - """ - Return the mapping of all known fast and slow final types to their - corresponding proxy types. - """ - return dict() - - -@functools.lru_cache(maxsize=None) -def get_intermediate_type_map(): - """ - Return a mapping of all known fast and slow intermediate types to their - corresponding proxy types. - """ - return dict() - - -@functools.lru_cache(maxsize=None) -def get_registered_functions(): - return dict() diff --git a/python/cuml/cuml/internals/global_settings.py b/python/cuml/cuml/internals/global_settings.py index 2779266a60..6730fb72a1 100644 --- a/python/cuml/cuml/internals/global_settings.py +++ b/python/cuml/cuml/internals/global_settings.py @@ -46,9 +46,11 @@ def __init__(self): self.shared_state.update( { - "_output_type": None, + "_output_type": None, "root_cm": None, + "accelerator_active": False, "accelerator_loaded": False, + "accelerated_modules": {} } )