Tabla de Contenidos

10 - Regularización

La regularización consiste en añadir una penalización a la función de coste para minimizar los pesos con el objeto de hacer el modelo más sencillo. Las regularizaciones más usadas en ML son:

Para ver como funcionan, vamos a crear un modelo de regresión lineal con los datos del dataset meatspec.zip. Este dataset contiene datos del contenido de grasa en carnes según 100 parámetros. El objetivo es predecir ese contenido de grasa.

Librerías necesarias:

import pandas as pd
import numpy as np


from sklearn.model_selection import train_test_split
from sklearn.linear_model import LinearRegression
from sklearn.metrics import mean_squared_error
from sklearn.preprocessing import StandardScaler

import matplotlib.pyplot as plt

from sklearn.linear_model import Ridge
from sklearn.linear_model import Lasso
from sklearn.linear_model import ElasticNet

#Regularización con Cross-Validate para encontrar el mejor valor de alpha
from sklearn.linear_model import RidgeCV
from sklearn.linear_model import LassoCV
from sklearn.linear_model import ElasticNetCV

# Configuración warnings
# ==============================================================================
import warnings
warnings.filterwarnings('ignore')

Cargamos los datos del dataset:

df = pd.read_csv('./../../../Datasets/meatspec.csv', index_col=0)
#Renombramos la columna objetivo (fat) a target
df.rename(columns = {'fat': 'target'}, inplace = True)

Separamos entrenamiento y test:

X = df.drop(columns = ['target'], axis = 1)
y = df['target']

standard_scaler = StandardScaler()
X_scaled = standard_scaler.fit_transform(X)
X_scaled = pd.DataFrame(X_scaled, columns = X.columns)

X_train, X_test, y_train, y_test = train_test_split(X_scaled, y, random_state=9)

Para que funcione bien la regularización los datos tienen que estar normalizados. Aunque deberíamos normalizar sólo los datos de train y crear una pipeline para normalizar los de test, lo hacemos así para simplificar.

Creamos el modelo:

model_lr = LinearRegression()
_ = model_lr.fit(X_train, y_train)

Miramos el error (en este caso RMSE):

#Error de entrenamiento
y_predict = model_lr.predict(X_train)
train_error = mean_squared_error(y_train, y_predict, squared = False)
print("RMSE entrenamiento: %.2f" % train_error)

#Error de test
y_predict = model_lr.predict(X_test)
ols_error = mean_squared_error(y_test, y_predict, squared = False)
print("RMSE test: %.2f" % ols_error)

RMSE entrenamiento: 0.77
RMSE test: 3.69

Como vemos, el error de entrenamiento se aleja bastante del error de test, síntoma de sobreajuste del modelo.

Vamos a mostrar un gráfico para ver los coeficientes del modelo. Para eso, creamos una función, ya que la utilizaremos más adelante:

def plot_coefficients(model, X):
    df_coefficients = pd.DataFrame(
        {
            'predictor': X.columns,
            'coef': model.coef_
        }
    )
    figure = plt.figure(figsize=(15, 5))
    axes = figure.add_subplot()
    axes.stem(df_coefficients['predictor'], df_coefficients['coef'], markerfmt=' ')
    axes.tick_params(axis = 'x', labelrotation = 90, labelsize = 7)
    axes.set_xlabel('variable')
    axes.set_ylabel('coeficientes')
    axes.set_title('Coeficientes del modelo')

plot_coefficients(model_lr, X_train)

El rango de nuestros coeficientes es [-30000, 30000].

Ridge (L2)

La regularización Ridge penaliza la suma de los coeficientes elevados al cuadrado:

