1.1. Pre-Processing

Before feeding the data to a model, it is usually necessary to pre-process it. This is necessary due to the mathematical formulation of these models, where some of them might require data to follow specific patterns. Here, the following pre-processing strategies are going to be presented: normalization, encoding and feature selection.

Normalization

Normalization is a class of transformations that make features present more uniform statistics. The first approach for normalizing data is the absolute maximum normalization. This technique takes the a set of features \(\boldsymbol X \in \mathbb R^{N\times n}\), where \(N\) is the number of observations and \(n\) is the number of features, and applies the following transformation: \[ \boldsymbol{\tilde X} = \frac{\boldsymbol{X}}{\max\mid\boldsymbol{X}\mid}, \tag{1}\] where \(\boldsymbol{\tilde X}\) is the normalized set of features. This normalization scales the data between \(-1\) and \(1\), while preserving sparsity. Note that the maximum is taken feature-wise, and so is the division.

Another type of normalization is the \(\min-\max\) normalization, which scales the data between \(0\) and \(1\), where the minimum value of the original data is mapped to \(0\), and the maximum to \(1\). It is given by: \[ \boldsymbol{\tilde X} = \frac{\boldsymbol{X} - \min(\boldsymbol{X})}{\max(\boldsymbol{X}) - \min(\boldsymbol{X})}. \tag{2}\] Note that unlike absolute maximum normalization, \(\min-\max\) normalization does not preserve sparsity.

Finally, standard normalization scales the data to so that each feature has a standard deviation of \(1\) and a mean of \(0\). The transformation is given by: \[ \boldsymbol{\tilde X} = \frac{\boldsymbol{X} - \boldsymbol{\mu}_{\boldsymbol{X}}}{\boldsymbol{\sigma}_{\boldsymbol{X}}}, \tag{3}\] where \(\boldsymbol{\mu}_{\boldsymbol{X}}\) are the mean values of the features and \(\boldsymbol{\sigma}_{\boldsymbol{X}}\) are the standard deviations.

Code: Normalization

Here, synthetic data is going to be normalized, in order to compare the different transformations explained. Importing packages.

import numpy as np
import matplotlib.pyplot as plt
from sklearn.preprocessing import MaxAbsScaler, MinMaxScaler, StandardScaler

Generating and printing data minimum and maximum.

feature_1 = np.random.normal(500000, 50000, 100)
feature_2 = np.random.normal(3, 1, 100)

X = np.column_stack((feature_1, feature_2))

print(f"Minimum: {X.min(axis=0)}")
print(f"Maximum: {X.max(axis=0)}")
Minimum: [3.44361495e+05 7.88805078e-02]
Maximum: [6.15555239e+05 5.18227783e+00]

Applying absolute maximum absolute normalization.

max_abs_scaler = MaxAbsScaler()
X_maxabs = max_abs_scaler.fit_transform(X)

print(f"Minimum: {X_maxabs.min(axis=0)}")
print(f"Maximum: {X_maxabs.max(axis=0)}")
Minimum: [0.55943232 0.0152212 ]
Maximum: [1. 1.]

Applying min-max normalization.

min_max_scaler = MinMaxScaler()
X_minmax = min_max_scaler.fit_transform(X)

print(f"Minimum: {X_minmax.min(axis=0)}")
print(f"Maximum: {X_minmax.max(axis=0)}")
Minimum: [0. 0.]
Maximum: [1. 1.]

Applying standard normalization.

std_scaler = StandardScaler()
X_std = std_scaler.fit_transform(X)

print(f"Mean: {X_std.mean(axis=0)}")
print(f"Std:  {X_std.std(axis=0)}")
Mean: [ 3.74367204e-15 -5.90638649e-16]
Std:  [1. 1.]

Plotting the data with different normalizations.

fig, axes = plt.subplots(2, 2, figsize=(10, 10))
axes = axes.ravel()

axes[0].scatter(X[:, 0], X[:, 1], alpha=0.7, color='red')
axes[0].set_title("Original Data")
axes[0].set_xlabel("Feature 1")
axes[0].set_ylabel("Feature 2")

axes[1].scatter(X_maxabs[:, 0], X_maxabs[:, 1], alpha=0.7, color='green')
axes[1].set_title("Maximum Absolute Normalization")
axes[1].set_xlabel("Feature 1")
axes[1].set_ylabel("Feature 2")

axes[2].scatter(X_minmax[:, 0], X_minmax[:, 1], alpha=0.7, color='green')
axes[2].set_title("Min-Max Normalization")
axes[2].set_xlabel("Feature 1")
axes[2].set_ylabel("Feature 2")

