Capítulo 7 K-Nearest-Neighbor

KNN es un algoritmo de aprendizaje supervisado que podemos usar tanto para regresión como clasificación. Es un algoritmo fácil de interpretar y que permite ser flexible en el balance entre sesgo y varianza (dependiendo de los hiper-parámetros seleccionados).

El algoritmo de K vecinos más cercanos realiza comparaciones entre un nuevo elemento y las observaciones anteriores que ya cuentan con etiqueta. La esencia de este algoritmo está en etiquetar a un nuevo elemento de manera similar a como están etiquetados aquellos K elementos que más se le parecen. Veremos este proceso para cada uno de los posibles casos:

7.1 Clasificación

La idea detrás del algoritmo es sencilla, etiqueta una nueva observación en la categoría que tenga mas elementos de las k observaciones más cercanas, es decir:

  1. Seleccionamos el hiper-parámetro K como el número elegido de vecinos.

  2. Se calculará la similitud (distancia) de esta nueva observación a cada observación existente.

  3. Ordenaremos estas distancias de menor a mayor.

  4. Tomamos las K primeras entradas de la lista ordenada.

  5. La nueva observación será asignada al grupo que tenga mayor número de observaciones en estas k primeras distancias (asignación por moda)

A continuación se ejemplifica este proceso:

Ejemplo:

Otro método que permite tener mayor control sobre las clasificaciones es asignar la probabilidad de pertenencia a cada clase de acuerdo con la proporción existente de cada una de las mismas. A partir de dichas probabilidades, el usuario puede determinar el punto de corte que sea más conveniente para el problema a resolver.

7.2 Regresión

En el caso de regresión, la etiqueta de una nueva observación se realiza a través del promedio del valor en las k observaciones más cercanas, es decir:

  1. Seleccionamos el hiper-parámetro K como el número elegido de vecinos.

  2. Se calculará la similitud (distancia) de esta nueva observación a cada observación existente

  3. Ordenaremos estas distancias de menor a mayor

  4. Tomamos las K primeras entradas de la lista ordenada.

  5. La nueva observación será etiquetada mediante el promedio del valor de las observaciones en estas k primeras distancias.

Considerando un modelo de 3 vecinos más cercanos, las siguientes imágenes muestran el proceso de ajuste y predicción de nuevas observaciones.

Ejemplo de balance de sesgo y varianza

7.3 Ajuste del modelo

En contraste con otros algoritmos de aprendizaje supervisado, K-NN no genera un modelo del aprendizaje con datos de entrenamiento, sino que el aprendizaje sucede en el mismo momento en el que se prueban los datos de prueba. A este tipo de algoritmos se les llama lazy learning methods porque no aprende del conjunto de entrenamiento inmediatamente, sino que almacena el conjunto de datos y, en el momento de la clasificación, realiza una acción en el conjunto de datos.

El algoritmo KNN en la fase de entrenamiento simplemente almacena el conjunto de datos y cuando obtiene nuevos datos, clasifica esos datos en una categoría que es muy similar a los nuevos datos.

7.3.1 Selección de Hiper-parámetro K

Al configurar un modelo KNN, sólo hay algunos parámetros que deben elegirse/ajustarse para mejorar el rendimiento, uno de estos parámetros es el valor de la K.

No existe una forma particular de determinar el mejor valor para “K”, por lo que debemos probar algunos valores para encontrar “el mejor” de ellos.

Para los modelos de clasificación, especialmente si solo hay dos clases, generalmente se elige un número impar para k. Esto es para que el algoritmo nunca llegue a un “empate”

Una opción para seleccionar la K adecuada es ejecutar el algoritmo KNN varias veces con diferentes valores de K y elegimos la K que reduce la cantidad de errores mientras se mantiene la capacidad del algoritmo para hacer predicciones con precisión.

Observemos lo siguiente:

Estas gráficas se conoce como “gráfica de codo” y generalmente se usan para determinar el valor K.

  • A medida que disminuimos el valor de K a 1, nuestras predicciones se vuelven menos estables. Imaginemos que tomamos K = 1 y tenemos un punto de consulta rodeado por varios rojos y uno verde, pero el verde es el vecino más cercano. Razonablemente, pensaríamos que el punto de consulta es probablemente rojo, pero como K = 1, KNN predice incorrectamente que el punto de consulta es verde.

  • Inversamente, a medida que aumentamos el valor de K, nuestras predicciones se vuelven más estables debido a que tenemos más observaciones con quienes comparar, por lo tanto, es más probable que hagan predicciones más precisas. Eventualmente, comenzamos a presenciar un número creciente de errores, es en este punto que sabemos que hemos llevado el valor de K demasiado lejos.

7.3.2 Métodos de cálculo de la distancia entre observaciones

