Introducción
Llegó la hora de intentar cumplir nuestra promesa del post anterior. Esto
significa que trataremos de ilustrar, mediante código, el valor que entrega
Pipelines
en problemas de Machine Learning.
A manera de ser aún más convincente, extenderé levemente la clase de imputación
de datos ModeImputer
que habíamos creado en el post anterior, a la vez de
conectarlo con otros dos procesos más: Transformación de variables y
Modelamiento supervisado. Mediante esto mostraré, lo que es para mí el principal
aspecto diferenciador de Pipelines
: La capacidad de evaluar de forma simple y
eficiente, todas las combinaciones posibles de las variaciones de cada método
que hemos definido en nuestro Pipeline
. La forma en que haremos esto es
mediante los conocidos métodos de sklearn
GridSearchCV
o
RandomizedSearchCV
, los cuales son ampliamente utilizados para buscar la
combinación óptima de parámetros de un modelo en virtud de los datos de
entrenamiento. El único ingrediente extra en nuestro caso, es que consideramos
la cadena de método de imputación, transformación y modelo supervisado, como un
solo gran estimador, utilizando GridSearchCV
o RandomizedSearchCV
directamente sobre el.
Código
El procedimiento es como sigue: Primero importaremos todas las librerías que
utilizaremos en el transcurso del post.
Luego se presenta ModeImputer
extendido, al cual le cambiamos a un nombre más
general: MyImputer
. Como 3era etapa generaremos datos ficticios, ilustrando el
patrón que debiésemos esperar que el modelo encuentre. Finalmente, crearemos
nuestro Pipeline
para luego definir que parámetros deseamos que sklearn
considere para calibrar y ajustaremos el Pipeline
mostrando directamente la
combinación óptimo de parámetros-modelo.
Importar Librerías.
# Importamos librerías, recurriendo fuertemente a modulos de sklearn,
# el enfoque principal de nuestro post además de matplotlib
# para presentar de forma más clara el patrón de los datos considerados.
import pandas as pd
import numpy as np
from sklearn.base import BaseEstimator, TransformerMixin
from sklearn.model_selection import GridSearchCV
from sklearn.datasets import make_regression
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import FunctionTransformer
from sklearn.linear_model import ElasticNet, Lasso, Ridge, LinearRegression
import matplotlib.pyplot as plt
%matplotlib inline
Clase ModeImputer
extendida
class MyImputer(BaseEstimator, TransformerMixin):
"""
Nuestra Clase anterior modificada. El principal
cambio a destacar es que se extiende la clase
para entregarle al usuario la opción de elegir
la metodología con que se imputarán el dataframe
dentro de dos opciones: `mode` (moda) y `mean` (media).
Implicando que `ModeImputer` ya no es el nombre más adecuado.
Además de lo anterior, tenemos que llamar dentro de la clase
al módulo `BaseEstimator` que entrega a MyImputer
funciones que `Pipelines` necesariamente requiere.
"""
def __init__(self, missing_value = '?', impute_type = 'mode'):
self.missing_value = missing_value
self.impute_type = impute_type
def fit(self, X, y=None):
# identificamos las columnas con carácter problematico
cols = X.apply(lambda x: x.str.contains('\\' + self.missing_value).any()).copy()
df_tmp = X.loc[:,'X'].replace({self.missing_value: np.nan})
# convertimos a numeros y luego a dataframe.
df_tmp = pd.to_numeric(df_tmp)
df_tmp = df_tmp.to_frame()
# dos casos 1 para moda, segundo para media.
if self.impute_type == 'mode':
mode = df_tmp.mode(axis = 0).values[0]
self.replace_with = dict(zip(df_tmp.columns, np.reshape(mode, newshape=(-1,))))
elif self.impute_type == 'mean':
mean = df_tmp.mean(axis = 0).values[0]
self.replace_with = dict(zip(df_tmp.columns, np.reshape(mean, newshape=(-1,))))
return self
def transform(self, X):
replace_with = {k:{self.missing_value: v} for k, v in self.replace_with.items()}
return X.replace(to_replace = replace_with).apply(pd.to_numeric, errors = 'ignore')
# Variable que utilizaremos para fijar el mismo
# random_state a lo largo del problema
random_state = 1
#Creamos un dataset ficticio con la ayuda de sklearn.
#En este caso 1000 filas, con 1 variable independiente
#`x` y 1 dependiente `y`. Al dataset le
#introducimos un poco de ruido con el parametro noise
X, y = make_regression(n_samples= 1000, n_features=1, n_informative=1, n_targets=1,
noise=5, random_state=random_state)
La siguiente celda además de llevar, por temas de conveniencia los dos arrays a
un mismo DataFrame, realiza un paso importante: Eleva la variable dependiente al
cuadrado. Lo relevante de este punto, es que explicita el tipo de relación que
deberíamos esperar que sklearn
detecte al cabo de ajustar el pipeline. Una de
tipo cuadrática.
df = pd.DataFrame({'X': X.flatten(), 'y': (y**2).flatten()})
A continuación mostramos el gráfico de dispersión de los datos que recién
creamos: x
e y
. Esta visualización, además de ilustrar el patrón
anteriormente mencionado muestra, a grandes rasgos, el grado de ruido que tiene
esta relación.
df.plot(kind='scatter', x = 'X', y = 'y')
<matplotlib.axes._subplots.AxesSubplot at 0x1d98f25d1d0>
# Seteamos el mismo random_state para numpy.
# Para después introducir los carácteres
# problematicos `'?'` de forma aleatoria.
np.random.seed(random_state)
df.X = df.X.astype(str)
i_choices = np.random.choice(df.index, size = 200)
df.iloc[i_choices, 0] = '?'
Nuestro Pipeline
Este es el paso clave del post. Aquí la API de sklearn
tiene como requisito
que entreguemos en una sola lista solo objetos compatibles de sklearn
. En
mayor detalle, cada elemento de la lista es una tupla que está compuesta por los
nombres que designaremos a cada etapa y con su respectivo objeto de sklearn
.
Notar que estos objetos que en la siguiente celda estamos llamando son solo
iniciales y su función es netamente actuar como placeholders.
pipeline = Pipeline([
('impute', MyImputer(missing_value = '?')),
('transform', FunctionTransformer(np.square, validate=True)),
('reg', ElasticNet(random_state=random_state))])
Como último ingrediente relevante es construir el diccionario que sklearn
necesita para saber por cuales combinaciones de parámetros debe iterar. Para
ello sklearn
nos pide que las llaves del diccionario empiecen con los nombres
de cada etapa que definimos en la celda anterior (i.e. impute
, transform
o
reg
) seguido por doble underscore y luego el nombre del parámetro de cada
etapa (i.e. impute_type
o func
). Los valores entonces son los parámetros a
recorrer en cada etapa.
Inspeccionando el diccionario, notamos que todos, salvo log_abs
, son ya sea,
parámetros que ya describimos anteriormente, funciones conocidas de numpy o
metodos importados directamente de sklearn
. El tratamiento especial en el caso
de log_abs
, es dado que en el post anterior manifestamos que una
transformación a evaluar era $log(x)$. El problema, es que nuestra variable
independiente x
contiene valores negativos y $log(x)$ no está definido para
los números negativos. Así, se tuvo que crear la función auxiliar log_abs
que
tranforma x a valores absolutos y luego utiliza log
: $log(|x|)$
def log_abs(x):
return np.log(np.abs(x))
parameters = {'impute__impute_type': ['mode', 'mean'],
'transform__func': [np.square, np.exp, np.reciprocal, log_abs],
'reg': [
ElasticNet(random_state=random_state),
Lasso(random_state=random_state),
Ridge(random_state=random_state),
LinearRegression()
]}
En la siguiente celda es donde finalmente creo se muestra de forma más clara la magia de sklearn.
Como ya habíamos adelantado, aplicamos GridsearchCV
directamente al pipeline
que definimos. A diferencia de RandomizedSearchCV
GridsearchCV
evaluará
exhaustivamente cada combinación posibles del producto cardinal de los sets
definidos en parameters
. Como check, para la combinación óptima además de
esperar una relación del tipo cuadrática: (np.square
) debiésemos esperar que
el método de imputación óptimo sea mean
. La razón es que en el caso de
impute
, es muy probable que la moda encontrada para nuestra variable
independiente, no corresponda a un valor que represente a uno de tendencia
central (aspecto deseable para nuestro problema). Esto se da, porque al ser x
una variable continua generada aleatoriamente, es practicamente imposible que
encuentre una valor que tenga una frecuencia superior a 1 en dataset. Por lo
tanto, al tener todos los valores la misma frecuencia, en vez de pandas
devolvernos un valor de tendencia central, no le queda más que devolvernos el
array completo de x
, tomando nuestra clase el primero que encuentra.
rs_reg = GridSearchCV(pipeline, parameters )
rs_reg = rs_reg.fit(df.X.to_frame(), df.y)
La celda anterior, nos muestra el último gran paso en el uso que le hemos dado a
Pipelines
. Aquí hacemos uso de la clásica función fit
de sklearn
. Como
ven, en una sola linea, logramos ajustar cada una de las combinaciones de
parámetros que definimos anteriormente, identificando, de paso, la combinación
óptima. No me creen? En la siguiente celda, les muestra esta combinación óptima
de parámetros que habiamos mencionado que GridSearchCV
del pipeline
debía
encontrar. mean
y np.square
para impute
y transform
, respectivamente.
rs_reg.best_params_
{'impute__impute_type': 'mean',
'reg': LinearRegression(copy_X=True, fit_intercept=True, n_jobs=1, normalize=False),
'transform__func': <ufunc 'square'>}
Conclusión
Para finalizar me gustaría enfatizar una serie de puntos que quise transmitir a lo largo de estos dos posts:
- Que podemos extender el ajuste de parámetros que no son parte del modelo,
pero si de etapas previas de forma simple y clara mediante
Pipelines
. - Que tenemos la libertad de definir nuestras propias clases de
sklearn
los cuales nos permiten utilizar los métodos desklearn
de forma más generalizada. - Que estas clases no necesariamente deben ser limitadas a utilizar funciones
unicamente de
sklearn
opython
nativo. Específicamente, como parte de una definición de nuestra clase customizada desklearn
, podemos utilizar otras librerías comopandas
. Lo cual siempre y cuando es permitido cuando sigamos los requerimientos solicitados porsklearn
.
A pesar de lo anterior, no crean que Pipelines
se restringe solo a esto. Es
más, para este blog temas cómo crear propios estimadores para luego integrarlos
a un Pipeline
, crear pipelines anidados y definir tratamientos en paralelo de
datasets heterogéneos mediante FeatureUnions
, fueron aspectos que por tiempo,
no pude considerar para este blog. En este sentido, si no deseas esperar, te
recomiendo que leas la documentación de sklearn, que tiene varios
ejemplos y guías muy útiles para empezar.