axes[3].scatter(X_std[:, 0], X_std[:, 1], alpha=0.7, color='blue')
axes[3].set_title("Standard Normalization")
axes[3].set_xlabel("Feature 1")
axes[3].set_ylabel("Feature 2")

plt.tight_layout()
plt.show()

Source: normalization.ipynb

Encoding

Another important part of pre-processing is encoding features that are not numerical. This envolves finding good representations for features that might be stored as strings, for example. When there is some sense of order in the feature, the ordinal encoder is used. It converts the \(m\) original classes into integers, keeping the order. On the other hand, if there is no order, the data is passed through a one-hot encoder, which converts the original \(m\) classes into \(m\) new features, where the columns are sparse, only containing \(1\) when the observations belongs to the specific class.

Code: Encoding

Here, examples of ordinal and one-hot encoding are going to be presented. Importing the libraries.

import polars as pl
import numpy as np
from sklearn.preprocessing import OneHotEncoder, OrdinalEncoder

Generating a toy-dataset.

data = {
    'ID': [1, 2, 3, 4, 5],
    'Color': ['Red', 'Blue', 'Green', 'Blue', 'Red'],
    'Size': ['Small', 'Large', 'Medium', 'Small', 'Medium'],
    'Price': [10, 20, 15, 12, 18]
}

df = pl.DataFrame(data)
print(df)
shape: (5, 4)
┌─────┬───────┬────────┬───────┐
│ ID  ┆ Color ┆ Size   ┆ Price │
│ --- ┆ ---   ┆ ---    ┆ ---   │
│ i64 ┆ str   ┆ str    ┆ i64   │
╞═════╪═══════╪════════╪═══════╡
│ 1   ┆ Red   ┆ Small  ┆ 10    │
│ 2   ┆ Blue  ┆ Large  ┆ 20    │
│ 3   ┆ Green ┆ Medium ┆ 15    │
│ 4   ┆ Blue  ┆ Small  ┆ 12    │
│ 5   ┆ Red   ┆ Medium ┆ 18    │
└─────┴───────┴────────┴───────┘

As the Color feature has no order in its values, one-hot encoding is going to be used. As for the Size feature, values assume order, so ordinal encoding is needed.

encoder_ohe = OneHotEncoder(sparse_output=False)
ohe_encoded = encoder_ohe.fit_transform(df.select("Color").to_numpy())

ohe_df = pl.DataFrame(
    ohe_encoded,
    schema=[f"color_{c}" for c in encoder_ohe.categories_[0]]
)

order = [["Small", "Medium", "Large"]]
encoder_ord = OrdinalEncoder(categories=order)
exp_encoded = encoder_ord.fit_transform(df.select("Size").to_numpy())

ord_df = pl.concat([df, ohe_df], how="horizontal").with_columns(
    pl.Series("Size_ordinal_encoding", exp_encoded.flatten())
)

print(ord_df)
shape: (5, 8)
┌─────┬───────┬────────┬───────┬────────────┬─────────────┬───────────┬───────────────────────┐
│ ID  ┆ Color ┆ Size   ┆ Price ┆ color_Blue ┆ color_Green ┆ color_Red ┆ Size_ordinal_encoding │
│ --- ┆ ---   ┆ ---    ┆ ---   ┆ ---        ┆ ---         ┆ ---       ┆ ---                   │
│ i64 ┆ str   ┆ str    ┆ i64   ┆ f64        ┆ f64         ┆ f64       ┆ f64                   │
╞═════╪═══════╪════════╪═══════╪════════════╪═════════════╪═══════════╪═══════════════════════╡
│ 1   ┆ Red   ┆ Small  ┆ 10    ┆ 0.0        ┆ 0.0         ┆ 1.0       ┆ 0.0                   │
│ 2   ┆ Blue  ┆ Large  ┆ 20    ┆ 1.0        ┆ 0.0         ┆ 0.0       ┆ 2.0                   │
│ 3   ┆ Green ┆ Medium ┆ 15    ┆ 0.0        ┆ 1.0         ┆ 0.0       ┆ 1.0                   │
│ 4   ┆ Blue  ┆ Small  ┆ 12    ┆ 1.0        ┆ 0.0         ┆ 0.0       ┆ 0.0                   │
│ 5   ┆ Red   ┆ Medium ┆ 18    ┆ 0.0        ┆ 0.0         ┆ 1.0       ┆ 1.0                   │
└─────┴───────┴────────┴───────┴────────────┴─────────────┴───────────┴───────────────────────┘
Source: encoding.ipynb