Otro parámetro que podemos ajustar para el modelo es la distancia usada, existen diferentes formas de medir qué tan “cerca” están dos puntos entre sí, y las diferencias entre estos métodos pueden volverse significativas en dimensiones superiores.

  • La más utilizada es la distancia euclidiana, el tipo estándar de distancia.

\[d(X,Y) = \sqrt{\sum_{i=1}^{n} (x_i-y_i)^2}\]

  • Otra métrica es la llamada distancia de Manhattan, que mide la distancia tomada en cada dirección cardinal, en lugar de a lo largo de la diagonal.

\[d(X,Y) = \sum_{i=1}^{n} |x_i - y_i|\]

  • De manera más general, las anteriores son casos particulares de la distancia de Minkowski, cuya fórmula es:

\[d(X,Y) = (\sum_{i=1}^{n} |x_i-y_i|^p)^{\frac{1}{p}}\]

  • La distancia de coseno es ampliamente en análisis de texto, sistemas de recomendación

\[d(X,Y)= 1 - \frac{\sum_{i=1}^{n}{X_iY_i}}{\sqrt{\sum_{i=1}^{n}{X_i^2}}\sqrt{\sum_{i=1}^{n}{Y_i^2}}}\]

7.4 Implementación en Python

Usaremos los pipelines antes implementados para ajustar tanto el modelo de regresión como el de clasificación. Una ventaja que se explorará al usar KFCV es el conjunto de hiperparámetros para elegir el mejor modelo posible.

Los pasos a seguir, son los siguientes:

  1. Carga de librerías
  2. Carga y separación inicial de datos ( test, train ).
  3. Pre-procesamiento e ingeniería de variables.
  4. Selección de tipo de modelo con hiperparámetros iniciales.
  5. Cálculo de métricas de desempeño.
  6. Creación de grid search y métricas de desempeño.
  7. Entrenamiento de modelos con hiperparámetros definidos.
  8. Análisis de métricas de error e hiperparámetros (Vuelve al paso 3, si es necesario).
  9. Selección de modelo a usar.
  10. Ajuste de modelo final con todos los datos (Vuelve al paso 2, si es necesario).
  11. Validar poder predictivo con datos de prueba.

7.4.1 Regresión

Paso 0: Carga de librerías

from mlxtend.feature_selection import ColumnSelector
from sklearn.compose import ColumnTransformer
from sklearn.preprocessing import StandardScaler, OneHotEncoder
from sklearn.neighbors import KNeighborsRegressor
from sklearn.pipeline import Pipeline
from sklearn.metrics import mean_absolute_error, mean_absolute_percentage_error
from sklearn.metrics import mean_squared_error, r2_score, make_scorer
from sklearn.model_selection import train_test_split, KFold, cross_val_score, cross_validate
from sklearn.model_selection import GridSearchCV
from sklearn.utils import shuffle

from plydata.one_table_verbs import pull
from mizani.formatters import comma_format, dollar_format
from plotnine import *
from siuba import *

import pandas as pd
import numpy as np

Paso 1: Carga y separación inicial de datos ( test, train )

Comenzamos por cargar los datos completos e identificar la variable de respuesta para separarla de las explicativas

#### CARGA DE DATOS ####
ames = pd.read_csv("data/ames.csv")

ames_y = ames >> pull("Sale_Price")    # ames[["Sale_Price"]]
ames_x = select(ames, -_.Sale_Price)   # ames.drop('Sale_Price', axis=1)

#### DIVISIÓN DE DATOS ####
ames_x_train, ames_x_test, ames_y_train, ames_y_test = train_test_split(
 ames_x, ames_y, 
 test_size = 0.20, 
 random_state = 195
 )

Contando con datos de entrenamiento, procedemos a realizar el feature engineering para extraer las mejores características que permitirán realizar las estimaciones en el modelo.

Paso 2: Pre-procesamiento e ingeniería de variables

En este paso se pone a prueba la imaginación y conocimiento de transformaciones estadísticas que permitan ofrecer un mejor ajuste a la relación entre datos dependientes y de respuesta.

EL primer pipeline es para facilitar las transformaciones sobre las columnas sugeridas inicialmente:

## SELECCIÓN DE VARIABLES

# Seleccionamos las variales numéricas de interés
num_cols = ["Full_Bath", "Half_Bath"]

# Seleccionamos las variables categóricas de interés
cat_cols = ["Overall_Cond"]

# Juntamos todas las variables de interés
columnas_seleccionadas = num_cols + cat_cols

pipe = ColumnSelector(columnas_seleccionadas)
ames_x_train_selected = pipe.fit_transform(ames_x_train)

ames_train_selected = pd.DataFrame(
  ames_x_train_selected, 
  columns = columnas_seleccionadas
  )

