From 74a1b8f242ab4489fcde091ce07f22ac142b3c77 Mon Sep 17 00:00:00 2001 From: Mavs Date: Wed, 28 Feb 2024 22:33:53 +0100 Subject: [PATCH] fixing inheritance --- .github/workflows/config.yml | 22 +++ atom/atom.py | 14 +- atom/basemodel.py | 112 +++++++---- atom/baserunner.py | 6 +- atom/basetrainer.py | 4 +- atom/basetransformer.py | 1 + atom/data/branch.py | 15 +- atom/models/__init__.py | 10 +- atom/models/classreg.py | 83 ++++----- atom/models/custom.py | 154 ++++++++------- atom/models/ensembles.py | 247 ++++++++++++++----------- atom/models/ts.py | 118 ++++-------- atom/plots/dataplot.py | 2 +- atom/plots/predictionplot.py | 10 +- atom/utils/types.py | 2 - atom/utils/utils.py | 4 +- docs_sources/user_guide/models.md | 4 +- docs_sources/user_guide/predicting.md | 30 +-- docs_sources/user_guide/time_series.md | 2 +- tests/test_atom.py | 37 +++- tests/test_data.py | 11 +- tests/test_models.py | 53 +++++- tests/test_plots.py | 35 +++- 23 files changed, 581 insertions(+), 395 deletions(-) diff --git a/.github/workflows/config.yml b/.github/workflows/config.yml index 697200414..55a79d18f 100644 --- a/.github/workflows/config.yml +++ b/.github/workflows/config.yml @@ -111,6 +111,28 @@ jobs: examples-tests: runs-on: ubuntu-latest steps: + - name: Free disk space + run: | + echo "==============================================" + echo "Freeing up disk space on CI system" + echo "==============================================" + + echo "Listing 100 largest packages" + dpkg-query -Wf '${Installed-Size}\t${Package}\n' | sort -n | tail -n 100 + df -h + echo "Removing large packages" + sudo apt-get remove -y '^ghc-8.*' + sudo apt-get remove -y '^dotnet-.*' + sudo apt-get remove -y '^llvm-.*' + sudo apt-get remove -y 'php.*' + sudo apt-get remove -y azure-cli google-cloud-sdk hhvm google-chrome-stable firefox powershell mono-devel + sudo apt-get autoremove -y + sudo apt-get clean + df -h + echo "Removing large directories" + # deleting 15GB + rm -rf /usr/share/dotnet/ + df -h - name: Check out source repository uses: actions/checkout@v3 - name: Set up Python environment diff --git a/atom/atom.py b/atom/atom.py index f61566dd5..c23672183 100644 --- a/atom/atom.py +++ b/atom/atom.py @@ -1273,7 +1273,7 @@ def _add_transformer( self._log(f"Fitting {transformer_c.__class__.__name__}...", 1) # Memoize the fitted transformer_c for repeated instantiations of atom - fit = self._memory.cache(fit_one) + fit = self.memory.cache(fit_one) kwargs = { "estimator": transformer_c, "X": self.branch.X_train, @@ -1283,11 +1283,8 @@ def _add_transformer( # Check if the fitted estimator is retrieved from cache to inform # the user, else user might notice the lack of printed messages - if self.memory.location is not None: - if fit._is_in_cache_and_valid([*fit._get_output_identifiers(**kwargs)]): - self._log( - f"Retrieving cached results for {transformer_c.__class__.__name__}...", 1 - ) + if fit.check_call_in_cache(**kwargs): + self._log(f"Loading cached results for {transformer_c.__class__.__name__}...", 1) transformer_c = fit(**kwargs) @@ -1306,7 +1303,6 @@ def _add_transformer( self.branch.X_train if X is None else X, self.branch.y_train if y is None else y, ) - else: X, y = self.pipeline._mem_transform(transformer_c, self.branch.X, self.branch.y) data = merge(self.branch.X if X is None else X, self.branch.y if y is None else y) @@ -1543,8 +1539,8 @@ def balance(self, strategy: str | Estimator = "adasyn", **kwargs): !!! warning * The balance method does not support [multioutput tasks][]. * This transformation is only applied to the training set - in order to maintain the original distribution of target - classes in the test set. + to maintain the original distribution of target classes + in the test set. !!! tip Use atom's [classes][self-classes] attribute for an overview diff --git a/atom/basemodel.py b/atom/basemodel.py index f3e42880a..3855912d7 100644 --- a/atom/basemodel.py +++ b/atom/basemodel.py @@ -262,16 +262,16 @@ def __init__( self._bootstrap: pd.DataFrame | None = None self._time_bootstrap = 0.0 - # Inject goal-specific methods from ClassRegModel or ForecastModel - cls = ForecastModel if goal is Goal.forecast else ClassRegModel - for n, m in vars(cls).items(): - if not n.startswith("__"): - try: - setattr(self, n, m.__get__(self, cls)) - except AttributeError: - # available_if descriptor raises an error - # if the estimator doesn't have the method - pass + # Inject goal-specific methods from ForecastModel + if goal is Goal.forecast and ClassRegModel in self.__class__.__bases__: + for n, m in vars(ForecastModel).items(): + if not n.startswith("__"): + try: + setattr(self, n, m.__get__(self, ForecastModel)) + except AttributeError: + # available_if descriptor raises an error + # if the estimator doesn't have the method + pass # Skip this part if only initialized for the estimator if branches: @@ -2497,7 +2497,7 @@ def transform( return pl.transform(Xt, yt) -class ClassRegModel: +class ClassRegModel(BaseModel): """Classification and regression models.""" @crash @@ -2612,15 +2612,15 @@ def _prediction( """ def get_transform_X_y( - X: RowSelector | XSelector, + X: XSelector, y: YSelector | None, ) -> tuple[pd.DataFrame, Pandas | None]: """Get X and y from the pipeline transformation. Parameters ---------- - X: hashable, segment, sequence or dataframe-like - Feature set. If not dataframe-like, expected to fail. + X: dataframe-like + Feature set. y: int, str, sequence, dataframe-like or None Target column(s) corresponding to `X`. @@ -2664,12 +2664,13 @@ def assign_prediction_columns() -> list[str]: # prediction calls from dataframes with reset indices Xt, yt = get_transform_X_y(X, y) else: - Xt, yt = self.branch._get_rows(X, return_X_y=True) + Xt, yt = self.branch._get_rows(X, return_X_y=True) # type: ignore[call-overload] if self.scaler: - Xt = self.scaler.transform(Xt) + Xt = cast(pd.DataFrame, self.scaler.transform(Xt)) + except Exception: # noqa: BLE001 - Xt, yt = get_transform_X_y(X, y) + Xt, yt = get_transform_X_y(X, y) # type: ignore[arg-type] if method != "score": pred = np.array(self.memory.cache(getattr(self.estimator, method))(Xt[self.features])) @@ -2705,7 +2706,7 @@ def assign_prediction_columns() -> list[str]: scorer=scorer, estimator=self.estimator, X=Xt, - y=yt, + y=yt, # type: ignore[arg-type] sample_weight=sample_weight, ) @@ -2940,7 +2941,7 @@ def score( ) -class ForecastModel: +class ForecastModel(BaseModel): """Forecasting models.""" @crash @@ -3049,20 +3050,54 @@ def _prediction( called. """ - if y is not None or X is not None: + + def get_transform_X_y( + X: XSelector | None, + y: YSelector | None, + ) -> tuple[pd.DataFrame, Pandas | None]: + """Get X and y from the pipeline transformation. + + Parameters + ---------- + X: dataframe-like or None + Feature set. + + y: int, str, sequence, dataframe-like or None + Target column(s) corresponding to `X`. + + Returns + ------- + dataframe + Transformed feature set. + + series, dataframe or None + Transformed target column. + + """ Xt, yt = self._check_input(X, y, columns=self.og.features, name=self.og.target) with adjust(self.pipeline, verbose=verbose) as pl: out = pl.transform(Xt, yt) if isinstance(out, tuple): - Xt, yt = out + return out elif X is not None: - Xt, yt = out, yt + return out, yt else: - Xt, yt = Xt, out + return Xt, out + + if y is not None: + try: + Xt, yt = self.branch._get_rows(y, return_X_y=True) # type: ignore[call-overload] + + if self.scaler and not Xt.empty: + Xt = cast(pd.DataFrame, self.scaler.transform(Xt)) + + except Exception: # noqa: BLE001 + Xt, yt = get_transform_X_y(X, y) + else: - Xt, yt = X, y + Xt, yt = get_transform_X_y(X, y) if method != "score": if "y" in sign(func := getattr(self.estimator, method)): @@ -3186,17 +3221,24 @@ def predict_interval( ) if inverse: + new_interval = pd.DataFrame(index=pred.index, columns=pred.columns) + # We pass every level of the multiindex to inverse_transform... - dfs = [] - for level in pred.columns.levels[2]: # type: ignore[union-attr] - df = pred.loc[:, pred.columns.get_level_values(2) == level] - df.columns = df.columns.droplevel(level=(1, 2)) - dfs.append(self.inverse_transform(y=df)) - - # ... and merge every level back to the original output - new_data = merge(*dfs) - new_data.columns = pred.columns - return self._convert(new_data) + for cover in pred.columns.levels[1]: # type: ignore[union-attr] + for level in pred.columns.levels[2]: # type: ignore[union-attr] + # Select only the lower or upper level columns + curr_cover = pred.columns.get_level_values(1) + curr_level = pred.columns.get_level_values(2) + df = pred.loc[:, (curr_cover == cover) & (curr_level == level)] + + # Use original columns names + df.columns = df.columns.droplevel(level=(1, 2)) + + # Apply inverse transformation + for name, column in self.inverse_transform(y=df).items(): + new_interval.loc[:, (name, cover, level)] = column + + return self._convert(new_interval) else: return self._convert(pred) @@ -3318,7 +3360,7 @@ def predict_residuals( Parameters ---------- - y: int, str, sequence or dataframe + y: int, str, sequence or dataframe-like Ground truth observations. X: hashable, segment, sequence, dataframe-like or None, default=None diff --git a/atom/baserunner.py b/atom/baserunner.py index 83a1a0b67..8b6a8193e 100644 --- a/atom/baserunner.py +++ b/atom/baserunner.py @@ -33,7 +33,7 @@ from atom.basetracker import BaseTracker from atom.basetransformer import BaseTransformer from atom.data import Branch -from atom.models import MODELS, Stacking, Voting +from atom.models import MODELS, create_stacking_model, create_voting_model from atom.pipeline import Pipeline from atom.utils.constants import DF_ATTRS from atom.utils.types import ( @@ -1500,7 +1500,7 @@ def stacking( kwargs[regressor] = model._get_est({}) - self._models.append(Stacking(models=models_c, name=name, **kw_model)) + self._models.append(create_stacking_model(models=models_c, name=name, **kw_model)) self[name]._est_params = kwargs if self.task.is_forecast else {"cv": "prefit"} | kwargs if train_on_test: @@ -1568,7 +1568,7 @@ def voting( ) self._models.append( - Voting( + create_voting_model( models=models_c, name=name, goal=self._goal, diff --git a/atom/basetrainer.py b/atom/basetrainer.py index 413e916bb..2f25baf8d 100644 --- a/atom/basetrainer.py +++ b/atom/basetrainer.py @@ -20,7 +20,7 @@ from atom.baserunner import BaseRunner from atom.data import BranchManager from atom.data_cleaning import BaseTransformer -from atom.models import MODELS, CustomModel +from atom.models import MODELS, create_custom_model from atom.plots import RunnerPlot from atom.utils.types import Model, Verbose, sequence_t from atom.utils.utils import ( @@ -218,7 +218,7 @@ def _prepare_parameters(self): inc.append(model) else: # Model is a custom estimator - inc.append(CustomModel(estimator=model, **kwargs)) + inc.append(create_custom_model(estimator=model, **kwargs)) if inc and exc: raise ValueError( diff --git a/atom/basetransformer.py b/atom/basetransformer.py index 58d202064..5d2e08e7b 100644 --- a/atom/basetransformer.py +++ b/atom/basetransformer.py @@ -238,6 +238,7 @@ def warnings(self, value: Bool | Warnings): warnings.filterwarnings("ignore", category=FutureWarning, module=".*imblearn.*") warnings.filterwarnings("ignore", category=UserWarning, module=".*sktime.*") warnings.filterwarnings("ignore", category=DeprecationWarning, module=".*shap.*") + warnings.filterwarnings("ignore", category=ResourceWarning, module=".*ray.*") os.environ["PYTHONWARNINGS"] = self._warnings # Affects subprocesses (joblib) @property diff --git a/atom/data/branch.py b/atom/data/branch.py index 3c5247c21..c1d865fd5 100644 --- a/atom/data/branch.py +++ b/atom/data/branch.py @@ -167,16 +167,21 @@ def name(self) -> str: @name.setter def name(self, value: str): - from atom.models import MODELS_ENSEMBLES # Avoid circular import + from atom.models import MODELS if not value: - raise ValueError("A branch can't have an empty name!") + raise ValueError("A branch can't have an empty name.") + elif value.lower().startswith(("stack", "vote")): + raise ValueError( + "Invalid name for the branch. The name of a " + "branch can't begin with 'stack' or 'vote'." + ) else: - for model in MODELS_ENSEMBLES: + for model in MODELS: if re.match(model.acronym, value, re.I): raise ValueError( - "Invalid name for the branch. The name of a branch can " - f"not begin with a model's acronym, and {model.acronym} " + "Invalid name for the branch. The name of a branch can't " + f"begin with a model's acronym, and {model.acronym} " f"is the acronym of the {model.__name__} model." ) diff --git a/atom/models/__init__.py b/atom/models/__init__.py index 1da3e5f3f..8c2ecc6d5 100644 --- a/atom/models/__init__.py +++ b/atom/models/__init__.py @@ -17,8 +17,8 @@ QuadraticDiscriminantAnalysis, RadiusNearestNeighbors, RandomForest, Ridge, StochasticGradientDescent, SupportVectorMachine, XGBoost, ) -from atom.models.custom import CustomModel -from atom.models.ensembles import Stacking, Voting +from atom.models.custom import create_custom_model +from atom.models.ensembles import create_stacking_model, create_voting_model from atom.models.ts import ( ARIMA, BATS, ETS, MSTL, SARIMAX, STL, TBATS, VAR, VARMAX, AutoARIMA, AutoETS, Croston, DynamicFactor, ExponentialSmoothing, NaiveForecaster, @@ -87,9 +87,3 @@ XGBoost, key="acronym", ) - -# Available ensembles -ENSEMBLES = ClassMap(Stacking, Voting, key="acronym") - -# Available models + ensembles -MODELS_ENSEMBLES = ClassMap(*MODELS, *ENSEMBLES, key="acronym") diff --git a/atom/models/classreg.py b/atom/models/classreg.py index 02dffde14..134b15eb6 100644 --- a/atom/models/classreg.py +++ b/atom/models/classreg.py @@ -22,12 +22,12 @@ ) from optuna.trial import Trial -from atom.basemodel import BaseModel +from atom.basemodel import ClassRegModel from atom.utils.types import Pandas, Predictor from atom.utils.utils import CatBMetric, Goal, LGBMetric, XGBMetric -class AdaBoost(BaseModel): +class AdaBoost(ClassRegModel): """Adaptive Boosting. AdaBoost is a meta-estimator that begins by fitting a @@ -97,7 +97,7 @@ def _get_distributions(self) -> Mapping[str, BaseDistribution]: return dist -class AutomaticRelevanceDetermination(BaseModel): +class AutomaticRelevanceDetermination(ClassRegModel): """Automatic Relevance Determination. Automatic Relevance Determination is very similar to @@ -163,7 +163,7 @@ def _get_distributions() -> dict[str, BaseDistribution]: } -class Bagging(BaseModel): +class Bagging(ClassRegModel): """Bagging model (with decision tree as base estimator). Bagging uses an ensemble meta-estimator that fits base predictors @@ -234,7 +234,7 @@ def _get_distributions() -> dict[str, BaseDistribution]: } -class BayesianRidge(BaseModel): +class BayesianRidge(ClassRegModel): """Bayesian ridge regression. Bayesian regression techniques can be used to include regularization @@ -299,7 +299,7 @@ def _get_distributions() -> dict[str, BaseDistribution]: } -class BernoulliNB(BaseModel): +class BernoulliNB(ClassRegModel): """Bernoulli Naive Bayes. BernoulliNB implements the Naive Bayes algorithm for multivariate @@ -362,7 +362,7 @@ def _get_distributions() -> dict[str, BaseDistribution]: } -class CatBoost(BaseModel): +class CatBoost(ClassRegModel): """Cat Boosting Machine. CatBoost is a machine learning method based on gradient boosting @@ -563,7 +563,7 @@ def _get_distributions() -> dict[str, BaseDistribution]: } -class CategoricalNB(BaseModel): +class CategoricalNB(ClassRegModel): """Categorical Naive Bayes. Categorical Naive Bayes implements the Naive Bayes algorithm for @@ -626,7 +626,7 @@ def _get_distributions() -> dict[str, BaseDistribution]: } -class ComplementNB(BaseModel): +class ComplementNB(ClassRegModel): """Complement Naive Bayes. The Complement Naive Bayes classifier was designed to correct the @@ -689,7 +689,7 @@ def _get_distributions() -> dict[str, BaseDistribution]: } -class DecisionTree(BaseModel): +class DecisionTree(ClassRegModel): """Single Decision Tree. A single decision tree classifier/regressor. @@ -760,7 +760,7 @@ def _get_distributions(self) -> Mapping[str, BaseDistribution]: } -class Dummy(BaseModel): +class Dummy(ClassRegModel): """Dummy classifier/regressor. When doing supervised learning, a simple sanity check consists of @@ -832,7 +832,7 @@ def _get_distributions(self) -> Mapping[str, BaseDistribution]: return dist -class ElasticNet(BaseModel): +class ElasticNet(ClassRegModel): """Linear Regression with elasticnet regularization. Linear least squares with l1 and l2 regularization. @@ -893,7 +893,7 @@ def _get_distributions() -> dict[str, BaseDistribution]: } -class ExtraTree(BaseModel): +class ExtraTree(ClassRegModel): """Extremely Randomized Tree. Extra-trees differ from classic decision trees in the way they are @@ -969,7 +969,7 @@ def _get_distributions(self) -> Mapping[str, BaseDistribution]: } -class ExtraTrees(BaseModel): +class ExtraTrees(ClassRegModel): """Extremely Randomized Trees. Extra-Trees use a meta estimator that fits a number of randomized @@ -1066,7 +1066,7 @@ def _get_distributions(self) -> Mapping[str, BaseDistribution]: } -class GaussianNB(BaseModel): +class GaussianNB(ClassRegModel): """Gaussian Naive Bayes. Gaussian Naive Bayes implements the Naive Bayes algorithm for @@ -1113,7 +1113,7 @@ class GaussianNB(BaseModel): } -class GaussianProcess(BaseModel): +class GaussianProcess(ClassRegModel): """Gaussian process. Gaussian Processes are a generic supervised learning method @@ -1175,7 +1175,7 @@ class GaussianProcess(BaseModel): } -class GradientBoostingMachine(BaseModel): +class GradientBoostingMachine(ClassRegModel): """Gradient Boosting Machine. A Gradient Boosting Machine builds an additive model in a forward @@ -1262,7 +1262,7 @@ def _get_distributions(self) -> Mapping[str, BaseDistribution]: return dist -class HuberRegression(BaseModel): +class HuberRegression(ClassRegModel): """Huber regressor. Huber is a linear regression model that is robust to outliers. It @@ -1325,7 +1325,7 @@ def _get_distributions() -> dict[str, BaseDistribution]: } -class HistGradientBoosting(BaseModel): +class HistGradientBoosting(ClassRegModel): """Histogram-based Gradient Boosting Machine. This Histogram-based Gradient Boosting Machine is much faster than @@ -1404,7 +1404,7 @@ def _get_distributions(self) -> Mapping[str, BaseDistribution]: return dist -class KNearestNeighbors(BaseModel): +class KNearestNeighbors(ClassRegModel): """K-Nearest Neighbors. K-Nearest Neighbors, as the name clearly indicates, implements the @@ -1480,7 +1480,7 @@ def _get_distributions(self) -> Mapping[str, BaseDistribution]: return dist -class Lasso(BaseModel): +class Lasso(ClassRegModel): """Linear Regression with lasso regularization. Linear least squares with l1 regularization. @@ -1540,7 +1540,7 @@ def _get_distributions() -> dict[str, BaseDistribution]: } -class LeastAngleRegression(BaseModel): +class LeastAngleRegression(ClassRegModel): """Least Angle Regression. Least-Angle Regression is a regression algorithm for @@ -1590,7 +1590,7 @@ class LeastAngleRegression(BaseModel): } -class LightGBM(BaseModel): +class LightGBM(ClassRegModel): """Light Gradient Boosting Machine. LightGBM is a gradient boosting model that uses tree-based learning @@ -1766,7 +1766,7 @@ def _get_distributions() -> dict[str, BaseDistribution]: } -class LinearDiscriminantAnalysis(BaseModel): +class LinearDiscriminantAnalysis(ClassRegModel): """Linear Discriminant Analysis. Linear Discriminant Analysis is a classifier with a linear @@ -1851,7 +1851,7 @@ def _get_distributions() -> dict[str, BaseDistribution]: } -class LinearSVM(BaseModel): +class LinearSVM(ClassRegModel): """Linear Support Vector Machine. Similar to [SupportVectorMachine][] but with a linear kernel. @@ -1977,7 +1977,7 @@ def _get_distributions(self) -> Mapping[str, BaseDistribution]: return dist -class LogisticRegression(BaseModel): +class LogisticRegression(ClassRegModel): """Logistic Regression. Logistic regression, despite its name, is a linear model for @@ -2084,7 +2084,7 @@ def _get_distributions(self) -> Mapping[str, BaseDistribution]: return dist -class MultiLayerPerceptron(BaseModel): +class MultiLayerPerceptron(ClassRegModel): """Multi-layer Perceptron. Multi-layer Perceptron is a supervised learning algorithm that @@ -2192,7 +2192,7 @@ def _get_distributions(self) -> Mapping[str, BaseDistribution]: return dist -class MultinomialNB(BaseModel): +class MultinomialNB(ClassRegModel): """Multinomial Naive Bayes. MultinomialNB implements the Naive Bayes algorithm for multinomially @@ -2256,7 +2256,7 @@ def _get_distributions() -> dict[str, BaseDistribution]: } -class OrdinaryLeastSquares(BaseModel): +class OrdinaryLeastSquares(ClassRegModel): """Linear Regression. Ordinary Least Squares is just linear regression without any @@ -2305,7 +2305,7 @@ class OrdinaryLeastSquares(BaseModel): } -class OrthogonalMatchingPursuit(BaseModel): +class OrthogonalMatchingPursuit(ClassRegModel): """Orthogonal Matching Pursuit. Orthogonal Matching Pursuit implements the OMP algorithm for @@ -2352,7 +2352,7 @@ class OrthogonalMatchingPursuit(BaseModel): } -class PassiveAggressive(BaseModel): +class PassiveAggressive(ClassRegModel): """Passive Aggressive. The passive-aggressive algorithms are a family of algorithms for @@ -2423,7 +2423,7 @@ def _get_distributions(self) -> Mapping[str, BaseDistribution]: } -class Perceptron(BaseModel): +class Perceptron(ClassRegModel): """Linear Perceptron classification. The Perceptron is a simple classification algorithm suitable for @@ -2495,7 +2495,7 @@ def _get_distributions() -> dict[str, BaseDistribution]: } -class QuadraticDiscriminantAnalysis(BaseModel): +class QuadraticDiscriminantAnalysis(ClassRegModel): """Quadratic Discriminant Analysis. Quadratic Discriminant Analysis is a classifier with a quadratic @@ -2556,7 +2556,7 @@ def _get_distributions() -> dict[str, BaseDistribution]: return {"reg_param": Float(0, 1.0, step=0.1)} -class RadiusNearestNeighbors(BaseModel): +class RadiusNearestNeighbors(ClassRegModel): """Radius Nearest Neighbors. Radius Nearest Neighbors implements the nearest neighbors vote, @@ -2635,7 +2635,7 @@ def _get_distributions() -> dict[str, BaseDistribution]: } -class RandomForest(BaseModel): +class RandomForest(ClassRegModel): """Random Forest. Random forests are an ensemble learning method that operate by @@ -2755,7 +2755,7 @@ def _get_distributions(self) -> Mapping[str, BaseDistribution]: return dist -class Ridge(BaseModel): +class Ridge(ClassRegModel): """Linear least squares with l2 regularization. If classifier, it first converts the target values into {-1, 1} @@ -2829,7 +2829,7 @@ def _get_distributions(self) -> Mapping[str, BaseDistribution]: return dist -class StochasticGradientDescent(BaseModel): +class StochasticGradientDescent(ClassRegModel): """Stochastic Gradient Descent. Stochastic Gradient Descent is a simple yet very efficient approach @@ -2914,7 +2914,7 @@ def _get_distributions(self) -> Mapping[str, BaseDistribution]: } -class SupportVectorMachine(BaseModel): +class SupportVectorMachine(ClassRegModel): """Support Vector Machine. The implementation of the Support Vector Machine is based on libsvm. @@ -3023,17 +3023,16 @@ def _get_distributions(self) -> Mapping[str, BaseDistribution]: "shrinking": Cat([True, False]), } - if self._goal is Goal.classification: - dist.pop("epsilon") - if self.engine.estimator == "cuml": dist.pop("epsilon") dist.pop("shrinking") + elif self._goal is Goal.classification: + dist.pop("epsilon") return dist -class XGBoost(BaseModel): +class XGBoost(ClassRegModel): """Extreme Gradient Boosting. XGBoost is an optimized distributed gradient boosting model diff --git a/atom/models/custom.py b/atom/models/custom.py index ae10fe3be..d013de331 100644 --- a/atom/models/custom.py +++ b/atom/models/custom.py @@ -7,71 +7,95 @@ from typing import Any -from atom.basemodel import BaseModel +from atom.basemodel import BaseModel, ClassRegModel, ForecastModel from atom.utils.types import Predictor +from atom.utils.utils import Goal -class CustomModel(BaseModel): - """Model with estimator provided by user.""" - - def __init__(self, **kwargs): - # Assign the estimator and store the provided parameters - if callable(est := kwargs.pop("estimator")): - self._est = est - self._params = {} - else: - self._est = est.__class__ - self._params = est.get_params() - - if hasattr(est, "name"): - name = est.name - else: - from atom.models import MODELS - - # If no name is provided, use the name of the class - name = self.fullname - if len(n := list(filter(str.isupper, name))) >= 2 and n not in MODELS: - name = "".join(n) - - self.acronym = getattr(est, "acronym", name) - if not name.startswith(self.acronym): - raise ValueError( - f"The name ({name}) and acronym ({self.acronym}) of model " - f"{self.fullname} do not match. The name should start with " - f"the model's acronym." - ) - - self.needs_scaling = getattr(est, "needs_scaling", False) - self.native_multilabel = getattr(est, "native_multilabel", False) - self.native_multioutput = getattr(est, "native_multioutput", False) - self.validation = getattr(est, "validation", None) - - super().__init__(name=name, **kwargs) - - self._estimators = {self._goal.name: self._est_class.__name__} - - @property - def fullname(self) -> str: - """Return the estimator's class name.""" - return self._est_class.__name__ - - @property - def _est_class(self) -> type[Predictor]: - """Return the estimator's class.""" - return self._est - - def _get_est(self, params: dict[str, Any]) -> Predictor: - """Get the model's estimator with unpacked parameters. - - Parameters - ---------- - params: dict - Hyperparameters for the estimator. - - Returns - ------- - Predictor - Estimator instance. - - """ - return super()._get_est(self._params | params) +def create_custom_model(estimator: Predictor, **kwargs) -> BaseModel: + """Create a custom model from the provided estimator. + + This function dynamically assigns the parent to the class. + + Parameters + ---------- + estimator: Predictor + Estimator to use in the model. + + kwargs + Additional keyword arguments passed to the model's constructor. + + Returns + ------- + CustomModel + Custom model with the provided estimator. + + """ + base = ForecastModel if kwargs["goal"] is Goal.forecast else ClassRegModel + + class CustomModel(base): # type: ignore[valid-type, misc] + """Model with estimator provided by user.""" + + def __init__(self, **kwargs): + # Assign the estimator and store the provided parameters + if callable(est := kwargs.pop("estimator")): + self._est = est + self._params = {} + else: + self._est = est.__class__ + self._params = est.get_params() + + if hasattr(est, "name"): + name = est.name + else: + from atom.models import MODELS + + # If no name is provided, use the name of the class + name = self.fullname + if len(n := list(filter(str.isupper, name))) >= 2 and n not in MODELS: + name = "".join(n) + + self.acronym = getattr(est, "acronym", name) + if not name.startswith(self.acronym): + raise ValueError( + f"The name ({name}) and acronym ({self.acronym}) of model " + f"{self.fullname} do not match. The name should start with " + f"the model's acronym." + ) + + self.needs_scaling = getattr(est, "needs_scaling", False) + self.native_multilabel = getattr(est, "native_multilabel", False) + self.native_multioutput = getattr(est, "native_multioutput", False) + self.validation = getattr(est, "validation", None) + + super().__init__(name=name, **kwargs) + + self._estimators = {self._goal.name: self._est_class.__name__} + + @property + def fullname(self) -> str: + """Return the estimator's class name.""" + return self._est_class.__name__ + + @property + def _est_class(self) -> type[Predictor]: + """Return the estimator's class.""" + return self._est + + def _get_est(self, params: dict[str, Any]) -> Predictor: + """Get the model's estimator with unpacked parameters. + + Parameters + ---------- + params: dict + Hyperparameters for the estimator. + + Returns + ------- + Predictor + Estimator instance. + + """ + return super()._get_est(self._params | params) + + return CustomModel(estimator=estimator, **kwargs) diff --git a/atom/models/ensembles.py b/atom/models/ensembles.py index 2870c9ad1..b3de2ae07 100644 --- a/atom/models/ensembles.py +++ b/atom/models/ensembles.py @@ -9,141 +9,182 @@ from typing import Any, ClassVar -from atom.basemodel import BaseModel +from atom.basemodel import BaseModel, ClassRegModel, ForecastModel from atom.utils.types import Model, Predictor +from atom.utils.utils import Goal -class Stacking(BaseModel): - """Stacking ensemble. +def create_stacking_model(**kwargs) -> BaseModel: + """Create a stacking model. + + This function dynamically assigns the parent to the class. Parameters ---------- - models: list of Model - Models from which to build the ensemble. + kwargs + Additional keyword arguments passed to the model's constructor. - **kwargs - Additional keyword arguments for BaseModel's constructor. + Returns + ------- + Stacking + Ensemble model. """ + base = ForecastModel if kwargs["goal"] is Goal.forecast else ClassRegModel - acronym = "Stack" - handles_missing = False - needs_scaling = False - validation = None - multiple_seasonality = False - native_multilabel = False - native_multioutput = False - supports_engines = ("sklearn",) - - _estimators: ClassVar[dict[str, str]] = { - "classification": "sklearn.ensemble.StackingClassifier", - "regression": "sklearn.ensemble.StackingRegressor", - "forecast": "atom.utils.patches.StackingForecaster", - } - - def __init__(self, models: list[Model], **kwargs): - super().__init__(**kwargs) - self._models = models - - def _get_est(self, params: dict[str, Any]) -> Predictor: - """Get the model's estimator with unpacked parameters. + class Stacking(base): # type: ignore[valid-type, misc] + """Stacking ensemble. Parameters ---------- - params: dict - Hyperparameters for the estimator. + models: list of Model + Models from which to build the ensemble. - Returns - ------- - Predictor - Estimator instance. + **kwargs + Additional keyword arguments for BaseModel's constructor. """ - # We use _est_class with get_params instead of just a dict - # to also fix the parameters of the models in the ensemble - estimator = self._est_class( - **{ - "estimators" if not self.task.is_forecast else "forecasters": [ - (m.name, m.export_pipeline()[-2:] if m.scaler else m.estimator) - for m in self._models - ] - } - ) - - # Drop the model names from params since those - # are not direct parameters of the ensemble - default = { - k: v - for k, v in estimator.get_params().items() - if k not in (m.name for m in self._models) + + acronym = "Stack" + handles_missing = False + needs_scaling = False + validation = None + multiple_seasonality = False + native_multilabel = False + native_multioutput = False + supports_engines = ("sklearn",) + + _estimators: ClassVar[dict[str, str]] = { + "classification": "sklearn.ensemble.StackingClassifier", + "regression": "sklearn.ensemble.StackingRegressor", + "forecast": "atom.utils.patches.StackingForecaster", } - return super()._get_est(default | params) + def __init__(self, models: list[Model], **kwargs): + super().__init__(**kwargs) + self._models = models + + def _get_est(self, params: dict[str, Any]) -> Predictor: + """Get the model's estimator with unpacked parameters. + + Parameters + ---------- + params: dict + Hyperparameters for the estimator. + + Returns + ------- + Predictor + Estimator instance. + + """ + # We use _est_class with get_params instead of just a dict + # to also fix the parameters of the models in the ensemble + estimator = self._est_class( + **{ + "estimators" if not self.task.is_forecast else "forecasters": [ + (m.name, m.export_pipeline()[-2:] if m.scaler else m.estimator) + for m in self._models + ] + } + ) + + # Drop the model names from params since those + # are not direct parameters of the ensemble + default = { + k: v + for k, v in estimator.get_params().items() + if k not in (m.name for m in self._models) + } + return super()._get_est(default | params) -class Voting(BaseModel): - """Voting ensemble. + return Stacking(**kwargs) - Parameters - ---------- - models: list of Model - Models from which to build the ensemble. - **kwargs - Additional keyword arguments for BaseModel's constructor. +def create_voting_model(**kwargs) -> BaseModel: + """Create a voting model. - """ + This function dynamically assigns the parent to the class. - acronym = "Vote" - handles_missing = False - needs_scaling = False - validation = None - multiple_seasonality = False - native_multilabel = False - native_multioutput = False - supports_engines = ("sklearn",) + Parameters + ---------- + kwargs + Additional keyword arguments passed to the model's constructor. - _estimators: ClassVar[dict[str, str]] = { - "classification": "atom.utils.patches.VotingClassifier", - "regression": "atom.utils.patches.VotingRegressor", - "forecast": "atom.utils.patches.EnsembleForecaster", - } + Returns + ------- + Stacking + Ensemble model. - def __init__(self, models: list[Model], **kwargs): - super().__init__(**kwargs) - self._models = models + """ + base = ForecastModel if kwargs["goal"] is Goal.forecast else ClassRegModel - def _get_est(self, params: dict[str, Any]) -> Predictor: - """Get the model's estimator with unpacked parameters. + class Voting(base): # type: ignore[valid-type, misc] + """Voting ensemble. Parameters ---------- - params: dict - Hyperparameters for the estimator. + models: list of Model + Models from which to build the ensemble. - Returns - ------- - Predictor - Estimator instance. + **kwargs + Additional keyword arguments for BaseModel's constructor. """ - # We use _est_class with get_params instead of just a dict - # to also fix the parameters of the models in the ensemble - estimator = self._est_class( - **{ - "estimators" if not self.task.is_forecast else "forecasters": [ - (m.name, m.export_pipeline()[-2:] if m.scaler else m.estimator) - for m in self._models - ] - } - ) - - # Drop the model names from params since those - # are not direct parameters of the ensemble - default = { - k: v - for k, v in estimator.get_params().items() - if k not in (m.name for m in self._models) + + acronym = "Vote" + handles_missing = False + needs_scaling = False + validation = None + multiple_seasonality = False + native_multilabel = False + native_multioutput = False + supports_engines = ("sklearn",) + + _estimators: ClassVar[dict[str, str]] = { + "classification": "atom.utils.patches.VotingClassifier", + "regression": "atom.utils.patches.VotingRegressor", + "forecast": "atom.utils.patches.EnsembleForecaster", } - return super()._get_est(default | params) + def __init__(self, models: list[Model], **kwargs): + super().__init__(**kwargs) + self._models = models + + def _get_est(self, params: dict[str, Any]) -> Predictor: + """Get the model's estimator with unpacked parameters. + + Parameters + ---------- + params: dict + Hyperparameters for the estimator. + + Returns + ------- + Predictor + Estimator instance. + + """ + # We use _est_class with get_params instead of just a dict + # to also fix the parameters of the models in the ensemble + estimator = self._est_class( + **{ + "estimators" if not self.task.is_forecast else "forecasters": [ + (m.name, m.export_pipeline()[-2:] if m.scaler else m.estimator) + for m in self._models + ] + } + ) + + # Drop the model names from params since those + # are not direct parameters of the ensemble + default = { + k: v + for k, v in estimator.get_params().items() + if k not in (m.name for m in self._models) + } + + return super()._get_est(default | params) + + return Voting(**kwargs) diff --git a/atom/models/ts.py b/atom/models/ts.py index f067eb5d9..ff644e406 100644 --- a/atom/models/ts.py +++ b/atom/models/ts.py @@ -15,14 +15,13 @@ from optuna.distributions import CategoricalDistribution as Cat from optuna.distributions import FloatDistribution as Float from optuna.distributions import IntDistribution as Int -from optuna.trial import Trial -from atom.basemodel import BaseModel +from atom.basemodel import ForecastModel from atom.utils.types import Predictor from atom.utils.utils import SeasonalPeriod -class ARIMA(BaseModel): +class ARIMA(ForecastModel): """Autoregressive Integrated Moving Average. Seasonal ARIMA models and exogenous input is supported, hence this @@ -46,6 +45,10 @@ class ARIMA(BaseModel): - [ARIMA][arimaclass] for forecasting tasks. + !!! note + The seasonal components ar removed from [hyperparameter tuning][] + if no [seasonality][] is specified. + !!! warning ARIMA often runs into numerical errors when optimizing the hyperparameters. Possible solutions are: @@ -92,30 +95,6 @@ class ARIMA(BaseModel): _order = ("p", "d", "q") _s_order = ("P", "D", "Q") - def _get_parameters(self, trial: Trial) -> dict[str, BaseDistribution]: - """Get the trial's hyperparameters. - - Parameters - ---------- - trial: [Trial][] - Current trial. - - Returns - ------- - dict - Trial's hyperparameters. - - """ - params = super()._get_parameters(trial) - - # If no seasonal periodicity, set seasonal components to zero - if not self._config.sp.sp: - for p in self._s_order: - if p in params: - params[p] = 0 - - return params - def _trial_to_est(self, params: dict[str, Any]) -> dict[str, Any]: """Convert trial's hyperparameters to parameters for the estimator. @@ -185,18 +164,19 @@ def _get_distributions(self) -> Mapping[str, BaseDistribution]: "with_intercept": Cat([True, False]), } - # Drop order and seasonal_order params if specified by user + # Drop order params if specified by user if "order" in self._est_params: for p in self._order: dist.pop(p) - if "seasonal_order" in self._est_params: + if "seasonal_order" in self._est_params or not self._config.sp.sp: + # Drop seasonal order params if specified by user or no seasonal periodicity for p in self._s_order: dist.pop(p) return dist -class AutoARIMA(BaseModel): +class AutoARIMA(ForecastModel): """Automatic Autoregressive Integrated Moving Average. [ARIMA][] implementation that includes automated fitting of @@ -289,7 +269,7 @@ def _get_distributions() -> dict[str, BaseDistribution]: } -class AutoETS(BaseModel): +class AutoETS(ForecastModel): """ETS model with automatic fitting capabilities. The [ETS][] models are a family of time series models with an @@ -367,7 +347,7 @@ def _get_distributions() -> dict[str, BaseDistribution]: } -class BATS(BaseModel): +class BATS(ForecastModel): """BATS forecaster with multiple seasonality. BATS is acronym for: @@ -458,7 +438,7 @@ def _get_distributions() -> dict[str, BaseDistribution]: } -class Croston(BaseModel): +class Croston(ForecastModel): """Croston's method for forecasting. Croston's method is a modification of (vanilla) exponential @@ -518,7 +498,7 @@ def _get_distributions() -> dict[str, BaseDistribution]: return {"smoothing": Float(0, 1, step=0.1)} -class DynamicFactor(BaseModel): +class DynamicFactor(ForecastModel): """Dynamic Factor. The DynamicFactor model incorporates dynamic factors to predict @@ -587,7 +567,7 @@ def _get_distributions() -> dict[str, BaseDistribution]: } -class ExponentialSmoothing(BaseModel): +class ExponentialSmoothing(ForecastModel): """Holt-Winters Exponential Smoothing forecaster. ExponentialSmoothing is a forecasting model that extends simple @@ -646,8 +626,8 @@ def _get_est(self, params: dict[str, Any]) -> Predictor: """ return super()._get_est( { - "trend": self._config.sp.trend_model, - "seasonal": self._config.sp.seasonal_model, + "trend": self._config.sp.trend_model if self._config.sp.sp else None, + "seasonal": self._config.sp.seasonal_model if self._config.sp.sp else None, } | params ) @@ -670,7 +650,7 @@ def _get_distributions() -> dict[str, BaseDistribution]: } -class ETS(BaseModel): +class ETS(ForecastModel): """Error-Trend-Seasonality model. The ETS models are a family of time series models with an @@ -729,8 +709,8 @@ def _get_est(self, params: dict[str, Any]) -> Predictor: """ return super()._get_est( { - "trend": self._config.sp.trend_model, - "seasonal": self._config.sp.seasonal_model, + "trend": self._config.sp.trend_model if self._config.sp.sp else None, + "seasonal": self._config.sp.seasonal_model if self._config.sp.sp else None, } | params ) @@ -752,7 +732,7 @@ def _get_distributions() -> dict[str, BaseDistribution]: } -class MSTL(BaseModel): +class MSTL(ForecastModel): """Multiple Seasonal-Trend decomposition using LOESS. The MSTL model (Multiple Seasonal-Trend decomposition using LOESS) @@ -859,7 +839,7 @@ def _get_distributions(self) -> Mapping[str, BaseDistribution]: return dist -class NaiveForecaster(BaseModel): +class NaiveForecaster(ForecastModel): """Naive Forecaster. NaiveForecaster is a dummy forecaster that makes forecasts using @@ -931,7 +911,7 @@ def _get_distributions() -> dict[str, BaseDistribution]: return {"strategy": Cat(["last", "mean", "drift"])} -class PolynomialTrend(BaseModel): +class PolynomialTrend(ForecastModel): """Polynomial Trend forecaster. Forecast time series data with a polynomial trend, using a sklearn @@ -989,7 +969,7 @@ def _get_distributions() -> dict[str, BaseDistribution]: } -class Prophet(BaseModel): +class Prophet(ForecastModel): """Prophet forecaster by Facebook. Prophet is designed to handle time series data with strong seasonal @@ -1060,6 +1040,7 @@ def _get_est(self, params: dict[str, Any]) -> Predictor: """ # Prophet expects a DateTime index frequency + freq = None if self._config.sp.sp: try: freq = next( @@ -1070,10 +1051,6 @@ def _get_est(self, params: dict[str, Any]) -> Predictor: # If not in mapping table, get from index if hasattr(self.X_train.index, "freq"): freq = self.X_train.index.freq.name - else: - freq = None - else: - freq = None return super()._get_est( {"freq": freq, "seasonality_mode": self._config.sp.seasonal_model} | params @@ -1096,7 +1073,7 @@ def _get_distributions() -> dict[str, BaseDistribution]: } -class SARIMAX(BaseModel): +class SARIMAX(ForecastModel): """Seasonal Autoregressive Integrated Moving Average. SARIMAX stands for Seasonal Autoregressive Integrated Moving Average @@ -1108,6 +1085,10 @@ class SARIMAX(BaseModel): - [SARIMAX][sarimaxclass] for forecasting tasks. + !!! note + The seasonal components ar removed from [hyperparameter tuning][] + if no [seasonality][] is specified. + !!! warning SARIMAX often runs into numerical errors when optimizing the hyperparameters. Possible solutions are: @@ -1154,30 +1135,6 @@ class SARIMAX(BaseModel): _order = ("p", "d", "q") _s_order = ("P", "D", "Q") - def _get_parameters(self, trial: Trial) -> dict[str, BaseDistribution]: - """Get the trial's hyperparameters. - - Parameters - ---------- - trial: [Trial][] - Current trial. - - Returns - ------- - dict - Trial's hyperparameters. - - """ - params = super()._get_parameters(trial) - - # If no seasonal periodicity, set seasonal components to zero - if not self._config.sp.sp: - for p in self._s_order: - if p in params: - params[p] = 0 - - return params - def _trial_to_est(self, params: dict[str, Any]) -> dict[str, Any]: """Convert trial's hyperparameters to parameters for the estimator. @@ -1236,18 +1193,19 @@ def _get_distributions(self) -> Mapping[str, BaseDistribution]: "use_exact_diffuse": Cat([True, False]), } - # Drop order and seasonal_order params if specified by user + # Drop order params if specified by user if "order" in self._est_params: for p in self._order: dist.pop(p) - if "seasonal_order" in self._est_params: + if "seasonal_order" in self._est_params or not self._config.sp.sp: + # Drop seasonal order params if specified by user or no seasonal periodicity for p in self._s_order: dist.pop(p) return dist -class STL(BaseModel): +class STL(ForecastModel): """Seasonal-Trend decomposition using LOESS. STL is a technique commonly used for decomposing time series data @@ -1324,7 +1282,7 @@ def _get_distributions() -> dict[str, BaseDistribution]: } -class TBATS(BaseModel): +class TBATS(ForecastModel): """TBATS forecaster with multiple seasonality. TBATS is acronym for: @@ -1417,7 +1375,7 @@ def _get_distributions() -> dict[str, BaseDistribution]: } -class Theta(BaseModel): +class Theta(ForecastModel): """Theta method for forecasting. The theta method is equivalent to simple [ExponentialSmoothing][] @@ -1497,7 +1455,7 @@ def _get_distributions() -> dict[str, BaseDistribution]: return {"deseasonalize": Cat([False, True])} -class VAR(BaseModel): +class VAR(ForecastModel): """Vector Autoregressive. The Vector Autoregressive (VAR) model is a type of multivariate @@ -1562,7 +1520,7 @@ def _get_distributions() -> dict[str, BaseDistribution]: } -class VARMAX(BaseModel): +class VARMAX(ForecastModel): """Vector Autoregressive Moving-Average. VARMAX is an extension of the [VAR][] model that incorporates not diff --git a/atom/plots/dataplot.py b/atom/plots/dataplot.py index 3de8ca37f..120f9925c 100644 --- a/atom/plots/dataplot.py +++ b/atom/plots/dataplot.py @@ -2198,7 +2198,7 @@ def plot_series( Parameters ---------- rows: str, sequence or dict, default=("train", "test") - Selection of rows on which to calculate the metric. + Selection of rows to plot. - If str: Name of the data set to plot. - If sequence: Names of the data sets to plot. diff --git a/atom/plots/predictionplot.py b/atom/plots/predictionplot.py index 2dd8a24a8..eebd6d475 100644 --- a/atom/plots/predictionplot.py +++ b/atom/plots/predictionplot.py @@ -39,7 +39,7 @@ Bool, ColumnSelector, FloatZeroToOneExc, Int, IntLargerEqualZero, IntLargerFour, IntLargerZero, Kind, Legend, MetricConstructor, MetricSelector, ModelsSelector, RowSelector, Sequence, TargetSelector, - TargetsSelector, XConstructor, + TargetsSelector, XConstructor, int_t, ) from atom.utils.utils import ( Task, check_canvas, check_dependency, check_empty, check_predict_proba, @@ -3152,7 +3152,7 @@ def plot_residuals( def plot_results( self, models: ModelsSelector = None, - metric: MetricSelector = None, + metric: MetricSelector | MetricConstructor = None, rows: RowSelector = "test", *, title: str | dict[str, Any] | None = None, @@ -3253,11 +3253,13 @@ def plot_results( else: metric_c = [] for m in lst(metric): - if isinstance(m, str): + if isinstance(m, int_t): + metric_c.extend(self._get_metric(m)) + elif isinstance(m, str): metric_c.extend(m.split("+")) else: metric_c.append(m) - metric_c = [m if "time" in m else get_custom_scorer(m) for m in metric_c] + metric_c = [m if "time" in str(m) else get_custom_scorer(m) for m in metric_c] fig = self._get_figure() xaxis, yaxis = BasePlot._fig.get_axes() diff --git a/atom/utils/types.py b/atom/utils/types.py index 04d105d9d..d6f0ff271 100644 --- a/atom/utils/types.py +++ b/atom/utils/types.py @@ -204,8 +204,6 @@ class Estimator(Protocol): """Protocol for sklearn-like estimators.""" def __init__(self, *args, **kwargs): ... - - def fit(self, *args, **kwargs): ... def get_params(self, *args, **kwargs): ... def set_params(self, *args, **kwargs): ... diff --git a/atom/utils/utils.py b/atom/utils/utils.py index fd4194529..ae47ab58a 100644 --- a/atom/utils/utils.py +++ b/atom/utils/utils.py @@ -1327,7 +1327,7 @@ def merge(*args) -> pd.DataFrame: if len(args_c := [x for x in args if x is not None and not x.empty]) == 1: return pd.DataFrame(args_c[0]) else: - return pd.DataFrame(pd.concat(args_c, axis=1)) + return pd.concat(args_c, axis=1) def replace_missing(X: T_Pandas, missing_values: list[Any] | None = None) -> T_Pandas: @@ -2335,7 +2335,7 @@ def fit_one( y: pd.Series, pd.DataFrame or None, default=None Target column(s) corresponding to `X`. - message: str or None + message: str or None, default=None Short message. If None, nothing will be printed. **fit_params diff --git a/docs_sources/user_guide/models.md b/docs_sources/user_guide/models.md index fab9ba5e4..65e57c02e 100644 --- a/docs_sources/user_guide/models.md +++ b/docs_sources/user_guide/models.md @@ -31,8 +31,8 @@ acronyms are: Although ATOM allows running all models for a given task using `#!python atom.run(models=None)`, it's usually smarter to select only a subset of models. Every model has a series of tags that indicate -special characteristics of the model. Use a model's `get_tags` method -to see its tags, or the [available_models][atomclassifier-available_models] +special characteristics of the model. Use a model's [`get_tags`][adaboost-get_tags] +method to see its tags, or the [available_models][atomclassifier-available_models] method to get an overview of all models and their tags. The tags differ per task, but can include: diff --git a/docs_sources/user_guide/predicting.md b/docs_sources/user_guide/predicting.md index 1879ec700..e1c615ed9 100644 --- a/docs_sources/user_guide/predicting.md +++ b/docs_sources/user_guide/predicting.md @@ -15,20 +15,28 @@ in sklearn's and sktime's API. For classification and regression tasks: -- decision_function -- predict -- predict_log_proba -- predict_proba -- score +:: atom.models:SupportVectorMachine + :: methods: + toc_only: True + include: + - decision_function + - predict + - predict_log_proba + - predict_proba + - score For forecast tasks: -- predict -- predict_interval -- predict_proba -- predict_quantiles -- predict_var -- score +:: atom.models:ARIMA + :: methods: + toc_only: True + include: + - predict + - predict_interval + - predict_proba + - predict_quantiles + - predict_var + - score !!! warning diff --git a/docs_sources/user_guide/time_series.md b/docs_sources/user_guide/time_series.md index 795429f79..f472495dc 100644 --- a/docs_sources/user_guide/time_series.md +++ b/docs_sources/user_guide/time_series.md @@ -59,7 +59,7 @@ proportional to the level of the other components. Specify the trend and/or seasonal models providing the `sp` parameter (or attribute) with a dictionary, e.g., `#!python atom.sp = {"sp": 12, "seasonal_model": "multiplicative"}`. -Both the `seasonal_trend` and `seasonal_model` values default to `additive`. +Both the `seasonal_model` and `trend_model` values default to `None`.
diff --git a/tests/test_atom.py b/tests/test_atom.py index fee94cace..e6c8a7a99 100644 --- a/tests/test_atom.py +++ b/tests/test_atom.py @@ -14,6 +14,7 @@ import pytest from category_encoders.target_encoder import TargetEncoder from pandas.testing import assert_frame_equal, assert_index_equal +from sklearn.base import BaseEstimator from sklearn.datasets import make_classification from sklearn.decomposition import PCA from sklearn.ensemble import RandomForestClassifier @@ -82,6 +83,13 @@ def test_backend_with_n_jobs_1(): # Test magic methods =============================================== >> +def test_init(): + """Assert that the __init__ method works for non-standard parameters.""" + atom = ATOMClassifier(X_bin, y_bin, device="gpu", backend="multiprocessing") + assert atom.device == "gpu" + assert atom.backend == "multiprocessing" + + def test_repr(): """Assert that the __repr__ method visualizes the pipeline(s).""" atom = ATOMClassifier(X_bin, y_bin, random_state=1) @@ -421,7 +429,7 @@ def test_shrink_int2bool(): assert atom.dtypes[0].name == "int64" atom.shrink(int2bool=True) - assert atom.dtypes[0].name == "bool" + assert atom.dtypes[0].name == "boolean" def test_shrink_int2uint(): @@ -540,6 +548,15 @@ def test_add_basetransformer_params_are_attached(): assert atom.pipeline[1].get_params()["random_state"] == 2 +def test_add_results_from_cache(): + """Assert that cached transformers are retrieved.""" + atom = ATOMClassifier(X_bin, y_bin, memory=True, random_state=1) + atom.scale() + + atom = ATOMClassifier(X_bin, y_bin, memory=True, random_state=1) + atom.scale() + + def test_add_train_only(): """Assert that atom accepts transformers for the train set only.""" atom = ATOMClassifier(X_bin, y_bin, random_state=1) @@ -717,6 +734,18 @@ def test_add_reset_index(): assert list(atom.dataset.index) == list(range(len(atom.dataset))) +def test_add_raise_duplicate_indices(): + """Assert that an error is raised when indices are duplicated.""" + + class AddRowsTransformer(BaseEstimator): + def transform(self, X, y): + return pd.concat([X, X.iloc[:5]]), pd.concat([y, y.iloc[:5]]) + + atom = ATOMClassifier(X_bin, y_bin, index=True, random_state=1) + with pytest.raises(ValueError, match=".*Duplicate indices.*"): + atom.add(AddRowsTransformer) + + def test_add_params_to_method(): """Assert that atom's parameters are passed to the method.""" atom = ATOMClassifier(X_bin, y_bin, verbose=1, random_state=1) @@ -801,10 +830,10 @@ def test_balance_wrong_task(): def test_balance(): """Assert that the balance method balances the training set.""" - atom = ATOMClassifier(X_bin, y_bin, random_state=1) - length = (atom.y_train == 1).sum() + atom = ATOMClassifier(X10, y10_str, random_state=1) + atom.clean() # To have column mapping atom.balance(strategy="NearMiss") - assert (atom.y_train == 1).sum() != length + assert (atom.y_train == 0).sum() == (atom.y_train == 1).sum() def test_clean(): diff --git a/tests/test_data.py b/tests/test_data.py index 9bee37d88..b7b177eed 100644 --- a/tests/test_data.py +++ b/tests/test_data.py @@ -1,7 +1,7 @@ """Automated Tool for Optimized Modeling (ATOM). Author: Mavs -Description: Unit tests for the branch module. +Description: Unit tests for the data module. """ import glob @@ -67,10 +67,17 @@ def test_data_property_unassigned_data(): def test_name_empty_name(): """Assert that an error is raised when name is empty.""" atom = ATOMClassifier(X_bin, y_bin, random_state=1) - with pytest.raises(ValueError, match=".*can't have an empty name!.*"): + with pytest.raises(ValueError, match=".*can't have an empty name.*"): atom.branch.name = "" +def test_name_ensemble_name(): + """Assert that an error is raised when name is the name of an ensemble.""" + atom = ATOMClassifier(X_bin, y_bin, random_state=1) + with pytest.raises(ValueError, match=".*can't begin with 'stack'.*"): + atom.branch.name = "stacked" + + def test_name_model_name(): """Assert that an error is raised when name is a model's acronym.""" atom = ATOMClassifier(X_bin, y_bin, random_state=1) diff --git a/tests/test_models.py b/tests/test_models.py index d92c98ba2..15f22a125 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -106,7 +106,7 @@ def test_all_models_regression(): def test_all_models_forecast(): """Assert that all models work with forecast.""" - atom = ATOMForecaster(y_fc, random_state=1) + atom = ATOMForecaster(y_fc, sp=12, random_state=1) atom.run( models=["!DF", "!MSTL", "!VAR", "!VARMAX"], n_trials=1, @@ -124,7 +124,7 @@ def test_all_models_forecast(): def test_all_models_forecast_multivariate(): """Assert that all models work with multivariate forecast.""" - atom = ATOMForecaster(X_ex, y=(-1, -2), verbose=2, random_state=1) + atom = ATOMForecaster(X_ex, y=(-1, -2), random_state=1) atom.run( models=["DF", "VAR", "VARMAX"], n_trials=1, @@ -136,10 +136,37 @@ def test_all_models_forecast_multivariate(): ) +def test_univariate_models_forecast_custom_seasonality(): + """Assert that univariate models accept custom seasonality.""" + atom = ATOMForecaster(y_fc, random_state=1) + atom.run( + models=["ARIMA", "SARIMAX"], + n_trials=1, + est_params={ + "arima": {"maxiter": 5, "method": "nm"}, + "order": (1, 0, 0), + "seasonal_order": (1, 0, 0, 12), + }, + errors="raise", + ) + + +def test_multivariate_forecast_custom_seasonality(): + """Assert that multivariate models accept custom seasonality.""" + atom = ATOMForecaster(X_ex, y=(-1, -2), random_state=1) + atom.run( + models=["VARMAX"], + n_trials=1, + est_params={"error_cov_type": "diagonal", "method": "nm", "maxiter": 5, "order": (1, 0)}, + errors="raise", + ) + + @pytest.mark.skipif(machine() not in ("x86_64", "AMD64"), reason="Only x86 support") -def test_models_sklearnex_classification(): +@pytest.mark.parametrize("device", ["cpu", "gpu"]) +def test_models_sklearnex_classification(device): """Assert the sklearnex engine works for classification tasks.""" - atom = ATOMClassifier(X_bin, y_bin, engine={"estimator": "sklearnex"}, random_state=1) + atom = ATOMClassifier(X_bin, y_bin, device=device, engine="sklearnex", random_state=1) atom.run( models=["KNN", "LR", "RF", "SVM"], n_trials=2, @@ -149,9 +176,10 @@ def test_models_sklearnex_classification(): @pytest.mark.skipif(machine() not in ("x86_64", "AMD64"), reason="Only x86 support.") -def test_models_sklearnex_regression(): +@pytest.mark.parametrize("device", ["cpu", "gpu"]) +def test_models_sklearnex_regression(device): """Assert the sklearnex engine works for regression tasks.""" - atom = ATOMRegressor(X_reg, y_reg, engine={"estimator": "sklearnex"}, random_state=1) + atom = ATOMRegressor(X_reg, y_reg, device=device, engine="sklearnex", random_state=1) atom.run( models=["EN", "KNN", "Lasso", "OLS", "RF", "Ridge", "SVM"], n_trials=2, @@ -170,7 +198,7 @@ def test_models_sklearnex_regression(): ) def test_models_cuml_classification(): """Assert that all classification models can be called with cuml.""" - atom = ATOMClassifier(X_bin, y_bin, engine={"estimator": "cuml"}, random_state=1) + atom = ATOMClassifier(X_bin, y_bin, device="gpu", engine="cuml", verbose=2, random_state=1) atom.run( models=["!CatB", "!LGB", "!XGB"], n_trials=1, @@ -200,7 +228,7 @@ def test_models_cuml_classification(): ) def test_models_cuml_regression(): """Assert that all regression models can be called with cuml.""" - atom = ATOMRegressor(X_reg, y_reg, engine={"estimator": "cuml"}, random_state=1) + atom = ATOMRegressor(X_reg, y_reg, device="gpu", engine="cuml", random_state=1) atom.run( models=["!CatB", "!LGB", "!XGB"], n_trials=1, @@ -237,7 +265,7 @@ def test_RNN(): @patch("sktime.forecasting.statsforecast.StatsForecastMSTL") -def test_MSTL(cls): +def test_MSTL_with_stl_kwargs_params(cls): """Assert that the MSTL model works when providing stl_kwargs params.""" cls.return_value.fit = Mock() cls.return_value.set_params.return_value.predict.__name__ = "predict" @@ -253,6 +281,13 @@ def test_MSTL(cls): assert atom.models == "MSTL" +def test_Prophet_non_standard_seasonality(): + """Assert that the Prophet model works with non-standard seasonality.""" + atom = ATOMForecaster(y_fc, sp=3, random_state=1) + atom.run("Prophet") + assert atom.models == "Prophet" + + @pytest.mark.parametrize("model", ["CatB", "LGB", "XGB"]) def test_pruning_non_sklearn(model): """Assert that non-sklearn models can be pruned.""" diff --git a/tests/test_plots.py b/tests/test_plots.py index f1d43b3c5..d96f2ef31 100644 --- a/tests/test_plots.py +++ b/tests/test_plots.py @@ -7,14 +7,15 @@ import glob from pathlib import Path -from unittest.mock import MagicMock, Mock, patch +from unittest.mock import Mock, patch import numpy as np import pandas as pd import pytest from optuna.visualization._terminator_improvement import _ImprovementInfo from shap.plots._force import AdditiveForceVisualizer -from sklearn.metrics import f1_score, get_scorer +from sklearn.metrics import f1_score, get_scorer, mean_squared_error +from sktime.forecasting.base import ForecastingHorizon from atom import ATOMClassifier, ATOMForecaster, ATOMRegressor from atom.plots.baseplot import Aesthetics, BaseFigure @@ -219,9 +220,11 @@ def test_canvas(): """Assert that the canvas works.""" atom = ATOMRegressor(X_reg, y_reg, random_state=1) atom.run("Tree") - with atom.canvas(1, 2, sharex=True, sharey=True, title="Title", display=False) as fig: + with atom.canvas(2, 2, sharex=True, sharey=True, title="Title", display=False) as fig: atom.plot_residuals(title={"text": "Residuals plot", "x": 0}) atom.plot_feature_importance(title="Feature importance plot") + atom.plot_residuals() + atom.plot_residuals() assert fig.__class__.__name__ == "Figure" @@ -394,6 +397,13 @@ def test_plot_rfecv(scoring): atom.plot_rfecv(display=False) +def test_plot_series(): + """Assert that the plot_series method works.""" + atom = ATOMForecaster(y_fc, random_state=1) + atom.plot_series(columns=None, display=False) + atom.plot_series(columns=-1, display=False) + + def test_plot_wordcloud(): """Assert that the plot_wordcloud method works.""" atom = ATOMClassifier(X_text, y10, random_state=1) @@ -476,6 +486,13 @@ def test_plot_parallel_coordinate(): def test_plot_pareto_front(): """Assert that the plot_pareto_front method works.""" + atom = ATOMRegressor(X_reg, y_reg, random_state=1) + atom.run("tree") + + # Not multi-metric + with pytest.raises(PermissionError, match=".*models with multi-metric runs.*"): + atom.plot_pareto_front(display=False) + atom = ATOMRegressor(X_reg, y_reg, random_state=1) atom.run("tree", metric=["mae", "mse", "rmse"], n_trials=3) @@ -615,6 +632,10 @@ def test_plot_forecast(): atom.plot_forecast(inverse=False, display=False) atom.plot_forecast(fh=atom.holdout.index, X=atom.holdout, display=False) + atom = ATOMForecaster(y_fc, random_state=1) + atom.run(models="NF") + atom.plot_forecast(fh=ForecastingHorizon(range(3)), display=False) + def test_plot_gains(): """Assert that the plot_gains method works.""" @@ -646,8 +667,6 @@ def test_plot_parshap(): atom.dummy.plot_parshap(display=False) # Without colorbar -@patch("atom.plots.predictionplot.Parallel", MagicMock()) -@patch("atom.plots.predictionplot.partial_dependence", MagicMock()) def test_plot_partial_dependence(): """Assert that the plot_partial_dependence method works.""" atom = ATOMClassifier(X_label, y=y_label, stratify=False, random_state=1) @@ -761,6 +780,8 @@ def test_plot_results(): atom.plot_results(metric="time+mae", display=False) atom.plot_results(metric=None, display=False) + atom.plot_results(metric=0, display=False) + atom.plot_results(metric=mean_squared_error, display=False) atom.plot_results(metric=["time_fit+time"], display=False) atom.plot_results(metric=["mae", "mse"], display=False) @@ -882,4 +903,8 @@ def test_plot_shap_waterfall(): """Assert that the plot_shap_waterfall method works.""" atom = ATOMClassifier(X_class, y_class, random_state=1) atom.run("Tree") + + with pytest.raises(ValueError, match=".*plotting multiple samples.*"): + atom.plot_shap_waterfall(rows=(0, 1), display=False) + atom.plot_shap_waterfall(display=False)