$$ Error=\frac 1 n \displaystyle\sum_{i=1}^{n} {(y_i-y'_i)^2 } + \alpha \frac 1 {m} \displaystyle\sum_{j=1}^{m} {|w_j|^2} $$

A esta penalización se le conoce como l2 y tiene el efecto de reducir de forma proporcional el valor de todos los coeficientes del modelo pero sin que estos lleguen a cero. El grado de penalización está controlado por el hiperparámetro α. Cuando α = 0 , la penalización es nula y el resultado es equivalente al de un modelo lineal por mínimos cuadrados ordinarios (OLS). A medida que α aumenta, mayor es la penalización y menor el valor de los predictores.

Para aplicar Ridge, vamos a utilizar la función de sklearn Ridge.

Primero, vamos a comprobar que con α = 0 el modelo se queda igual:

model_ridge = Ridge(alpha=0)
_ = model_ridge.fit(X_train, y_train)

plot_coefficients(model_ridge, X_train)

Como vemos en el gráfico, los coeficientes no han variado.

Vamos a probar con α = 0.5:

model_ridge = Ridge(alpha=0.5)
_ = model_ridge.fit(X_train, y_train)

plot_coefficients(model_ridge, X_train)

Ahora sí que ha bajado mucho el rango de los coeficientes, [-6, 10] más o menos. Vemos el error:

#Error de entrenamiento
y_predict = model_ridge.predict(X_train)
train_error = mean_squared_error(y_train, y_predict, squared = False)
print("RMSE entrenamiento: %.2f" % train_error)

#Error de test
y_predict = model_ridge.predict(X_test)
test_error = mean_squared_error(y_test, y_predict, squared = False)
print("RMSE test: %.2f" % test_error)

RMSE entrenamiento: 3.27
RMSE test: 2.68

Nuestro error de entrenamiento ha empeorado, pero el de test ha mejorado. Además, ya no hay tanta diferencia entre ambos, con lo que hemos reducido el sobreajuste.

¿Y con α = 1?

model_ridge = Ridge(alpha=1)
_ = model_ridge.fit(X_train, y_train)

plot_coefficients(model_ridge, X_train)

#Error de entrenamiento
y_predict = model_ridge.predict(X_train)
train_error = mean_squared_error(y_train, y_predict, squared = False)
print("RMSE entrenamiento: %.2f" % train_error)

#Error de test
y_predict = model_ridge.predict(X_test)
test_error = mean_squared_error(y_test, y_predict, squared = False)
print("RMSE test: %.2f" % test_error)

RMSE entrenamiento: 3.52
RMSE test: 2.88

El valor de los coeficientes baja todavía más, pero el error de test también ha aumentado.

¿Qué valor de α es el adecuado? Al ser un hiperparámetro que tenemos que definir a la hora crear el modelo, necesitamos saber cual es el valor óptimo de α. Para eso, podemos utilizar la función RidgeCV, que buscará mediante validación cruzada el valor óptimo de α que minimice la función de coste (en nuestro caso RMSE).

Esta función almacena en el atributo *alpha_* el mejor valor de α encontrado. Además, si ponemos el parámetro *store_cv_values = True* almacenará los errores para cada valor de α probado.

#np.logspace devuelve números espaciados uniformemente en una escala logarítmica.
model_ridgeCV = RidgeCV(
            alphas = np.logspace(-10, 2, 200),
            store_cv_values = True
         )

_ = model_ridgeCV.fit(X_train, y_train)

Creamos una función para ver los coeficientes del modelo en función de la regularización:

def plot_coefficients_reg(alphas, model, X, y):
    coefs = []
    for alpha in alphas:
        if model == 'ridge':
            model_temp = Ridge(alpha=alpha)
        elif model == 'lasso':
            model_temp = Lasso(alpha=alpha)
        elif model == 'elastic':
            model_temp = ElasticNet(alpha=alpha)
        model_temp.fit(X, y)
        coefs.append(model_temp.coef_)
    figure = plt.figure(figsize=(15, 5))
    axes = figure.add_subplot()
    axes.plot(alphas, coefs)
    axes.set_xscale('log')
    axes.set_xlabel('alpha')
    axes.set_ylabel('coeficientes')
    axes.set_title('Coeficientes del modelo en función de la regularización')

plot_coefficients_reg(model_ridgeCV.alphas, 'ridge', X_train, y_train)

Como vemos, cuanto más alto es el valor de α, más se acercan los coeficientes a 0.

Por último, creamos otra función para ver la evolución del error en función del valor de α:

def plot_error(cv_values, alphas):
    # np.sqrt para trabajar con RMSE
    # axis = 0 para calcular la media de cada columna
    mse_mean = np.sqrt(cv_values.mean(axis = 0))

    #Sacamos el óptimo
    best_mean = np.min(mse_mean)
    #index_best devuelve un array de arrays
    index_best = np.where(mse_mean == best_mean)[0][0]
    best_alpha = alphas[index_best]
    
    figure = plt.figure(figsize=(15, 5))
    axes = figure.add_subplot()
    axes.plot(alphas, mse_mean)
    axes.set_xscale('log')
    axes.set_ylim([0,None])
    axes.set_title('Evolución del error CV en función de la regularización')
    axes.set_xlabel('alpha')
    axes.set_ylabel('RMSE')
    label = 'best alpha: ' + str(best_alpha)
    axes.axvline(x = best_alpha, c = "blue", linestyle = '--', label = label)
    axes.legend(fontsize=15,facecolor='#CDCDCD',labelcolor="#000000")

# modelo.cv_values almacena el mse de cv para cada valor de alpha. Tiene
# dimensiones (n_samples, n_alphas)
plot_error(model_ridgeCV.cv_values_, model_ridgeCV.alphas)

Mostramos el mejor valor de α encontrado por la función y el valor de los coeficientes:

print(f"El mejor valor de alpha encontrado es {model_ridgeCV.alpha_}")

El mejor valor de alpha encontrado es 2.3272024789604072e-05

plot_coefficients(model_ridgeCV, X_train)

Error del modelo:

#Error de entrenamiento
y_predict = model_ridgeCV.predict(X_train)
train_error = mean_squared_error(y_train, y_predict, squared = False)
print("RMSE entrenamiento: %.2f" % train_error)

#Error de test
y_predict = model_ridgeCV.predict(X_test)
ridge_error = mean_squared_error(y_test, y_predict, squared = False)
print("RMSE test: %.2f" % ridge_error)

RMSE entrenamiento: 1.91
RMSE test: 1.80

Nuestros errores se han acercado y además son más bajos que en la regresión lineal. Además, los coeficientes finales tienen un rango mucho más bajo ([-850, 650] más o menos).

Aunque el valor óptimo de α es aquel con el que se minimiza el error de validación cruzada, una práctica extendida es utilizar, en lugar de este, el mayor valor de α que se aleja menos de una desviación típica del óptimo. De este modo, se consigue un modelo más sencillo, pero cuya capacidad predictiva es similar a la conseguida con el modelo más complejo.

Lasso (L1)

Al contrario que Ridge, Lasso minimiza los coeficientes pudiendo llegar a 0, con lo que puede que alguno de ellos quede eliminado del modelo.

$$ Error=\frac 1 n \displaystyle\sum_{i=1}^{n} {(y_i-y'_i)^2 } + \alpha \frac 1 m \displaystyle\sum_{j=1}^{m} {|w_j|^1} $$

Igual que hemos hecho con Ridge, vamos a utilizar LassoCV para encontrar el mejor valor de α:

model_lassoCV = LassoCV(
            alphas = np.logspace(-10, 3, 200),
            cv = 10
         )

_ = model_lassoCV.fit(X_train, y_train)

Vemos que si aumentamos el valor de α, los coeficientes se acercan a 0 (al contrario que con Ridge, aquí sí que pueden llegar a 0, con lo que es una forma de reducir la dimensionalidad de nuestro modelo).

plot_coefficients_reg(model_lassoCV.alphas_, 'lasso', X_train, y_train)

Mostramos la variación del error en función de α y el mejor valor encontrado:

plot_error(model_lassoCV.mse_path_.T, model_lassoCV.alphas_)

print(f"Mejor valor de alpha encontrado: {model_lassoCV.alpha_}")

Mejor valor de alpha encontrado: 1e-10

Miramos el error de entrenamiento y test:

#Error de entrenamiento
y_predict = model_lassoCV.predict(X_train)
train_error = mean_squared_error(y_train, y_predict, squared = False)
print("RMSE entrenamiento: %.2f" % train_error)

#Error de test
y_predict = model_lassoCV.predict(X_test)
lasso_error = mean_squared_error(y_test, y_predict, squared = False)
print("RMSE test: %.2f" % lasso_error)

RMSE entrenamiento: 4.87
RMSE test: 3.86

Aunque hemos reducido el sobreajuste, no mejoramos casi nada el modelo sin regularizar.

Vamos a ver como quedan nuestros coeficientes:

# Coeficientes del modelo
# ==============================================================================
df_coeficientes = pd.DataFrame(
                        {'predictor': X_train.columns,
                         'coef': model_lassoCV.coef_.flatten()}
                  )

# Predictores incluidos en el modelo (coeficiente != 0)
df_coeficientes[df_coeficientes.coef != 0]


     predictor	coef
0	V1	59.484875
1	V2	-16.613824
2	V3	-15.894723
3	V4	-15.250222
4	V5	-14.543708
...	...	...
95	V96	1.195200
96	V97	-0.134883
97	V98	-1.917606
98	V99	-3.943738
99	V100	-5.612002
100 rows × 2 columns

No ha eliminado ningún coeficiente (aunque el valor es más bajo).

Vamos a ver el número de coeficientes incluidos en el modelo con Lasso en función del valor de α:

# Número de predictores incluidos (coeficiente !=0) en función de alpha
# ==============================================================================
alphas = model_lassoCV.alphas_
n_predictores = []

for alpha in alphas:
    modelo_temp = Lasso(alpha=alpha)
    modelo_temp.fit(X_train, y_train)
    coef_no_cero = np.sum(modelo_temp.coef_ != 0)
    n_predictores.append(coef_no_cero)

fig, ax = plt.subplots(figsize=(7, 3.84))
ax.plot(alphas, n_predictores)
ax.set_xscale('log')
ax.set_ylim([-15,None])
ax.set_xlabel('alpha')
ax.set_ylabel('nº predictores')
ax.set_title('Predictores incluidos en función de la regularización')

Como se puede apreciar en la anterior imagen, Lasso empieza a eliminar coeficientes a partir de $\alpha = 10^{-3}$ más o menos.

Antes hemos dicho que es habitual elegir el mayor valor de α que se aleja menos de una desviación típica del óptimo. Vamos a comprobar si mejoramos nuestro modelo:

# Evolución del error en función de alpha
# ==============================================================================
# modelo.mse_path_ almacena el mse de cv para cada valor de alpha. Tiene
# dimensiones (n_alphas, n_folds)
mse_cv = model_lassoCV.mse_path_.mean(axis=1)
mse_sd = model_lassoCV.mse_path_.std(axis=1)

# Se aplica la raíz cuadrada para pasar de mse a rmse
rmse_cv = np.sqrt(mse_cv)
rmse_sd = np.sqrt(mse_sd)

# Se identifica el óptimo y el óptimo + 1std
min_rmse     = np.min(rmse_cv)
sd_min_rmse  = rmse_sd[np.argmin(rmse_cv)]
min_rsme_1sd = np.max(rmse_cv[rmse_cv <= min_rmse + sd_min_rmse])
optimo       = model_lassoCV.alphas_[np.argmin(rmse_cv)]

# Mejor valor alpha encontrado + 1sd
# ==============================================================================
min_rmse     = np.min(rmse_cv)
sd_min_rmse  = rmse_sd[np.argmin(rmse_cv)]
min_rsme_1sd = np.max(rmse_cv[rmse_cv <= min_rmse + sd_min_rmse])
optimo       = model_lassoCV.alphas_[np.argmin(rmse_cv)]
optimo_1sd   = model_lassoCV.alphas_[rmse_cv == min_rsme_1sd]

print(f"Mejor valor de alpha encontrado + 1 desviación estándar: {optimo_1sd}")

Mejor valor de alpha encontrado + 1 desviación estándar: [0.18896523]

Entrenamos nuestro modelo con ese valor de $\alpha$:

# Mejor modelo alpha óptimo + 1sd
# ==============================================================================
model_lasso_1std = Lasso(alpha=optimo_1sd)
model_lasso_1std.fit(X_train, y_train)

# Coeficientes del modelo
# ==============================================================================
df_coeficientes = pd.DataFrame(
                        {'predictor': X_train.columns,
                         'coef': model_lasso_1std.coef_.flatten()}
                  )

# Predictores incluidos en el modelo (coeficiente != 0)
df_coeficientes[df_coeficientes.coef != 0]


     predictor	coef
4	V5	-1.002796
5	V6	-8.277915
6	V7	-7.335488
7	V8	-6.294674
8	V9	-5.383142
9	V10	-4.311487
10	V11	-3.376037
11	V12	-2.394135
12	V13	-1.300096
13	V14	-0.081426
37	V38	18.159401
38	V39	20.662721
39	V40	13.594109
40	V41	2.898530
51	V52	-2.733636
52	V53	-5.269952
53	V54	-2.028877

Ahora sí que ha eliminado características, quedándose sólo con 17 (ha eliminado 83).

plot_coefficients(model_lasso_1std, X_train)

Error del modelo:

#Error de entrenamiento
y_predict = model_lasso_1std.predict(X_train)
train_error= mean_squared_error(y_train, y_predict, squared = False)
print("RMSE entrenamiento: %.2f" % train_error)

#Error de test
y_predict = model_lasso_1std.predict(X_test)
lasso_1std_error = mean_squared_error(y_test, y_predict, squared = False)
print("RMSE test: %.2f" % lasso_error)

RMSE entrenamiento: 4.77
RMSE test: 3.86

Aunque nuestro error ha empeorado, hay que tener en cuenta que el modelo se ha simplificado bastante, al eliminar 83 características.

ElasticNet

Es la unión de Lasso y Ridge. El hiperparámetro l1_ratio indica que importancia le da a cada regularización:

Usamos (como antes) ElasticNetCV, pero esta vez tendremos que encontrar dos parámetros óptimos: $\alpha$ y l1_ratio:

# Creación y entrenamiento del modelo (con búsqueda por CV del valor óptimo alpha)
# ==============================================================================
# Por defecto ElasticNetCV utiliza el mean squared error
model_elasticCV = ElasticNetCV(
            l1_ratio        = [0, 0.1, 0.5, 0.7, 0.9, 0.95, 0.99],
            alphas          = np.logspace(-10, 3, 200),
            cv              = 10
         )
_ = model_elasticCV.fit(X = X_train, y = y_train)

print(f"Mejor alfa encontrado: {model_elasticCV.alpha_}")
print(f"Mejor l1 ratio encontrado: {model_elasticCV.l1_ratio_}")

Mejor alfa encontrado: 1e-10
Mejor l1 ratio encontrado: 0.7

Coeficientes del modelo:

plot_coefficients(model_elasticCV, X_train)

# Coeficientes del modelo
# ==============================================================================
df_coeficientes = pd.DataFrame(
                        {'predictor': X_train.columns,
                         'coef': model_elasticCV.coef_.flatten()}
                  )

# Predictores incluidos en el modelo (coeficiente != 0)
df_coeficientes[df_coeficientes.coef != 0]


     predictor	coef
0	V1	59.484876
1	V2	-16.613824
2	V3	-15.894723
3	V4	-15.250222
4	V5	-14.543708
...	...	...
95	V96	1.195200
96	V97	-0.134883
97	V98	-1.917606
98	V99	-3.943738
99	V100	-5.612002
100 rows × 2 columns

En este caso no ha eliminado ningún coeficiente. Podríamos hacer como antes y buscar no los valores óptimos, si no aquellos que simplifiquen el modelo sin que el error se vea muy afectado.

Error del modelo:

#Error de entrenamiento
y_predict = model_elasticCV.predict(X_train)
train_error = mean_squared_error(y_train, y_predict, squared = False)
print("RMSE entrenamiento: %.2f" % train_error)

#Error de test
y_predict = model_elasticCV.predict(X_test)
elastic_error = mean_squared_error(y_test, y_predict, squared = False)
print("RMSE test: %.2f" % elastic_error)

RMSE entrenamiento: 4.87
RMSE test: 3.86

El error es parecido a Lasso.

Comparaciones entre modelos

df_comparacion = pd.DataFrame({
                    'modelo': ['OLS', 'Ridge', 'Lasso', 'Lasso_1std', 'Elastic-net'],
                    'test rmse': [ols_error, ridge_error, lasso_error, lasso_1std_error, elastic_error]
                 })

fig, ax = plt.subplots(figsize=(7, 3.84))
df_comparacion.set_index('modelo').plot(kind='barh', ax=ax)
ax.set_xlabel('rmse')
ax.set_ylabel('modelo')
ax.set_title('Comparación de modelos')

Para este caso, el modelo con el menor error de test es Ridge. Lasso_1std empeora un poco el error en comparación con el modelo simple, pero utilizando sólo 17 predictores.

Referencias

Regularización Ridge, Lasso y Elastic Net con Python by Joaquín Amat Rodrigo, available under a Attribution 4.0 International (CC BY 4.0)