ames_train_selected.info()
## <class 'pandas.core.frame.DataFrame'>
## RangeIndex: 2344 entries, 0 to 2343
## Data columns (total 3 columns):
##  #   Column        Non-Null Count  Dtype 
## ---  ------        --------------  ----- 
##  0   Full_Bath     2344 non-null   object
##  1   Half_Bath     2344 non-null   object
##  2   Overall_Cond  2344 non-null   object
## dtypes: object(3)
## memory usage: 55.1+ KB

Y una vez que se han sugerido algunas columnas, se procede a realizar el pipeline de transformación:

## TRANSFORMACIÓN DE COLUMNAS

# ColumnTransformer para aplicar transformaciones
preprocessor = ColumnTransformer(
    transformers = [
        ('scaler', StandardScaler(), num_cols),
        ('onehotencoding', OneHotEncoder(drop='first', sparse_output=False), cat_cols)
    ],
    verbose_feature_names_out = False,
    remainder = 'passthrough'  # Mantener las columnas restantes sin cambios
)

transformed_data = preprocessor.fit_transform(ames_train_selected)
new_column_names = preprocessor.get_feature_names_out()

transformed_df = pd.DataFrame(
  transformed_data,
  columns=new_column_names
  )

transformed_df
##       Full_Bath  Half_Bath  ...  Overall_Cond_Very_Good  \
## 0          0.78       1.24  ...                    0.00   
## 1          0.78      -0.75  ...                    0.00   
## 2          0.78      -0.75  ...                    0.00   
## 3         -1.01      -0.75  ...                    0.00   
## 4         -1.01       1.24  ...                    0.00   
## ...         ...        ...  ...                     ...   
## 2339      -1.01       1.24  ...                    0.00   
## 2340       0.78      -0.75  ...                    0.00   
## 2341      -1.01      -0.75  ...                    0.00   
## 2342       0.78       1.24  ...                    0.00   
## 2343       0.78      -0.75  ...                    0.00   
## 
##       Overall_Cond_Very_Poor  
## 0                       0.00  
## 1                       0.00  
## 2                       0.00  
## 3                       0.00  
## 4                       0.00  
## ...                      ...  
## 2339                    0.00  
## 2340                    0.00  
## 2341                    0.00  
## 2342                    0.00  
## 2343                    0.00  
## 
## [2344 rows x 10 columns]
transformed_df.info()
## <class 'pandas.core.frame.DataFrame'>
## RangeIndex: 2344 entries, 0 to 2343
## Data columns (total 10 columns):
##  #   Column                      Non-Null Count  Dtype  
## ---  ------                      --------------  -----  
##  0   Full_Bath                   2344 non-null   float64
##  1   Half_Bath                   2344 non-null   float64
##  2   Overall_Cond_Average        2344 non-null   float64
##  3   Overall_Cond_Below_Average  2344 non-null   float64
##  4   Overall_Cond_Excellent      2344 non-null   float64
##  5   Overall_Cond_Fair           2344 non-null   float64
##  6   Overall_Cond_Good           2344 non-null   float64
##  7   Overall_Cond_Poor           2344 non-null   float64
##  8   Overall_Cond_Very_Good      2344 non-null   float64
##  9   Overall_Cond_Very_Poor      2344 non-null   float64
## dtypes: float64(10)
## memory usage: 183.2 KB

Recordemos que la función ColumnTransformes() solo son los pasos a seguir, necesitamos usar el método fit() que nos devuelve una receta actualizada con las estimaciones y la función transform() que nos devuelve la matriz transformada. Estos pasos pueden resumirse con el método: fit_transform()

Una vez que la receta de transformación de datos está lista, procedemos a implementar el pipeline del modelo de interés.

Paso 3: Selección de tipo de modelo con hiperparámetros iniciales

Ahora se procede a unir en un mismo flujo el proceso de ingeniería de datos y un modelo inicial sugerido. En este primer ejemplo, se muestra un modelo de 5 vecinos más cercanos.

# Crear el pipeline con la regresión por KNN
pipeline = Pipeline([
   ('preprocessor', preprocessor),
   ('regressor', KNeighborsRegressor(n_neighbors=5))
])

# Entrenar el pipeline
results = pipeline.fit(ames_train_selected, ames_y_train)

## PREDICCIONES
y_pred = pipeline.predict(ames_x_test)

ames_test = (
  ames_x_test >>
  mutate(Sale_Price_Pred = y_pred, Sale_Price = ames_y_test)
)

(
ames_test >>
  select(_.Sale_Price, _.Sale_Price_Pred)
)
##       Sale_Price  Sale_Price_Pred
## 390       165000        196553.00
## 1235      124000        179150.00
## 2288       75000        179150.00
## 107       206000        130380.00
## 1861      190000        159600.00
## ...          ...              ...
## 116       171000        181390.00
## 398       120500        130380.00
## 1253      146000        196553.00
## 78        125000        158026.00
## 714       110000        144280.00
## 
## [586 rows x 2 columns]

Estas son las predicciones logradas con el modelo inicial.

