diff --git a/docs/images/winso-iqr.png b/docs/images/winso-iqr.png new file mode 100644 index 000000000..0d275cdba Binary files /dev/null and b/docs/images/winso-iqr.png differ diff --git a/docs/images/winso-quantiles.png b/docs/images/winso-quantiles.png new file mode 100644 index 000000000..dec3ca9e7 Binary files /dev/null and b/docs/images/winso-quantiles.png differ diff --git a/docs/images/winso-raw.png b/docs/images/winso-raw.png new file mode 100644 index 000000000..6c6199358 Binary files /dev/null and b/docs/images/winso-raw.png differ diff --git a/docs/user_guide/outliers/ArbitraryOutlierCapper.rst b/docs/user_guide/outliers/ArbitraryOutlierCapper.rst index e96da692f..ec9cb253f 100644 --- a/docs/user_guide/outliers/ArbitraryOutlierCapper.rst +++ b/docs/user_guide/outliers/ArbitraryOutlierCapper.rst @@ -5,10 +5,13 @@ ArbitraryOutlierCapper ====================== -The :class:`ArbitraryOutlierCapper()` caps the maximum or minimum values of a variable +:class:`ArbitraryOutlierCapper()` caps the maximum or minimum values of a variable at an arbitrary value indicated by the user. The maximum or minimum values should be entered in a dictionary with the form {feature:capping value}. +Python implementation +--------------------- + Let's look at this in an example. First we load the Titanic dataset, and separate it into a train and a test set: @@ -62,6 +65,9 @@ dictionary to the attribute that will be used in the transformation: capper.right_tail_caps_ +In the following output, we see that the dictionary we entered when setting up the transformer +was assigned to a different attribute after fitting: + .. code:: python {'age': 50, 'fare': 200} @@ -73,13 +79,16 @@ Now, we can go ahead and cap the variables: train_t = capper.transform(X_train) test_t = capper.transform(X_test) -If we now check the maximum values in the transformed data, they should be those entered +If we now check the maximum values in the transformed data -they should be those entered in the dictionary: .. code:: python train_t[['fare', 'age']].max() +In the following output, we see that the variables were capped at the requested maximum +values: + .. code:: python fare 200.0 @@ -90,56 +99,12 @@ in the dictionary: Additional resources -------------------- -You can find more details about the :class:`ArbitraryOutlierCapper()` functionality in the following -notebook: - -- `Jupyter notebook `_ - For more details about this and other feature engineering methods check out these resources: +- `Feature Engineering for Machine Learning `_, online course. +- `Feature Engineering for Time Series Forecasting `_, online course. +- `Python Feature Engineering Cookbook `_, book. -.. figure:: ../../images/feml.png - :width: 300 - :figclass: align-center - :align: left - :target: https://www.trainindata.com/p/feature-engineering-for-machine-learning - - Feature Engineering for Machine Learning - -| -| -| -| -| -| -| -| -| -| - -Or read our book: - -.. figure:: ../../images/cookbook.png - :width: 200 - :figclass: align-center - :align: left - :target: https://www.packtpub.com/en-us/product/python-feature-engineering-cookbook-9781835883587 - - Python Feature Engineering Cookbook - -| -| -| -| -| -| -| -| -| -| -| -| -| - -Both our book and course are suitable for beginners and more advanced data scientists -alike. By purchasing them you are supporting Sole, the main developer of Feature-engine. \ No newline at end of file +Both our book and courses are suitable for beginners and more advanced data scientists +alike. By purchasing them you are supporting `Sole `_, +the main developer of feature-engine. \ No newline at end of file diff --git a/docs/user_guide/outliers/OutlierTrimmer.rst b/docs/user_guide/outliers/OutlierTrimmer.rst index 22a8190a2..03e9bd91b 100644 --- a/docs/user_guide/outliers/OutlierTrimmer.rst +++ b/docs/user_guide/outliers/OutlierTrimmer.rst @@ -10,7 +10,9 @@ occurrences. Outliers can distort the learning process of machine learning model reducing predictive accuracy. To prevent this, if you suspect that the outliers are errors or rare occurrences, you can remove them from the training data. -In this guide, we show how to remove outliers in Python using the :class:`OutlierTrimmer()`. +.. note:: + + In this guide, we show how to remove outliers in Python using the :class:`OutlierTrimmer()`. The first step to removing outliers consists of identifying those outliers. Outliers can be identified through various statistical methods, such as box plots, z-scores, the interquartile range (IQR), or the median absolute deviation. @@ -44,7 +46,7 @@ Interquartile range proximity rule ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ The interquartile range proximity rule can be used to detect outliers both in variables that show a normal distribution -and in variables with a skew. When using the IQR, we detect outliers as those values that lie before the 25th percentile +and in variables with a skew. When using the IQR, we define outliers as those values that lie before the 25th percentile times a factor of the IQR, or after the 75th percentile times a factor of the IQR. This factor is normally 1.5, or 3 if we want to be more stringent. With the IQR method, the limits are calculated as follows: @@ -87,13 +89,18 @@ values would be those that lie before or after a certain percentile or quantiles - right tail: 95th percentile - left tail: 5th percentile -The number of outliers identified by any of these methods will vary. These methods detect outliers, but they can’t -decide if they are true outliers or faithful data points. That required further examination and domain knowledge. +The number of outliers identified by any of these methods will vary. + +.. note:: + + These methods help detect extreme values in a distribution, but they can’t decide if + they are true outliers or faithful data points. That requires further examination + and domain knowledge. Let’s move on to removing outliers in Python. -Remove outliers in Python -------------------------- +Removing outliers in Python +--------------------------- In this demo, we'll identify and remove outliers from the Titanic Dataset. First, let's load the data and separate it into train and test: @@ -142,13 +149,13 @@ Let's now identify potential extreme values in the training set by using boxplot plt.show() -In the following boxplots, we see that all three variables have data points that are significantly greater than the +In the following boxplot, we see that all three variables have data points that are significantly greater than the majority of the data distribution. The variable age also shows outlier values towards the lower values. .. figure:: ../../images/boxplot-titanic.png :align: center -The variables have different scales, so let's plot them individually for better visualization. Let's start by making a +The variables have different scales, so let's plot them individually for better visualisation. Let's start by making a boxplot of the variable fare: .. code:: python @@ -215,12 +222,14 @@ we only want to cap outliers in 2 variables, which we indicate in a list. ot.fit(X_train) With `fit()`, the :class:`OutlierTrimmer()` finds the values at which it should cap the variables. These values are -stored in one of its attributes: +stored in the following attribute: .. code:: python ot.right_tail_caps_ +In the following output, we see the values beyond which observations will be deemed outliers: + .. code:: python {'sibsp': 2.5, 'fare': 66.34379999999999} @@ -244,20 +253,22 @@ We see that the transformed dataset contains less rows: ((916, 8), (764, 8)) -If we evaluate now the maximum of the variables in the transformed datasets, they should be <= the values observed in +If we evaluate the maximum of the variables in the transformed datasets, they should be <= the values observed in the attribute `right_tail_caps_`: .. code:: python train_t[['fare', 'age']].max() +In the following output, we see the maximum of the variables after removing the outliers: + .. code:: python fare 65.0 age 53.0 dtype: float64 -Finally, we can check the boxplots of the transformed variables to corroborate the effect on their distribution. +Finally, we can check the boxplot of the transformed variables to corroborate the effect on their distribution. .. code:: python @@ -266,7 +277,7 @@ Finally, we can check the boxplots of the transformed variables to corroborate t plt.ylabel("variable values") plt.show() -We see the boxplot and the `sibsp` does no longer have outliers, but as `fare` was very skewed, when removing outliers, +We can see in the boxplot that `sibsp` does no longer have outliers, but as `fare` was very skewed, when removing outliers, the parameters of the IQR change, and we continue to see outliers: .. figure:: ../../images/boxplot-sibsp-fare-iqr.png @@ -291,19 +302,20 @@ We can corroborate the size adjustment in the target as follows: y_train.shape, y_train_t.shape, -The previous command returns the following output: +We see in the output that the transformed target has the same number of rows that the +transformed dataset: .. code:: python ((916,), (764,)) -We can obtain the names of the fetaures in the transformed dataset as follows: +We can obtain the names of the features in the transformed dataset as follows: .. code:: python ot.get_feature_names_out() -That returns the following variable namesL +That returns the following variable names: .. code:: python @@ -312,8 +324,8 @@ That returns the following variable namesL MAD ^^^ -We saw that the IQR did not work amazingly for the variable fare, because its skew is too big. So let's remove outliers -by using the MAD instead: +We saw that the IQR did not work amazingly for the variable `fare` because it's a very +skewed variable. So let's remove outliers by using the MAD instead: .. code:: python @@ -362,22 +374,26 @@ Let's inspect the maximum values beyond which data points will be considered out ot_age.right_tail_caps_ +Values greater than the following value will be considered outliers: + .. code:: python {'age': 67.73951212364803} -And the lower values beyond which data points will be considered outliers: +Let's now check out the lower value beyond which data points will be considered outliers: .. code:: python ot_age.left_tail_caps_ +Values smaller than the following will be considered outliers: + .. code:: python {'age': -7.410476010820627} -The minimum value does not make sense, because age can't be negative. So, we'll try capping this variable with -percentiles instead. +The minimum value determined by the z-score does not make sense because age can't be +negative. So, we'll try capping this variable with percentiles instead. Percentiles ^^^^^^^^^^^ @@ -389,7 +405,7 @@ We'll cap age at the bottom 5 and top 95 percentile: ot = OutlierTrimmer(capping_method='mad', tail='right', fold=0.05, - variables=['fare'], + variables=['age'], ) ot.fit(X_train) @@ -401,28 +417,32 @@ Let's inspect the maximum values beyond which data points will be considered out ot_age.right_tail_caps_ +Values greater than the following will be considered outliers: + .. code:: python {'age': 54.0} -And the lower values beyond which data points will be considered outliers: +Let's inspect the minimum value beyond which data points will be considered outliers: .. code:: python ot_age.left_tail_caps_ +Values smaller than the following will be considered outliers: + .. code:: python {'age': 9.0} -Let's tranform the dataset and target: +Let's transform the dataset and target: .. code:: python train_t, y_train_t = ot_age.transform_x_y(X_train, y_train) test_t, y_test_t = ot_age.transform_x_y(X_test, y_test) -And plot the resulting variable: +After removing the outliers from `age`, let's plot the resulting variable: .. code:: python @@ -442,7 +462,7 @@ Pipeline The :class:`OutlierTrimmer()` removes observations from the predictor data sets. If we want to use this transformer within a Pipeline, we can't use Scikit-learn's pipeline because it can't readjust the target. But we can use -Feature-engine's pipeline instead. +feature-engine's pipeline instead. Let's start by creating a pipeline that removes outliers and then encodes categorical variables: @@ -608,58 +628,15 @@ The default values for fold are as follows: You can manually adjust the fold value to make the outlier detection process more or less conservative, thus customizing the extent of outlier trimming. -Tutorials, books and courses ----------------------------- - -In the following Jupyter notebook, in our accompanying Github repository, you will find more examples using -:class:`OutlierTrimmer()`. - -- `Jupyter notebook `_ - -For tutorials about this and other feature engineering methods check out our online course: - -.. figure:: ../../images/feml.png - :width: 300 - :figclass: align-center - :align: left - :target: https://www.trainindata.com/p/feature-engineering-for-machine-learning - - Feature Engineering for Machine Learning - -| -| -| -| -| -| -| -| -| -| - -Or read our book: - -.. figure:: ../../images/cookbook.png - :width: 200 - :figclass: align-center - :align: left - :target: https://www.packtpub.com/en-us/product/python-feature-engineering-cookbook-9781835883587 - - Python Feature Engineering Cookbook - -| -| -| -| -| -| -| -| -| -| -| -| -| - -Both our book and course are suitable for beginners and more advanced data scientists -alike. By purchasing them you are supporting Sole, the main developer of Feature-engine. \ No newline at end of file +Additional resources +-------------------- + +For more details about this and other feature engineering methods check out these resources: + +- `Feature Engineering for Machine Learning `_, online course. +- `Feature Engineering for Time Series Forecasting `_, online course. +- `Python Feature Engineering Cookbook `_, book. + +Both our book and courses are suitable for beginners and more advanced data scientists +alike. By purchasing them you are supporting `Sole `_, +the main developer of feature-engine. \ No newline at end of file diff --git a/docs/user_guide/outliers/Winsorizer.rst b/docs/user_guide/outliers/Winsorizer.rst index 741690238..eef16794e 100644 --- a/docs/user_guide/outliers/Winsorizer.rst +++ b/docs/user_guide/outliers/Winsorizer.rst @@ -5,114 +5,311 @@ Winsorizer ========== -The :class:`Winsorizer()` caps maximum and/or minimum values of a variable at automatically -determined values. The minimum and maximum values can be calculated in 1 of 3 different ways: +Outliers are data points that significantly differ from the majority of the observations +of the variable. They can be a natural occurrence, or they can be collection errors. -Gaussian limits: +Importantly, outliers can affect the learning process of some machine learning algorithms +by skewing parameter estimates, hence reducing predictive accuracy. + +If you suspect the presence of outliers in your variables and are training models or conducting +statistical analyses that are susceptible to outliers, then, you can remove the outliers from +the data. + +Outliers can be directly removed (check out :ref:`OutlierTrimmer() `), +or alternatively, the variable values can be capped at arbitrarily defined minimum and +maximum values. + +.. note:: + + :class:`Winsorizer()` caps maximum and/or minimum values of a variable at automatically + determined values. + +Calculating the capping values +------------------------------ + +The minimum and maximum values can be calculated in 4 different ways: + +Gaussian limits or z-score +~~~~~~~~~~~~~~~~~~~~~~~~~~ - right tail: mean + 3* std - left tail: mean - 3* std -IQR limits: +Interquartile range proximity rule +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - right tail: 75th quantile + 1.5* IQR - left tail: 25th quantile - 1.5* IQR where IQR is the inter-quartile range: 75th quantile - 25th quantile. -MAD limits: +Maximum absolute deviation +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +- right tail: median + 3.29* MAD +- left tail: median - 3.29* MAD - - right tail: median + 3.29* MAD - - left tail: median - 3.29* MAD +where MAD is the median absolute deviation from the median: -where MAD is the median absolute deviation from the median. +- MAD = median(abs(X-median(X))) -percentiles or quantiles: +Percentiles or quantiles +~~~~~~~~~~~~~~~~~~~~~~~~ - right tail: 95th percentile - left tail: 5th percentile -**Example** +.. note:: + + The factor that multiplies the `std`, `IQR`, or `MAD`, as well as the percentiles to + use to find the capping values can be changed to make the capping more or less stringent. + The values used by default by :class:`Winsorizer()` are those suggested as optimal + in statistical studies. + -Let's cap some outliers in the Titanic Dataset. First, let's load the data and separate +Python implementation +--------------------- + +In this section, we'll show how to cap variables using :class:`Winsorizer()`. + +We'll use the house prices dataset. Let's load the data and separate it into train and test: .. code:: python + import matplotlib.pyplot as plt + from sklearn.datasets import fetch_openml from sklearn.model_selection import train_test_split - from feature_engine.datasets import load_titanic - from feature_engine.outliers import Winsorizer - X, y = load_titanic( - return_X_y_frame=True, - predictors_only=True, - handle_missing=True, + # Load dataset + data = fetch_openml( + name='house_prices', + version=1, + as_frame=True, + parser='auto', + ).frame + + # Separate into train and test sets + X_train, X_test, y_train, y_test = train_test_split( + data.drop(['Id', 'SalePrice'], axis=1), + data['SalePrice'], + test_size=0.3, + random_state=0, ) + print(X_train.head()) - X_train, X_test, y_train, y_test = train_test_split( - X, y, test_size=0.3, random_state=0, - ) +We see the resulting training set below: - print(X_train.head()) +.. code:: python + + MSSubClass MSZoning LotFrontage LotArea Street Alley LotShape \ + 64 60 RL NaN 9375 Pave NaN Reg + 682 120 RL NaN 2887 Pave NaN Reg + 960 20 RL 50.0 7207 Pave NaN IR1 + 1384 50 RL 60.0 9060 Pave NaN Reg + 1100 30 RL 60.0 8400 Pave NaN Reg + + LandContour Utilities LotConfig ... ScreenPorch PoolArea PoolQC Fence \ + 64 Lvl AllPub Inside ... 0 0 NaN GdPrv + 682 HLS AllPub Inside ... 0 0 NaN NaN + 960 Lvl AllPub Inside ... 0 0 NaN NaN + 1384 Lvl AllPub Inside ... 0 0 NaN MnPrv + 1100 Bnk AllPub Inside ... 0 0 NaN NaN -We see the resulting data below: + MiscFeature MiscVal MoSold YrSold SaleType SaleCondition + 64 NaN 0 2 2009 WD Normal + 682 NaN 0 11 2008 WD Normal + 960 NaN 0 2 2010 WD Normal + 1384 NaN 0 10 2009 WD Normal + 1100 NaN 0 1 2009 WD Normal + + [5 rows x 79 columns] + +Let's create a function to plot the histogram of 2 variables, `GrLivArea` and `MasVnrArea` +next to their respective boxplots: .. code:: python - pclass sex age sibsp parch fare cabin embarked - 501 2 female 13.000000 0 1 19.5000 Missing S - 588 2 female 4.000000 1 1 23.0000 Missing S - 402 2 female 30.000000 1 0 13.8583 Missing C - 1193 3 male 29.881135 0 0 7.7250 Missing Q - 686 3 female 22.000000 0 0 7.7250 Missing Q + def plot_distributions(df): + fig, axes = plt.subplots(ncols=2, nrows=2, figsize=(10,8)) + + # Histogram var 1 + df['GrLivArea'].plot.hist(bins=20, ax=axes[0,0]) + axes[0,0].set_title('GrLivArea') + + # Boxplot var 1 + df.boxplot(column=['GrLivArea'], ax=axes[0,1]) + axes[0,1].set_title('Boxplot') + axes[0,1].set_ylabel("Value") + + # Histogram var 2 + df['MasVnrArea'].plot.hist(bins=20, ax=axes[1,0]) + axes[1,0].set_title('MasVnrArea') + + # Boxplot var 2 + df.boxplot(column=['MasVnrArea'], ax=axes[1,1]) + axes[1,1].set_title('Boxplot') + axes[1,1].set_ylabel("Value") + plt.tight_layout(w_pad=2) + + plt.tight_layout(w_pad=2) + plt.show() + +Let's now test the function using the training set: + +.. code:: python + + plot_distributions(X_train) + +In the following image, we see the histogram of the 2 variables followed by their respective +box plots: + +.. figure:: ../../images/winso-raw.png + :align: center + +We observe that both variables are right skewed and also that they seem to have outliers +(see dots beyond the top whiskers in the boxplot). + +.. tip:: + + The methods to determine outliers highlight observations that deviate from the variable's + expected distributions, but they can't unequivocally confirm if they are true outliers or + mere rare occurrences. Determining true outliers requires domain knowledge and further data exploration. + +We will set :class:`Winsorizer()` to cap outliers in the previous variables at the right +side of the distribution (param `tail`'s default functionality). We want the maximum +values to be determined using the interquartile range proximity rule (param `capping_method`) +using 1.5 of the IQR to find those limits (param `fold`). + +.. note:: + + One of the variables contains NAN, hence, we impute the dataframe before finding outliers. -Now, we will set the :class:`Winsorizer()` to cap outliers at the right side of the -distribution only (param `tail`). We want the maximum values to be determined using the -mean value of the variable (param `capping_method`) plus 3 times the standard deviation -(param `fold`). And we only want to cap outliers in 2 variables, which we indicate in a -list. .. code:: python - capper = Winsorizer(capping_method='gaussian', - tail='right', - fold=3, - variables=['age', 'fare']) + from feature_engine.imputation import MeanMedianImputer + from feature_engine.pipeline import Pipeline + from feature_engine.outliers import Winsorizer + + w = Winsorizer( + capping_method="iqr", + fold=1.5, + variables=['GrLivArea', 'MasVnrArea'], + ) + + pipe = Pipeline([ + ("imputer", MeanMedianImputer()), + ("outlier", w), + ]) + + # fit the transformer + train_t = pipe.fit_transform(X_train) + test_t = pipe.transform(X_test) - capper.fit(X_train) +In the previous code, we created a pipeline that first imputes variables with their mean +and then caps the variables `'GrLivArea'` and `'MasVnrArea'` at a maximum value determined +using the IQR proximity rule. -With `fit()`, the :class:`Winsorizer()` finds the values at which it should cap the variables. -These values are stored in its attribute: +With `fit()`, :class:`Winsorizer()` finds the values at which it should cap the variables. +These values are stored in the following attribute: .. code:: python - capper.right_tail_caps_ + pipe.named_steps["outlier"].right_tail_caps_ + +Below, we see the maximum values for each variable: + +.. code:: python + + {'GrLivArea': 2764.625, 'MasVnrArea': 425.0} + +:class:`Winsorizer()` has also a `left_tail_caps_` attribute, which in this case should +be empty. Let's check that out: .. code:: python - {'age': 67.73951212364803, 'fare': 174.70395336846678} + pipe.named_steps["outlier"].left_tail_caps_ -We can now go ahead and censor the outliers: +Below, we see that the dictionary is empty: .. code:: python - # transform the data - train_t = capper.transform(X_train) - test_t = capper.transform(X_test) + {} -If we evaluate now the maximum of the variables in the transformed datasets, they should -coincide with the values observed in the attribute `right_tail_caps_`: +Let's now plot the histogram and boxplot of the transformed variables with the function +we created previously: + +.. code:: python + + plot_distributions(train_t) + +In the following image, we see the histogram of the 2 variables followed by their respective +box plots after capping their maximum values: + +.. figure:: ../../images/winso-iqr.png + :align: center + + +We observe in the previous image that the variables no longer have outliers (no dots beyond +the whiskers of the boxplot). We also see more observations placed at the right end of the +distribution (rightmost bin in the histograms). + +As an alternative, let's cap the variables at their 10th percentile on the right tail: + +.. code:: python + + w = Winsorizer( + capping_method="quantiles", + tail = "right", + fold = 0.1, + variables=['GrLivArea', 'MasVnrArea'], + ) + + pipe = Pipeline([ + ("imputer", MeanMedianImputer()), + ("outlier", w), + ]) + + # fit the transformer + train_t = pipe.fit_transform(X_train) + test_t = pipe.fit_transform(X_test) + +Let's now go ahead and plot the distributions of the transformed variables: .. code:: python - train_t[['fare', 'age']].max() + plot_distributions(train_t) + +In the following image, we see the histogram of the 2 variables followed by their respective +box plots after capping their maximum values: + +.. figure:: ../../images/winso-quantiles.png + :align: center + +We observe again that the variables have no outliers. + +We can inspect the maximum values determined with this method: .. code:: python - fare 174.703953 - age 67.739512 - dtype: float64 + pipe.named_steps["outlier"].right_tail_caps_ + +Below, we see the maximum values for each variable: + +.. code:: python + + {'GrLivArea': 2146.7000000000003, 'MasVnrArea': 340.6} + + +.. tip:: + + The methods used to determine the capping values will return different results. For + variables that are normally distributed, the z-score is a good choice. For skewed variables + any of the other methods are better suited. Keep in mind, however, that if the variable + is too skewed, calculating some of the parameters of the IQR or MAD methods will not be + possible and the transformer will raise an error. In those cases, use the percentiles instead. Setting up the stringency (param `fold`) ---------------------------------------- @@ -130,61 +327,17 @@ The default values for fold are as follows: - 'quantiles': `fold` is set to 0.05. You can manually adjust the `fold` value to make the outlier detection process more or less -conservative, thus customizing the extent of outlier capping. +conservative, thus customising the extent of outlier capping. Additional resources -------------------- -You can find more details about the :class:`Winsorizer()` functionality in the following -notebook: - -- `Jupyter notebook `_ - For more details about this and other feature engineering methods check out these resources: +- `Feature Engineering for Machine Learning `_, online course. +- `Feature Engineering for Time Series Forecasting `_, online course. +- `Python Feature Engineering Cookbook `_, book. -.. figure:: ../../images/feml.png - :width: 300 - :figclass: align-center - :align: left - :target: https://www.trainindata.com/p/feature-engineering-for-machine-learning - - Feature Engineering for Machine Learning - -| -| -| -| -| -| -| -| -| -| - -Or read our book: - -.. figure:: ../../images/cookbook.png - :width: 200 - :figclass: align-center - :align: left - :target: https://www.packtpub.com/en-us/product/python-feature-engineering-cookbook-9781835883587 - - Python Feature Engineering Cookbook - -| -| -| -| -| -| -| -| -| -| -| -| -| - -Both our book and course are suitable for beginners and more advanced data scientists -alike. By purchasing them you are supporting Sole, the main developer of Feature-engine. +Both our book and courses are suitable for beginners and more advanced data scientists +alike. By purchasing them you are supporting `Sole `_, +the main developer of feature-engine.