Feature Selection

In some cases, the dataset has numerous features, resulting in complex and computationally expensive models. Therefore, it is interesting to select a small subset of features to decrease the computational cost of the trained model. This selection is important to avoid the curse of dimensionality, in which the increase of features in the model worsens its performance.

The first strategy is to eliminate features by variance. When a feature does not vary throughout observations, it does not add new information to the model, therefore, it can be excluded. Similarly, one can define a variance threshold for eliminating features.

A more advanced approach is to use the Variance Inflation Factor (VIF) algorithm. It removes features based on their multicollinearity with other features. That is, if one feature is a linear combination of other ones, it is removed. For each feature \(x_j \in \boldsymbol{X}\), the VIF trains a linear regression with \(x_j\) as a target. The coefficient of determination \(R^2\) of the final model is used to calculate the VIF coefficient: \[ \text{VIF}(x_j) = \frac{1}{1-R_j^2}. \tag{4}\] If this coefficient is equal to \(1\), there is no multicollinearity, while higher values indicate correlation. With that, given a threshold – generally \(5\) or \(10\) –, features can be removed.

Code: Feature Selection

In this code, VIF will be used to remove features that can be represented via linear combination of other features. Importing libraries.

import numpy as np
import polars as pl
import statsmodels.api as sm
from statsmodels.stats.outliers_influence import variance_inflation_factor

Generating data, and creating a linear combination of features with extra noise.

n_samples = 100

x1 = np.random.normal(2, 1, n_samples)
x2 = np.random.normal(1, 1, n_samples)
x3 = np.random.normal(-1, 1, n_samples)
x4 = np.random.normal(-2, 1, n_samples)

x5 = 2 * x1 + 3 * x2 - x3 + x4 + np.random.normal(0, 0.01, n_samples)

df = pl.DataFrame({"x1": x1, "x2": x2, "x3": x3, "x4": x4, "x5": x5})
print(df)
shape: (100, 5)
┌──────────┬──────────┬───────────┬───────────┬───────────┐
│ x1       ┆ x2       ┆ x3        ┆ x4        ┆ x5        │
│ ---      ┆ ---      ┆ ---       ┆ ---       ┆ ---       │
│ f64      ┆ f64      ┆ f64       ┆ f64       ┆ f64       │
╞══════════╪══════════╪═══════════╪═══════════╪═══════════╡
│ 1.127813 ┆ 1.976019 ┆ -0.306735 ┆ -2.932537 ┆ 5.553077  │
│ 1.702715 ┆ 2.837258 ┆ 0.608874  ┆ -3.093993 ┆ 8.240383  │
│ 3.728344 ┆ 1.754147 ┆ -0.640377 ┆ -1.966818 ┆ 11.409613 │
│ 0.130101 ┆ 2.07627  ┆ -2.519377 ┆ -1.094331 ┆ 7.905766  │
│ 1.842719 ┆ 0.689719 ┆ -1.809076 ┆ -2.46972  ┆ 5.091085  │
│ …        ┆ …        ┆ …         ┆ …         ┆ …         │
│ 2.346223 ┆ 0.987793 ┆ 0.792093  ┆ -2.469477 ┆ 4.412669  │
│ 3.270795 ┆ 0.981068 ┆ -1.740227 ┆ -2.858559 ┆ 8.373868  │
│ 2.448963 ┆ 1.937447 ┆ -1.56691  ┆ -1.530378 ┆ 10.756647 │
│ 1.616927 ┆ 3.011794 ┆ -1.378437 ┆ -0.693779 ┆ 12.944466 │
│ 1.235787 ┆ 2.634723 ┆ -0.22323  ┆ -0.927663 ┆ 9.676474  │
└──────────┴──────────┴───────────┴───────────┴───────────┘

Creating a simple VIF function.

df_with_const = df.with_columns(pl.lit(1).alias("const"))
X_matrix = df_with_const.to_numpy()

for i, col in enumerate(df_with_const.columns):
    if col != "const":
        vif = variance_inflation_factor(X_matrix, i)
        print(f"{col}: {vif:.2f}")
x1: 49692.18
x2: 75753.11
x3: 10711.37
x4: 9551.55
x5: 121103.62

As the results show, feature x5 has the highest VIF value, it would be the first feature to be dropped. Note that the other features also have high values, because the generation of the linearly dependent feature also make other features dependent on it.

Source: vif.ipynb