7.4.1.1 Métricas de desempeño

Se procede en el siguiente paso a cuantificar los errores producidos por la predicción.

Paso 4: Cálculo de métricas de desempeño

pd.options.display.float_format = '{:.2f}'.format

y_obs = ames_test["Sale_Price"]
y_pred = ames_test["Sale_Price_Pred"]

me = np.mean(y_obs - y_pred)
mae = mean_absolute_error(y_obs, y_pred)
mape = mean_absolute_percentage_error(y_obs, y_pred)
mse = mean_squared_error(y_obs, y_pred)
rmse = np.sqrt(mse)
r2 = r2_score(y_obs, y_pred)

n = len(y_obs)  # Número de observaciones
p = 9  # Número de predictores 
r2_adj = 1 - (n - 1) / (n - p - 1) * (1 - r2)

metrics_data = {
    "Metric": ["ME", "MAE", "MAPE", "MSE", "RMSE", "R^2", "R^2 Adj"],
    "Value": [me, mae, mape, mse, rmse, r2, r2_adj]
}

metrics_df = pd.DataFrame(metrics_data)
metrics_df
##     Metric         Value
## 0       ME       2948.31
## 1      MAE      47359.00
## 2     MAPE          0.27
## 3      MSE 4884359339.83
## 4     RMSE      69888.19
## 5      R^2          0.26
## 6  R^2 Adj          0.25

Una manera amigable de dimensionar los errores y el buen desempeño es mediante gráficos

#### Gráficos de desempeño de modelo

(
  ames_test >>
    ggplot(aes(x = "Sale_Price", y = "Sale_Price_Pred")) +
    geom_point() +
    scale_y_continuous(labels = dollar_format(digits=0, big_mark=','), limits = [0, 600000] ) +
    scale_x_continuous(labels = dollar_format(digits=0, big_mark=','), limits = [0, 500000] ) +
    geom_abline(color = "red") +
    coord_equal() +
    labs(
      title = "Comparación entre predicción y observación",
      y = "Predicción",
      x = "Observación")
)
## <Figure Size: (640 x 480)>

(
ames_test >>
  select(_.Sale_Price, _.Sale_Price_Pred) >>
  mutate(error = _.Sale_Price - _.Sale_Price_Pred) >>
  ggplot(aes(x = "error")) +
  geom_histogram(color = "white", fill = "black") +
  geom_vline(xintercept = 0, color = "red") +
  scale_x_continuous(labels=dollar_format(big_mark=',', digits=0)) + 
  ylab("Conteos de clase") + xlab("Errores") +
  ggtitle("Distribución de error")
)
## <Figure Size: (640 x 480)>

(
ames_test >>
  select(_.Sale_Price, _.Sale_Price_Pred) >>
  mutate(error = _.Sale_Price - _.Sale_Price_Pred) >>
  ggplot(aes(sample = "error")) +
  geom_qq(alpha = 0.3) + stat_qq_line(color = "red") +
  scale_y_continuous(labels=dollar_format(big_mark=',', digits = 0)) + 
  xlab("Distribución normal") + ylab("Distribución de errores") +
  ggtitle("QQ-Plot")
)
## <Figure Size: (640 x 480)>

(
ames_test >>
  select(_.Sale_Price, _.Sale_Price_Pred) >>
  mutate(error = _.Sale_Price - _.Sale_Price_Pred) >>
  ggplot(aes(x = "Sale_Price")) +
  geom_linerange(aes(ymin = 0, ymax = "error"), colour = "purple") +
  geom_point(aes(y = "error"), size = 0.05, alpha = 0.5) +
  geom_abline(intercept = 0, slope = 0) +
  scale_x_continuous(labels=dollar_format(big_mark=',', digits=0)) + 
  scale_y_continuous(labels=dollar_format(big_mark=',', digits=0)) +
  xlab("Precio real") + ylab("Error de estimación") +
  ggtitle("Relación entre error y precio de venta")
)
## <Figure Size: (640 x 480)>

7.4.1.2 Validación cruzada

Para determinar cuáles son los hiper-parámetros que funcionan mejor, es necesario realizar experimentos mediante ensayo-error hasta determinar la mejor solución. En cada partición del método de muestreo KFCV se implementan las distintas configuraciones y se calculan predicciones. Con las predicciones hechas en cada fold, se obtienen intervalos de confianza para conocer la variación asociada al modelo a través de los hiper-parámetros implementados.

Usaremos las recetas antes implementadas para ajustar tanto el modelo de regresión como el de clasificación. Exploraremos un conjunto de hiperparámetros para elegir el mejor modelo, sin embargo, para realizar este proceso de forma ágil, se inicializará un flujo de trabajo que se encargue de realizar todos los experimentos deseados y elegir el modelo adecuado.

Paso 5: Creación de grid search y métricas de desempeño

# Definir el objeto K-Fold Cross Validator
k = 10
kf = KFold(n_splits=k, shuffle=True, random_state=42)

param_grid = {
 'n_neighbors': range(2, 21),
 'weights': ['uniform', 'distance'],
 'metric': ['euclidean', 'manhattan'],
 'p': [1, 2]
}

Algunas otras posibles distancias son:

  • euclidean
  • manhattan
  • chebyshev
  • minkowski
  • hamming
  • jaccard
  • cosine

Una vez definidos los posibles hiperparámetros, procedemos a definir las métricas que serán usadas para cuantificar la bondad del ajuste.

# Definir las métricas de desempeño que deseas calcular como funciones de puntuación

def adjusted_r2_score(y_true, y_pred, n, p):
  r2 = r2_score(y_true, y_pred)
  adjusted_r2 = 1 - (1 - r2) * (n - 1) / (n - p - 1)
  return adjusted_r2

scoring = {
    'neg_mean_squared_error': make_scorer(mean_squared_error, greater_is_better=False),
    'r2': make_scorer(adjusted_r2_score, greater_is_better=True, 
                      n=np.ceil(len(ames_train_selected)), p=len(ames_train_selected.columns)),
    'neg_mean_absolute_error': make_scorer(mean_absolute_error, greater_is_better=False),
    'mape': make_scorer(mean_absolute_percentage_error, greater_is_better=False)
}

Paso 6: Entrenamiento de modelos con hiperparámetros definidos

Teniendo todos los elementos anteriores listos, se procede con el ajuste de todas las posibles configuraciones del modelo a través de la validación cruzada. Esto permitirá contar con medidas de tendencia central para los resultados de cada uno de las configuraciones y evaluar si estadísticamente hay diferencia entre los mejores resultados.

pipeline = Pipeline([
    ('preprocessor', preprocessor),
    ('regressor', GridSearchCV(
      KNeighborsRegressor(), 
      param_grid, 
      cv=kf, 
      scoring=scoring, 
      refit='neg_mean_squared_error',
      verbose=3, 
      n_jobs=-1)
     )
])

pipeline.fit(ames_train_selected, ames_y_train)
## Fitting 10 folds for each of 152 candidates, totalling 1520 fits
## Pipeline(steps=[('preprocessor',
##                  ColumnTransformer(remainder='passthrough',
##                                    transformers=[('scaler', StandardScaler(),
##                                                   ['Full_Bath', 'Half_Bath']),
##                                                  ('onehotencoding',
##                                                   OneHotEncoder(drop='first',
##                                                                 sparse_output=False),
##                                                   ['Overall_Cond'])],
##                                    verbose_feature_names_out=False)),
##                 ('regressor',
##                  GridSearchCV(cv=KFold(n_splits=10, random_state=42, shuffle=True),
##                               estimator=KN...
##                                           'weights': ['uniform', 'distance']},
##                               refit='neg_mean_squared_error',
##                               scoring={'mape': make_scorer(mean_absolute_percentage_error, greater_is_better=False),
##                                        'neg_mean_absolute_error': make_scorer(mean_absolute_error, greater_is_better=False),
##                                        'neg_mean_squared_error': make_scorer(mean_squared_error, greater_is_better=False),
##                                        'r2': make_scorer(adjusted_r2_score, n=2344.0, p=3)},
##                               verbose=3))])

Paso 7: Análisis de métricas de error e hiperparámetros (Vuelve al paso 3, si es necesario)

Podemos obtener las métricas de los resultados de cada fold:

results_cv = pipeline.named_steps['regressor'].cv_results_

# Convierte los resultados en un DataFrame
pd.set_option('display.max_columns', 500)
results_df = pd.DataFrame(results_cv)
results_df.columns
## Index(['mean_fit_time', 'std_fit_time', 'mean_score_time', 'std_score_time',
##        'param_metric', 'param_n_neighbors', 'param_p', 'param_weights',
##        'params', 'split0_test_neg_mean_squared_error',
##        'split1_test_neg_mean_squared_error',
##        'split2_test_neg_mean_squared_error',
##        'split3_test_neg_mean_squared_error',
##        'split4_test_neg_mean_squared_error',
##        'split5_test_neg_mean_squared_error',
##        'split6_test_neg_mean_squared_error',
##        'split7_test_neg_mean_squared_error',
##        'split8_test_neg_mean_squared_error',
##        'split9_test_neg_mean_squared_error',
##        'mean_test_neg_mean_squared_error', 'std_test_neg_mean_squared_error',
##        'rank_test_neg_mean_squared_error', 'split0_test_r2', 'split1_test_r2',
##        'split2_test_r2', 'split3_test_r2', 'split4_test_r2', 'split5_test_r2',
##        'split6_test_r2', 'split7_test_r2', 'split8_test_r2', 'split9_test_r2',
##        'mean_test_r2', 'std_test_r2', 'rank_test_r2',
##        'split0_test_neg_mean_absolute_error',
##        'split1_test_neg_mean_absolute_error',
##        'split2_test_neg_mean_absolute_error',
##        'split3_test_neg_mean_absolute_error',
##        'split4_test_neg_mean_absolute_error',
##        'split5_test_neg_mean_absolute_error',
##        'split6_test_neg_mean_absolute_error',
##        'split7_test_neg_mean_absolute_error',
##        'split8_test_neg_mean_absolute_error',
##        'split9_test_neg_mean_absolute_error',
##        'mean_test_neg_mean_absolute_error', 'std_test_neg_mean_absolute_error',
##        'rank_test_neg_mean_absolute_error', 'split0_test_mape',
##        'split1_test_mape', 'split2_test_mape', 'split3_test_mape',
##        'split4_test_mape', 'split5_test_mape', 'split6_test_mape',
##        'split7_test_mape', 'split8_test_mape', 'split9_test_mape',
##        'mean_test_mape', 'std_test_mape', 'rank_test_mape'],
##       dtype='object')
# Puedes seleccionar las columnas de interés, por ejemplo:

summary_df = (
  results_df >>
  select(-_.contains("split"), -_.contains("time"), -_.params)
)
summary_df
##     param_metric param_n_neighbors param_p param_weights  \
## 0      euclidean                 2       1       uniform   
## 1      euclidean                 2       1      distance   
## 2      euclidean                 2       2       uniform   
## 3      euclidean                 2       2      distance   
## 4      euclidean                 3       1       uniform   
## ..           ...               ...     ...           ...   
## 147    manhattan                19       2      distance   
## 148    manhattan                20       1       uniform   
## 149    manhattan                20       1      distance   
## 150    manhattan                20       2       uniform   
## 151    manhattan                20       2      distance   
## 
##      mean_test_neg_mean_squared_error  std_test_neg_mean_squared_error  \
## 0                      -5497718542.17                     964733484.45   
## 1                      -5600934828.97                     985048211.63   
## 2                      -5497718542.17                     964733484.45   
## 3                      -5600934828.97                     985048211.63   
## 4                      -4610333681.11                     591223238.59   
## ..                                ...                              ...   
## 147                    -3956406860.61                     821709791.57   
## 148                    -3770846215.48                     481194295.38   
## 149                    -3972516407.18                     812688611.81   
## 150                    -3770846215.48                     481194295.38   
## 151                    -3972516407.18                     812688611.81   
## 
##      rank_test_neg_mean_squared_error  mean_test_r2  std_test_r2  \
## 0                                 145          0.12         0.17   
## 1                                 149          0.11         0.17   
## 2                                 145          0.12         0.17   
## 3                                 149          0.11         0.17   
## 4                                 139          0.27         0.07   
## ..                                ...           ...          ...   
## 147                                53          0.37         0.09   
## 148                                 3          0.40         0.05   
## 149                                57          0.37         0.09   
## 150                                 3          0.40         0.05   
## 151                                57          0.37         0.09   
## 
##      rank_test_r2  mean_test_neg_mean_absolute_error  \
## 0             145                          -51629.82   
## 1             149                          -51802.17   
## 2             145                          -51629.82   
## 3             149                          -51802.17   
## 4             139                          -47366.84   
## ..            ...                                ...   
## 147            53                          -42437.89   
## 148             3                          -42527.16   
## 149            55                          -42504.86   
## 150             3                          -42527.16   
## 151            55                          -42504.86   
## 
##      std_test_neg_mean_absolute_error  rank_test_neg_mean_absolute_error  \
## 0                             6374.90                                145   
## 1                             6257.34                                149   
## 2                             6374.90                                145   
## 3                             6257.34                                149   
## 4                             3797.50                                139   
## ..                                ...                                ...   
## 147                           2397.04                                 29   
## 148                           2077.80                                 37   
## 149                           2396.68                                 35   
## 150                           2077.80                                 37   
## 151                           2396.68                                 35   
## 
##      mean_test_mape  std_test_mape  rank_test_mape  
## 0             -0.32           0.06             147  
## 1             -0.32           0.06             151  
## 2             -0.32           0.06             147  
## 3             -0.32           0.06             151  
## 4             -0.29           0.04             139  
## ..              ...            ...             ...  
## 147           -0.25           0.02              19  
## 148           -0.26           0.02              73  
## 149           -0.25           0.02              23  
## 150           -0.26           0.02              73  
## 151           -0.25           0.02              23  
## 
## [152 rows x 16 columns]

En la siguiente gráfica observamos las distintas métricas de error asociados a los hiperparámetros elegidos:

(
  summary_df >>
  ggplot(aes(x = "param_n_neighbors", y = "mean_test_r2", size = "param_p",
             color = "param_metric", shape = "param_weights")) +
  geom_point(alpha = 0.65) +
  ggtitle("Parametrización de KNN vs R^2") +
  xlab("Parámetro: Número de vecinos cercanos") +
  ylab("R^2 promedio")
)
## <Figure Size: (640 x 480)>

Seleccionando la mejor parametrización, se puede definir la siguiente gráfica para comparar la diferencia entre hiperparámetros a un nivel más granular.

(
  summary_df >>
  filter(
    _.param_weights == "uniform",
    _.param_p == 1,
    _.param_metric == "manhattan") >>
  mutate(
    ymin = np.maximum(0, _.mean_test_r2 - _.std_test_r2),
    ymax = np.minimum(1, _.mean_test_r2 + _.std_test_r2)) >>
  ggplot(aes(x = "param_n_neighbors", y = "mean_test_r2")) +
  geom_errorbar(aes(ymin='ymin', ymax='ymax'),
    width=0.3, position=position_dodge(0.9)) +
  geom_point(alpha = 0.65) +
  ggtitle("Parametrización de KNN vs R^2") +
  xlab("Parámetro: Número de vecinos cercanos") +
  ylab("R^2 promedio")
)
## <Figure Size: (640 x 480)>

En la anterior gráfica observamos la R^2 con distintos números de vecinos.

Paso 8: Selección de modelo a usar

Habiendo realizado un análisis de los hiperparámetros, se procede a elegir el mejor modelo. Esto a veces a muy evidente y otras no. En cualquier caso, puede automatizarse la extracción del mejor modelo:

best_params = pipeline.named_steps['regressor'].best_params_
best_params
## {'metric': 'manhattan', 'n_neighbors': 19, 'p': 1, 'weights': 'uniform'}

Sabiendo cuáles son los mejores hiperparámetros, se procede a extraer el modelo que DEBEREMOS AJUSTAR A TODOS LOS DATOS DE ENTRENAMIENTO.

best_estimator = pipeline.named_steps['regressor'].best_estimator_
best_estimator
## KNeighborsRegressor(metric='manhattan', n_neighbors=19, p=1)

Paso 9: Ajuste de modelo final con todos los datos (Vuelve al paso 2, si es necesario)

Ahora obtendremos el modelo que mejor desempeño tiene y haremos las predicciones del conjunto de prueba con este modelo.

Es importante volver a hacer el ajuste con el modelo elegido.

final_knn_pipeline = Pipeline([
   ('preprocessor', preprocessor),
   ('regressor', best_estimator)
])

# Entrenar el pipeline
final_knn_pipeline.fit(ames_train_selected, ames_y_train)
## Pipeline(steps=[('preprocessor',
##                  ColumnTransformer(remainder='passthrough',
##                                    transformers=[('scaler', StandardScaler(),
##                                                   ['Full_Bath', 'Half_Bath']),
##                                                  ('onehotencoding',
##                                                   OneHotEncoder(drop='first',
##                                                                 sparse_output=False),
##                                                   ['Overall_Cond'])],
##                                    verbose_feature_names_out=False)),
##                 ('regressor',
##                  KNeighborsRegressor(metric='manhattan', n_neighbors=19, p=1))])

Este último objeto creado es el modelo final entrenado, el cual contiene toda la información del pre-procesamiento de datos, por lo que en caso de ponerse en producción, sólo se necesita de los nuevos datos y de este último elemento para poder realizar nuevas predicciones.

Paso 10: Validar poder predictivo con datos de prueba

Imaginemos por un momento que pasa un mes de tiempo desde que hicimos nuestro modelo, es hora de ponerlo a prueba prediciendo valores de nuevos elementos:

## Predicciones finales
y_pred_knn = final_knn_pipeline.predict(ames_x_test)

results_reg = (
  ames_x_test >>
  mutate(final_knn_pred = y_pred_knn, Sale_Price = ames_y_test) >>
  select(_.Sale_Price, _.final_knn_pred)
)
results_reg
##       Sale_Price  final_knn_pred
## 390       165000       212511.16
## 1235      124000       186756.74
## 2288       75000       186756.74
## 107       206000       130642.11
## 1861      190000       219827.68
## ...          ...             ...
## 116       171000       147781.58
## 398       120500       130642.11
## 1253      146000       212511.16
## 78        125000       133233.16
## 714       110000       170178.95
## 
## [586 rows x 2 columns]

Métricas de desempeño

Ahora para calcular las métricas de desempeño usaremos la paquetería MLmetrics. Es posible definir nuestro propio conjunto de métricas que deseamos reportar creando el objeto metric_set:

me = np.mean(y_obs - y_pred_knn)
mae = mean_absolute_error(y_obs, y_pred_knn)
mape = mean_absolute_percentage_error(y_obs, y_pred_knn)
mse = mean_squared_error(y_obs, y_pred_knn)
rmse = np.sqrt(mse)
r2 = r2_score(y_obs, y_pred_knn)
r2_adj = adjusted_r2_score(y_true = y_obs, y_pred = y_pred_knn,
  n=np.ceil(len(ames_train_selected)), p=len(ames_train_selected.columns))

metrics_data = {
    "Metric": ["ME", "MAE", "MAPE", "MSE", "RMSE", "R^2", "R^2 Adj"],
    "Value": [me, mae, mape, mse, rmse, r2, r2_adj]
}

metrics_df = pd.DataFrame(metrics_data)
metrics_df
##     Metric         Value
## 0       ME      -2465.53
## 1      MAE      45521.92
## 2     MAPE          0.27
## 3      MSE 4247334581.26
## 4     RMSE      65171.58
## 5      R^2          0.35
## 6  R^2 Adj          0.35
(
  results_reg >>
    ggplot(aes(x = "final_knn_pred", y = "Sale_Price")) +
    geom_point() +
    geom_abline(color = "red") +
    xlab("Prediction") +
    ylab("Observation") +
    ggtitle("Comparisson")
)
## <Figure Size: (640 x 480)>

7.4.1.3 Importancia de variables

Antes de pasar al siguiente paso, es importante validar que hayamos hecho un uso correcto de las variables predictivas. En este momento es posible detectar variables que no estén aportando valor o variables que no debiéramos estar usando debido a que cometeríamos data leakage. Para enfrentar esto, ayuda estimar y ordenar el valor de importancia de cada variable en el modelo.

importance = np.zeros(ames_x_test[columnas_seleccionadas].shape[1])

# Realiza el procedimiento de permutación
for i in range(ames_x_test[columnas_seleccionadas].shape[1]):
    ames_x_test_permuted = ames_x_test[columnas_seleccionadas].copy()
    ames_x_test_permuted.iloc[:, i] = shuffle(ames_x_test_permuted.iloc[:, i], random_state=42)  
    # Permuta una característica
    y_pred_permuted = final_knn_pipeline.predict(ames_x_test_permuted)
    mse_permuted = mean_squared_error(ames_y_test, y_pred_permuted)
    importance[i] = mse - mse_permuted

# Calcula la importancia relativa
importance = importance / importance.sum()
importance
## array([0.71249685, 0.12976432, 0.15773883])
importance_df = pd.DataFrame({
  'Variable': columnas_seleccionadas, 
  'Importance': importance
  })

# Crea la gráfica de barras
(
  importance_df >>
  ggplot(aes(x= 'reorder(Variable, Importance)', y='Importance')) + 
  geom_bar(stat='identity', fill='blue', color = "black") + 
  labs(title='Importancia de las Variables', x='Variable', y='Importancia') +
  coord_flip()
)
## <Figure Size: (640 x 480)>

La gráfica anterior muestra la importancia de una variable cuando se lleva a cabo una permutación. Dado que este resultado fue aleatorio, resulta vital contar con un conjunto de permutaciones que permitan conocer la caída promedio y desviación esperada en el desempeño cuando se elimina una variable o esta se vuelve inservible.

n_permutations = 50
performance_losses = []

for i in range(ames_x_test[columnas_seleccionadas].shape[1]):
    loss = []
    for j in range(n_permutations):
        ames_x_test_permuted = ames_x_test[columnas_seleccionadas].copy()
        ames_x_test_permuted.iloc[:, i] = np.random.permutation(ames_x_test_permuted.iloc[:, i])
        y_pred_permuted = final_knn_pipeline.predict(ames_x_test_permuted)
        mse_permuted = mean_squared_error(ames_y_test, y_pred_permuted)
        loss.append(mse_permuted)
    performance_losses.append(loss)

performance_losses = performance_losses/np.sum(performance_losses, axis=0)
mean_losses = np.mean(performance_losses, axis=1)
std_losses = np.std(performance_losses, axis=1)

importance_df = pd.DataFrame({
  'Variable': columnas_seleccionadas, 
  'Mean_Loss': mean_losses, 
  'Std_Loss': std_losses
  })
(
  importance_df >>
  mutate(
    ymin = _.Mean_Loss - _.Std_Loss,
    ymax = _.Mean_Loss + _.Std_Loss) >>
  ggplot(aes(x = 'reorder(Variable, Mean_Loss)', y = "Mean_Loss")) +
  geom_errorbar(aes(ymin='ymin', ymax='ymax'),
    width=0.2, position=position_dodge(0.9)) +
  geom_point(alpha = 0.65) +
  labs(title='Importancia de las Variables', x='Variable', y='Importancia') +
  coord_flip()
)
## <Figure Size: (640 x 480)>

Si en la gráfica anterior notamos algo raro en cuanto a la(s) variable(s) más importante(s) y la factibilidad de conseguirla(s) o usarla(s)… ¡¡HAY QUE EMPEZAR DESDE CERO SIN CONSIDERAR ESA VARIABLE!!