datacubeR

Tabular Playground Enero 2022

Mi primera incursión en Kaggle

Prediciendo ventas de Kaggle

picture of me picture of me

Como algunos sabrán me cambié de pega. En mi nuevo trabajo hay una fuerte componente temporal involucrada en los problemas que resolvemos y justo este mes en Kaggle el Tabular Playground abordó un problema de series de tiempo. ¿Qué mejor manera de introducirse en el mundo de Kaggle? .

La verdad es que hace rato que quería empezar a competir en Kaggle, pero me daba como miedito. Como que es jugar en las grandes ligas donde hay mucha gente que sabe lo que está haciendo. Decidí entrar en estas competencias de un mes, que no son con premio (pensando que eran más sencillas) y en verdad aprendí muchísimo y no son más fáciles. Una de las cosas más importante que me llevo es que los modelos líneales son poderosos y útiles.

SPOILER: El ganador de esta competencia ganó con un Modelo Lineal, y harto feature engineering con data externa.

Sobre Kaggle

  • Yo creo (es sólo mi opinion) que Kaggle no es para alguien recién empezando. Si bien se puede encontrar gente de todos los niveles, siento que participar en Kaggle es abrumante. Todos los días hay Kernels nuevos, con nuevas opiniones, consejos, tips, que uno no sabe por donde partir, lo cual creo que para alguien que está partiendo es demasiada información por todas partes (Para mí fue terrible, de hecho, luego de dos semanas como que no quería seguir entrando, es muy desgastante).

  • Otra cosa que me dí cuenta, es que las personas que realmente saben en Kaggle son pocas, y hay que tener un ojo crítico para encontrarlos. En el caso de esta competencia un tipo de nick AmbrosoM siempre destacó con sus kernels, todas las soluciones se basaban en sus hallazgos y terminó ganando la competencia, porque efectivamente tenía un entendimiento bastante superior al resto de nosotros.

  • Hay muchos que colocan Kernels muy bonitos, con EDA espectacular, con modelos espectaculares y que reciben muchos votos, pero que terminan dando la hora en el Private Leaderboard. Hay un chico en el cuál tomé las ideas de Hybrid Models que terminó más abajo que yo, supongo que por abusar del Public Leaderboard y otro que implementó un transformer desde cero. Como que quise copiar su Transformer pero no logré entenderlo, y lo que no entiendo no lo implemento.

  • La semana pasada tuve la oportunidad de escuchar una entrevista de Weights & Biases a un Kaggler llamado Mark Tenenholtz, y me gustó mucho, porque él decía que la verdadera habilidad que da Kaggle es aprender a tener un buen esquema de validación y evitar el Overfitting. El mejor consejo que entregaba es no preocuparse del Score Público y confiar en el CV local. Siempre dan ese consejo pero pocas veces se sigue. Y en esta competencia, el que salió 1ero en el Public LeaderBoard terminó 322 en el Private LeaderBoard. De verdad la competencia fue una oda al Overfitting.

Más adelante les cuento como me fue a mí!!.

Entonces quiero mostrarles cuál fue una de las soluciones que plantée con lo aprendido que usa una técnica llamada Hybrid Models, o Boosted Linear Models, el cual combina modelos lineales con modelos Boosting (que siguen siendo mis favoritos).

Presentación del Problema

import pandas as pd
import numpy as np   
import matplotlib.pyplot as plt
import seaborn as sns
import holidays

Quizás, la única librería que hay que destacar acá es Holidays. Es una librería bien sencilla pero que tiene todos los feriados de los países.

df_train = pd.read_csv('../inputs/train.csv', index_col=0, parse_dates=['date'])
df_test = pd.read_csv('../inputs/test.csv', index_col=0, parse_dates=['date'])

df_train.shape, df_test.shape
((26298, 5), (6570, 4))

Como se puede ver, el número de variables predictoras es muy limitado, sólo 4. Y el número de registros permite poder realizar una gran cantidad de experimentos sin un costo computacional tan alto. Los registros del dataset se ven así:

df_train.head()
datecountrystoreproductnum_sold
row_id
02015-01-01FinlandKaggleMartKaggle Mug329
12015-01-01FinlandKaggleMartKaggle Hat520
22015-01-01FinlandKaggleMartKaggle Sticker146
32015-01-01FinlandKaggleRamaKaggle Mug572
42015-01-01FinlandKaggleRamaKaggle Hat911
52015-01-01FinlandKaggleRamaKaggle Sticker283
62015-01-01NorwayKaggleMartKaggle Mug526
72015-01-01NorwayKaggleMartKaggle Hat906
82015-01-01NorwayKaggleMartKaggle Sticker250
92015-01-01NorwayKaggleRamaKaggle Mug1005
102015-01-01NorwayKaggleRamaKaggle Hat1461
112015-01-01NorwayKaggleRamaKaggle Sticker395
122015-01-01SwedenKaggleMartKaggle Mug440
132015-01-01SwedenKaggleMartKaggle Hat624
142015-01-01SwedenKaggleMartKaggle Sticker175
  • Como pueden notar hay pocas variables, lo cual quiere decir que esta competencia se va a ganar principalmente por quien desarrolle las mejores features.
  • Dado que hay una fuerte componente temporal una muy buena idea es poder descomponer las fechas en varias variables adicionales.
  • El esquema de validación es algo que no se puede dejar al azar. Dado que hay una componente temporal (secuencial) es que no se pueden hacer splits aleatorios, se debe respetar el orden.

Sigo sin comprender por qué es necesario utilizar splits ordenados en el tiempo. Creo personalmente que hay una diferencia entre un problema de Forecasting de Series de Tiempo y otro de Regresión de Series de Tiempo. La documentación de sktime explica que un Forecasting es cuando el target t-1 es input necesario para predecir el target t. Y es en este caso donde se requiere una validación secuencial.

En el caso de esta competencia, se propuso utilizar GroupKFold donde cada grupo era un año y terminó dando buenos resultados a pesar de que uno a veces terminaba validando en el pasado.

Si alguien tiene alguna propuesta/respuesta/explicación a este fenómeno que me contacte.

Variables Temporales

Algunas de las variables creadas descomponiendo la fecha fueron las siguientes (todas construidas con Pandas).

def create_date_features(df):
    df['day_of_year'] = df.date.dt.day_of_year
    df['day_of_month'] = df.date.dt.day
    df['day_of_week'] = df.date.dt.weekday
    df['month'] = df.date.dt.month
    df['quarter'] = df.date.dt.quarter
    df['year'] = df.date.dt.year
    df['period'] = df.date.dt.to_period('M')
    return df

df_train = create_date_features(df_train)
df_test = create_date_features(df_test)

df_train.shape, df_test.shape
    
((26298, 12), (6570, 11))

EDA

El Análisis exploratorio es definitivamente algo que no me gusta hacer, pero que pucha que ayuda para entender la data. Una de las cosas que más aprendí en esta competencia es la importancia de siempre volver al análisis. El análisis exploratorio para ir descubriendo que features nuevos ir construyendo pero también al Error Analysis para ir chequeando donde tu modelo está fallando y trabajar en dichas falencias.

Yo aún no tengo un framework definido de error analysis. Es algo que recién descubrí su importancia. (A pesar de que escuché la clase Andrew Ng hace tiempo en este tema). Siento que ahora recién empiezo a tomarle el peso a tener este tipo de información para elegir el mejor modelo

df_train.date.agg({np.min, np.max})
amin   2015-01-01
amax   2018-12-31
Name: date, dtype: datetime64[ns]
df_test.date.agg({np.min, np.max})
amin   2019-01-01
amax   2019-12-31
Name: date, dtype: datetime64[ns]
df_train.period.value_counts().sort_index().plot(figsize = (12,8));

png

df_test.period.value_counts().sort_index().plot(figsize = (12,8));

png

def cat_per_column(df):
    cat_cols = [col for col in df.columns if df[col].dtype == 'object']
    for col in cat_cols:
        print(df[col].value_counts(), '\n')
        

cat_per_column(df_train)
Finland    8766
Norway     8766
Sweden     8766
Name: country, dtype: int64 

KaggleMart    13149
KaggleRama    13149
Name: store, dtype: int64 

Kaggle Mug        8766
Kaggle Hat        8766
Kaggle Sticker    8766
Name: product, dtype: int64 
cat_per_column(df_test)
Finland    2190
Norway     2190
Sweden     2190
Name: country, dtype: int64 

KaggleMart    3285
KaggleRama    3285
Name: store, dtype: int64 

Kaggle Mug        2190
Kaggle Hat        2190
Kaggle Sticker    2190
Name: product, dtype: int64 
df_train.set_index('period').num_sold.plot(figsize = (16,8));

png

df_train.groupby(['country', 'store','product']).num_sold.agg(['min','max','mean'])
minmaxmean
countrystoreproduct
FinlandKaggleMartKaggle Hat2101113362.479808
Kaggle Mug126774204.200548
Kaggle Sticker70326103.044490
KaggleRamaKaggle Hat3541895628.926762
Kaggle Mug2201398356.110883
Kaggle Sticker128559180.232033
NorwayKaggleMartKaggle Hat3351809594.645448
Kaggle Mug2011113334.370294
Kaggle Sticker114518169.577687
KaggleRamaKaggle Hat59628841036.357974
Kaggle Mug3661935584.297741
Kaggle Sticker214874295.607803
SwedenKaggleMartKaggle Hat2481207419.214237
Kaggle Mug149730235.885010
Kaggle Sticker86356119.613279
KaggleRamaKaggle Hat4282169731.452430
Kaggle Mug2531438411.273101
Kaggle Sticker148637208.314853
df_train.groupby(['country', 'store','product']).num_sold.mean().unstack(level = 'store').assign(ratio = lambda x: x.KaggleRama/x.KaggleMart)
storeKaggleMartKaggleRamaratio
countryproduct
FinlandKaggle Hat362.479808628.9267621.735067
Kaggle Mug204.200548356.1108831.743927
Kaggle Sticker103.044490180.2320331.749070
NorwayKaggle Hat594.6454481036.3579741.742817
Kaggle Mug334.370294584.2977411.747457
Kaggle Sticker169.577687295.6078031.743200
SwedenKaggle Hat419.214237731.4524301.744818
Kaggle Mug235.885010411.2731011.743532
Kaggle Sticker119.613279208.3148531.741570

Gracias al Análisis Exploratorio se pudo notar que básicamente Kaggle Rama siempre vende alrededor de 1.73x más que KaggleMart. Yo particularmente no lo utilicé en el proceso de modelamiento, pero creo que los primeros lugares sí lo utilizaron para que el modelo entendiera mejor la diferencia entre una y otra tienda.

Gráficos comparativos

A continuación presento una serie de gráficos para poder entender las distintas tendencias entre las categorías del dataset y distintas ventanas de tiempo.

df_train.query('store == "KaggleRama"').groupby(["day_of_week"]).num_sold.mean().plot(label = 'KaggleRama')
df_train.query('store == "KaggleMart"').groupby(["day_of_week"]).num_sold.mean().plot(label = 'KaggleMart')
plt.legend()

png

df_train.query('product == "Kaggle Sticker"').groupby(["day_of_week"]).num_sold.mean().plot(label = "Kaggle Sticker")
df_train.query('product == "Kaggle Hat"').groupby(["day_of_week"]).num_sold.mean().plot(label = "Kaggle Hat")
df_train.query('product == "Kaggle Mug"').groupby(["day_of_week"]).num_sold.mean().plot(label = "Kaggle Mug")
plt.legend();

0: Es Lunes. 6: Es Domingo.

png

df_train.groupby("day_of_month").num_sold.mean().plot();

png

df_train.query('store == "KaggleRama"').groupby(["day_of_month"]).num_sold.mean().plot(label = 'KaggleRama')
df_train.query('store == "KaggleMart"').groupby(["day_of_month"]).num_sold.mean().plot(label = 'KaggleMart')
plt.legend();

png

df_train.query('product == "Kaggle Sticker"').groupby(["day_of_month"]).num_sold.mean().plot(label = "Kaggle Sticker")
df_train.query('product == "Kaggle Hat"').groupby(["day_of_month"]).num_sold.mean().plot(label = "Kaggle Hat")
df_train.query('product == "Kaggle Mug"').groupby(["day_of_month"]).num_sold.mean().plot(label = "Kaggle Mug")
plt.legend();

png

df_train.groupby("day_of_year").num_sold.mean().plot();

png

df_train.query('store == "KaggleRama"').groupby(["day_of_year"]).num_sold.mean().plot(label = 'KaggleRama')
df_train.query('store == "KaggleMart"').groupby(["day_of_year"]).num_sold.mean().plot(label = 'KaggleMart')
plt.legend();

png

df_train.query('product == "Kaggle Sticker"').groupby(["day_of_year"]).num_sold.mean().plot(label = 'Kaggle Sticker')
df_train.query('product == "Kaggle Hat"').groupby(["day_of_year"]).num_sold.mean().plot(label = 'Kaggle Hat')
df_train.query('product == "Kaggle Mug"').groupby(["day_of_year"]).num_sold.mean().plot(label = 'Kaggle Mug')
plt.legend();

png

df_train.groupby("year").num_sold.mean().plot();

png

df_train.query('store == "KaggleRama"').groupby(["year"]).num_sold.mean().plot(label = 'KaggleRama')
df_train.query('store == "KaggleMart"').groupby(["year"]).num_sold.mean().plot(label = 'KaggleMart')
plt.legend();

png

df_train.query('product == "Kaggle Sticker"').groupby(["year"]).num_sold.mean().plot(label = 'Kaggle Sticker')
df_train.query('product == "Kaggle Hat"').groupby(["year"]).num_sold.mean().plot(label = 'Kaggle Hat')
df_train.query('product == "Kaggle Mug"').groupby(["year"]).num_sold.mean().plot(label = 'Kaggle Mug')
plt.legend();

png

plt.figure(figsize = (18,12))
df_train.set_index('date').num_sold.plot(label = 'daily')
df_train.set_index('date').num_sold.resample('W').mean().plot(label = 'weekly')
df_train.set_index('date').num_sold.resample('M').mean().plot(label = 'monthly')
plt.legend();

png

Conclusiones del EDA

  • Es posible ver que el viernes hay un leve aumento en las ventas y ya el sábado y el domingo se vende bastante más.
  • Hay un incremento en las ventas al final del mes.
  • También existen incrementos en los finales de año.
  • Hay un efecto relacionado a los festivos. Se pueden ver peaks recurrentes año a año que están asociados a festividades propias de cada país. (De ahí la importancia de la librería holidays.)
  • Se puede ver que desde el 2016 en adelante hay un incremento lineal en las ventas. Esto muy probablemente indica que en el año a predecir (2019) la tendencia continúa, y por lo tanto nuestro modelo debe ser capaz de extrapolar.
  • Se pueden ver periodos de estacionalidad cuando suavizamos la curva al nivel de semana.

Feature Engineering

A partir de las conclusiones del EDA se intentarán modelar variables para abordar los distintos efectos:

Holiday Effect

Para poder entonces modelar los efectos de los festivos utilizamos la librería Holiday. Esta librería permite extraer los feriados por País y por Año.

finland = pd.DataFrame([dict(date = date, 
                            finland_holiday = event, 
                            country= 'Finland') for date, event in holidays.Finland(years=[2015, 2016, 2017, 2018, 2019]).items()])
finland['date'] = finland['date'].astype("datetime64")

norway = pd.DataFrame([dict(date = date, 
                        norway_holiday = event, 
                        country= 'Norway') for date, event in holidays.Norway(years=[2015, 2016, 2017, 2018, 2019]).items()])
norway['date'] = norway['date'].astype("datetime64")

sweden = pd.DataFrame([dict(date = date, 
                            sweden_holiday = event.replace(", Söndag", ""), 
                            country= 'Sweden') for date, event in holidays.Sweden(years=[2015, 2016, 2017, 2018, 2019]).items() if event != 'Söndag'])
sweden['date'] = sweden['date'].astype("datetime64")

df_train = df_train.merge(finland, on = ['date', 'country'], how = 'left').merge(norway, on = ['date', 'country'], how = 'left').merge(sweden, on = ['date', 'country'], how = 'left')
df_test = df_test.merge(finland, on = ['date', 'country'], how = 'left').merge(norway, on = ['date', 'country'], how = 'left').merge(sweden, on = ['date', 'country'], how = 'left')
df_train
datecountrystoreproductnum_soldday_of_yearday_of_monthday_of_weekmonthquarteryearperiodfinland_holidaynorway_holidaysweden_holiday
02015-01-01FinlandKaggleMartKaggle Mug3291131120152015-01UudenvuodenpäiväNaNNaN
12015-01-01FinlandKaggleMartKaggle Hat5201131120152015-01UudenvuodenpäiväNaNNaN
22015-01-01FinlandKaggleMartKaggle Sticker1461131120152015-01UudenvuodenpäiväNaNNaN
32015-01-01FinlandKaggleRamaKaggle Mug5721131120152015-01UudenvuodenpäiväNaNNaN
42015-01-01FinlandKaggleRamaKaggle Hat9111131120152015-01UudenvuodenpäiväNaNNaN
................................................
262932018-12-31SwedenKaggleMartKaggle Hat82336531012420182018-12NaNNaNNyårsafton
262942018-12-31SwedenKaggleMartKaggle Sticker25036531012420182018-12NaNNaNNyårsafton
262952018-12-31SwedenKaggleRamaKaggle Mug100436531012420182018-12NaNNaNNyårsafton
262962018-12-31SwedenKaggleRamaKaggle Hat144136531012420182018-12NaNNaNNyårsafton
262972018-12-31SwedenKaggleRamaKaggle Sticker38836531012420182018-12NaNNaNNyårsafton

26298 rows × 15 columns

Fourier Effect

Para modelar los periodos estacionales también es posible utilizar las propiedades de las series de Fourier. Sabemos (o deberíamos saber) que una serie de Fourier es una aproximación de infinitos combinaciones lineales de senos y cosenos a una función periodica. El tema es que para poder evitar considerar el ruido propio de una serie de tiempo podríamos sólo utilizar un número de componentes finitos que nos permitan deshacernos del ruido y encontrar sólo la señal de interés:

from numpy.fft import rfft, irfft, rfftfreq

def low_pass(s, threshold=2e4, d = None):
    if d is None:
        d = 2e-3 / s.size
    fourier = rfft(s)
    frequencies = rfftfreq(s.size, d=d)
    fourier[frequencies > threshold] = 0
    return irfft(fourier)
plt.figure(figsize = (18,12))
s = df_train.num_sold
for i, d in enumerate([1e-1, 1e-2, 1e-3, 1e-4, 1e-5, 1e-6, 1e-7, 1e-8, 1e-9], start=1):
    ax = plt.subplot(3,3, i)
    ax.plot(low_pass(s, threshold=2e4, d = d))
    ax.set_title(f'd = {d}')

png

Luego una serie de Fourier se define formalmente como: \[f(t) \sim \frac{a_o}{2} + \sum{}_{n=1}^{\infty}\left[a_n cos\left(\frac{2n\pi}{T}t\right) + b_n sin\left(\frac{2n\pi}{T}t\right)\right]\]

Afortunadamente la librería feature-engine contiene una clase dedicada a componentes cíclicos (aka Fourier). Utilizando sus propiedades de manera apropiada, es posible replicar cada componente de la Serie de Fourier.

\(var\_sin = sin\left(\frac{2\pi}{max\_value}t\right)\) \(var\_cos = cos\left(\frac{2\pi}{max\_value}t\right)\)

\(var\_sin = sin\left(\frac{2n\pi}{max\_value}t\right) = sin\left(\frac{2\pi}{\frac{max\_value}{n}}t\right)\) \(var\_cos = cos\left(\frac{2n\pi}{max\_value}t\right) = cos\left(\frac{2\pi}{\frac{max\_value}{n}}t\right)\)

El Transformer creado por Feature-Engine tiene el inconveniente de que siempre nombra su resultado como var_sin o var_cos. Esto genera que cuando creo más de una componente de Fourier, estas se sobreescriben. Para mitigar eso creé mi propia versión del Transformer

class CyclicalTransformerV2(CyclicalTransformer):
    def __init__(self, suffix = None, **kwargs):
            super().__init__(**kwargs)
            self.suffix = suffix
        
    def transform(self, X):
        X = super().transform(X)
        if self.suffix is not None:
            transformed_names = X.filter(regex = r'sin$|cos$').columns
            new_names = {name: name + self.suffix for name in transformed_names}
            X.rename(columns=new_names, inplace=True)
        return X

Para ello heredé del CyclicalTransformer de Feature Engine y sólo modifiqué su transform.

Interacciones

Adicionalmente, es posible crear interacciones entre las variables categóricas y distintas componentes de Fourier para captar la estacionalidad propia de cada una de ella multiplicando la versión Dummy (One Hot Encoded) de cada categoría con una componente de Fourier.

products = pd.get_dummies(df_train['product'])
products.index = df_train.date

fourier_variables = pd.DataFrame({'sin': np.sin(2*np.pi*1*df_train['day_of_week']/7),
                            'cos': np.cos(2*np.pi*1*df_train['day_of_week']/7),
                            'sin2': np.sin(2*np.pi*2*df_train['day_of_week']/7),
                            'cos2': np.cos(2*np.pi*2*df_train['day_of_week']/7),
                            'sin3': np.sin(2*np.pi*3*df_train['day_of_week']/7),
                            'cos3': np.cos(2*np.pi*3*df_train['day_of_week']/7)})

fourier_variables.index = df_train.date
fourier_variables[['sin','cos']].plot(figsize = (16,12));
ax = fourier_variables.plot.scatter('sin', 'cos', figsize = (16,12)).set_aspect('equal')

png

La gracia de la componente de Fourier es que permite que el modelo entienda que por ejemplo el Lunes (0) y el Domingo (6), están a la misma distancia a pesar de que numéricamente están a 6 unidades de distancia. Es como llevarlo a coordenadas polares.

El modelo

Como dijimos una de las gracias que tiene este problema es que es muy ruidoso, pero a la vez tiene una tendencia, en la cual nosotros tendremos que extrapolar.

Sé que muchos están esperando que diga que la regresión Lineal va a resolver todos los problemas, pero no. La extrapolación no es una ventaja de los modelos de árboles, pero sí de los modelos líneales (un punto para los modelos lineales). Por otro lado, ajustarse a alta variabilidad no es una ventaja de los modelos lineales, pero sí de los modelo de árbol (un punto para los modelos de árbol).

La pregunta es,

¿por qué no usar ambos de manera inteligente?

Aquí es donde aprendí de los modelos Híbridos.

El fundamento de los modelos híbridos es que el modelo líneal se encarga de captar la tendencia y extrapolarla. Y el modelo Boosting se encarga de aprender el ruido. Super interesante!

La idea acá es probar muchas combinaciones. Dentro de los modelos lineales que probé estuvo LR, HuberRegressor, PassiveAggresiveRegressor, entre otros. Y en los Boosting, probamos los 3 grandes, XGBoost, LightGBM y Catboost. Los resultados a mostrar ahora fueron los mejores para modelos antes de ensamblar.

Si ajustamos un modelo líneal a la data se ve algo así:

png

Como se ve un modelo lineal puede captar la tendencia de las ventas, e incluso la estacionalidad. Lo que no logra captar tan bien son los puntos extremos.

De hecho si hacemos una gráfica de los residuals (el valor real menos la predicción) notamos esto:

png

Los mayores errores (los puntitos azules más abajo) se dan en los peaks que se escapan de la tendencia. Luego la idea es poder utilizar el modelo Boosting para aprender los errores del modelo lineal y luego sumar las predicciones, obteniendo este ajuste:

png

Este procedimiento podría ser muy propenso a Overfitting, por lo cual hay que asegurarse de una buena estrategia de validación.

Implementación de la Solución:

Generación de Features

Para generar mis features cree una clase que implementa todo de manera ordenada.

  • Un detalle es que dentro de las discusiones de Kaggle se notó de que utilizar el GDP de cada país entregaba muy buenos resultados, por lo cual también lo agregué como feature.
  • Además otros festivos como la pascua, el día de la mamá dieron buenos resultados. Además se dejaron indicadores de días previos y posteriores a fechas especiales que dieron buenos resultados.
  • Se agreagaron indicadores para detectar viernes y fines de semana.
  • Finalmente para el ajuste del modelo lineal se encontró que transformar el target a Log funcionaba de mejor manera. Esto debido a que el MAE de \(log(y)\) es muy parecido al error del SMAPE que era la métrica con la que se evaluaba este modelo.

El modelo que muestro acá es luego de varias ejecuciones afinando las variables finales y el respectivo preprocesamiento.

class Preprocess:
    def __init__(self, gdp_path, easter, special_days, country_holidays):
        self.gdp_path = gdp_path     
        self.easter = easter  
        self.special_days = special_days
        self.country_holidays = country_holidays
    
    def import_data(self, path):
        self.df = pd.read_csv(path, parse_dates=['date'], index_col=0)
        self.index = self.df.index
        
    def get_gdp(self, path):
        gdp = pd.read_csv(path)
        gdp.columns = gdp.columns.str.title().str.replace('Gdp_','')
        gdp = gdp.set_index('Year').stack(0).reset_index()
        gdp.columns = ['year','country','gdp']
        return gdp

    def create_date_features(self, df):
        # A
        df['day_of_year'] = df.date.dt.day_of_year
        df['day_of_month'] = df.date.dt.day
        df['day_of_week'] = df.date.dt.weekday
        df['week_of_year'] = df.date.dt.isocalendar().week.astype('int64')
        df['year'] = df.date.dt.year
        return df
    
    def add_country_holidays(self, df):
        years = [2015, 2016, 2017, 2018, 2019]
        
        finland = self.country_holidays['finland']
        norway = self.country_holidays['norway']
        sweden = self.country_holidays['sweden']
        
        return (df.merge(finland, on = ['date', 'country'], how = 'left')
                        .merge(norway, on = ['date', 'country'], how = 'left')
                        .merge(sweden, on = ['date', 'country'], how = 'left'))

    def get_specific_dates_features(self, df):
        
        return (df.assign(easter = lambda x: x.year.map(self.easter).astype('datetime64'),
                            moms_day = lambda x: x.year.map(self.special_days['moms_day']).astype('datetime64'),
                            wed_jun = lambda x: x.year.map(self.special_days['wed_june']).astype('datetime64'),
                            sun_nov = lambda x: x.year.map(self.special_days['sun_nov']).astype('datetime64'),
                            
                            days_from_easter = lambda x: (x.date - x.easter).dt.days.clip(-5, 65),
                            days_from_mom = lambda x: (x.date - x.moms_day).dt.days.clip(-1, 9),
                            days_from_wed = lambda x: (x.date - x.wed_jun).dt.days.clip(-5, 5),
                            days_from_sun = lambda x: (x.date - x.sun_nov).dt.days.clip(-1, 9),
            )).drop(columns = ['easter','moms_day','wed_jun','sun_nov'])
        
    def join_gdp(self, df):
        return df.merge(self.gdp, on = ['country','year'], how = 'left')

    def feature_engineering(self, df):
        df['log_gdp'] = np.log(df.gdp)
        days_cats = [df.day_of_week < 4, df.day_of_week == 4, df.day_of_week > 4]
        days = ['week','friday','weekends']
        df['week'] = np.select(days_cats, days)
        return df
    
    def __call__(self, path, name = 'Train'):
        self.import_data(path)
        self.gdp = self.get_gdp(self.gdp_path).set_index('year')
        out = (self.df.pipe(self.create_date_features)
                    .pipe(self.join_gdp)
                    .pipe(self.feature_engineering)
                    .pipe(self.add_country_holidays)
                    .pipe(self.get_specific_dates_features))
        out.index = self.index
        print(f'{name} Set created with {out.shape[1]} features')
        return out

Preprocesamiento

Dado que se utilizan dos modelos distintos, con distintas caractertísticas, los preprocesamientos se hacen de manera distinta. De hecho las variables de Fourier se utilizan en 4 componentes y dos en el Boosting. Además se utilizó OrdinalEncoder en el modelo Boosting, mientras que en el modelo Lineal se usa OneHotEncoding y StandardScaler.

from feature_engine.creation import CombineWithReferenceFeature
from feature_engine.encoding import OneHotEncoder, OrdinalEncoder
from feature_engine.imputation import CategoricalImputer
from feature_engine.selection import DropFeatures
from feature_engine.wrappers import SklearnTransformerWrapper
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler

from utils import CyclicalTransformerV2
preprocess_dict = {}

scaler = SklearnTransformerWrapper(StandardScaler())
cyc_1 = CyclicalTransformerV2(variables = ['day_of_year'], 
                            max_values = {'day_of_year':365}, 
                            suffix = '_1')
cyc_2 = CyclicalTransformerV2(variables = ['day_of_year'], 
                            max_values = {'day_of_year':365/2,}, 
                            suffix = '_2')
cyc_3 = CyclicalTransformerV2(variables = ['day_of_week'], 
                            max_values = {'day_of_week':7,}, 
                            suffix = '_week')
cyc_4 = CyclicalTransformerV2(variables = ['day_of_week'], 
                            max_values = {'day_of_week':7/2,}, 
                            suffix = '_semiweek')

prep = Pipeline(steps = [
    ('cat_imp', CategoricalImputer()),
    ('ohe', OneHotEncoder(drop_last=True)),
    ('cyc1', cyc_1),
    ('cyc2', cyc_2),
    ('cyc3', cyc_3),
    ('cyc4', cyc_4),
    ('combo', CombineWithReferenceFeature(
        variables_to_combine=['day_of_year_sin_1', 'day_of_year_cos_1',
                           'day_of_year_sin_2', 'day_of_year_cos_2'], 
        reference_variables=['product_Kaggle Mug', 'product_Kaggle Hat'],
        operations = ['mul'])),
    ('combo_week', CombineWithReferenceFeature(
        variables_to_combine=['day_of_week_sin_week', 'day_of_week_cos_week',
        'day_of_week_sin_semiweek', 'day_of_week_cos_semiweek'], 
        reference_variables=['product_Kaggle Mug', 'product_Kaggle Hat'],
        operations = ['mul'])),
    ('drop', DropFeatures(features_to_drop=['year'],
    )),
    ('sc', scaler)
    ])

preprocess_dict['linear_v1'] = prep


bcyc_1 = CyclicalTransformerV2(variables = ['day_of_year'], 
                            max_values = {'day_of_year':365}, 
                            suffix = '_1')
bcyc_2 = CyclicalTransformerV2(variables = ['day_of_year'], 
                            max_values = {'day_of_year':365/2,}, 
                            suffix = '_2')

prep = Pipeline(steps = [
    ('cat_imp', CategoricalImputer()),
    ('oe', OrdinalEncoder()),
    ('cyc1', bcyc_1),
    ('cyc2', bcyc_2),
    ('drop', DropFeatures(features_to_drop=['year']
    )),
])

preprocess_dict['boosting_v1'] = prep

Proceso de Entrenamiento

Esta función de entrenamiento tomará X e y para ser entrenados en un modelo utilizando una estrategia de CV definida. Además esta función devolverá los valores de Entrenamiento, Validación y Test para ser usados eventualmente en un stacking.

Un detalle que puede llamar la atención en esta implementación es que nunca entrené el modelo en toda la data. La predicción en test se da como el promedio de las predicciones en test para cada fold.

Esta estrategia la aprendí en un modelo de Abishek Thakur y últimamente no me ha dado TAN buenos resultados por lo que para futuras competencias haré un refit en toda la data.

SPOILER: Todos los stacking terminaron super sobreajustados por lo que no los envié dentro de mi predicción final.

def cv_trainer(model, X,y, X_test = None, folds = None):
    
    S_train = pd.DataFrame(np.nan, index = range(len(X)), columns = ['fold','num_sold'])
    S_test = {}
    
    scores = dict(train = [],
                val = [])
    
    for fold, (train_idx, val_idx) in enumerate(folds):
        
        X_train, y_train = X.iloc[train_idx],y.iloc[train_idx]
        X_val, y_val = X.iloc[val_idx],y.iloc[val_idx]
        
        id_train = X_train.index
        id_val = X_val.index
        id_test = X_test.index
        
        model.fit(X_train, y_train)
        y_pred_train = pd.Series(model.predict(X_train), index = id_train, name = 'num_sold')
        y_pred_val = pd.Series(model.predict(X_val), index = id_val, name = 'num_sold')
        y_pred_test = pd.Series(model.predict(X_test), index = id_test, name = 'num_sold')
        
        S_train.loc[val_idx, 'num_sold'] = y_pred_val
        S_train.loc[val_idx, 'fold'] = fold
        
        scores['train'].append(smape(y_train, y_pred_train))
        scores['val'].append(smape(y_val, y_pred_val))
        
        S_test[fold] = y_pred_test
        
    S_test = pd.DataFrame(S_test).mean(axis = 1).to_frame()
    S_test.columns = ['num_sold']
    
    return scores, S_train, S_test

Modelo Híbrido

Para poder implementar el modelo Híbrido cree una clase de Scikit-Learn de la siguiente forma:

class HybridModel(BaseEstimator, RegressorMixin):
    def __init__(self, l_model, b_model):
        self.l_model = l_model
        self.b_model = b_model
        
    def fit(self, X, y):
        
        log_y = np.log(y)
        self.l_model.fit(X, log_y)
        linear_pred = np.exp(self.l_model.predict(X))
        y_resid = y - linear_pred
        
        self.b_model.fit(X, y_resid)
        
        self.is_fitted_ = True
        return self
    
    def predict(self, X):
        check_is_fitted(self, 'is_fitted_')
        l_predict = np.exp(self.l_model.predict(X))
        b_predict = self.b_model.predict(X)
        
        y_predict = l_predict + b_predict
        return y_predict

Esta clase hereda de BaseEstimator y RegressorMixin y va a tomar X, lo va introducir al pipeline del modelo lineal y lo va entrenar con el \(log(y)\). Posteriormente va a calcular el residual (es decir, el valor real menos la predicción) y va a entrenar un modelo boosting en este residual.

Finalmente la predicción va a ser la suma de la predicción en el modelo lineal y el modelo Boosting.

Código Principal

import os

import numpy as np
import pandas as pd
from sklearn.linear_model import HuberRegressor, Ridge, LinearRegression
from sklearn.model_selection import GroupKFold
from sklearn.pipeline import Pipeline
from lightgbm import LGBMRegressor

import data as d
from models import HybridModel
from preprocessing import preprocess_dict
from utils import Preprocess, cv_trainer
GDP_PATH = '../inputs/GDP_data_2015_to_2019_Finland_Norway_Sweden.csv'
TRAIN_PATH = '../inputs/train.csv'
TEST_PATH = '../inputs/test.csv'
feature_engineer = Preprocess(GDP_PATH, d.EASTER_DICT, d.SPECIAL_DAYS_DICT, d.COUNTRY_HOLIDAYS_DICT)
train_df = feature_engineer(TRAIN_PATH)
test_df = feature_engineer(TEST_PATH, name = 'Test')
X = train_df.drop(columns = ['gdp','date','num_sold'])
y = train_df.num_sold
X_test = test_df.drop(columns = ['gdp','date'])

ridge = Pipeline(steps = [
    ('prep', preprocess_dict['linear_v1']),
    ('model', LinearRegression())
])

xgb = Pipeline(steps = [
    ('prep', preprocess_dict['boosting_v1']),
    ('model', LGBMRegressor())
])

model = HybridModel(ridge, xgb)
folds = GroupKFold(n_splits=4).split(X = train_df, groups=train_df.year) # not sure if it's ok
scores, S_train, S_test = cv_trainer(model, X,y, X_test, folds)
model_name = '_'.join([type(model).__name__, type(model.l_model.named_steps.model).__name__, type(model.b_model.named_steps.model).__name__])
print(f'Training results for {model_name}')
print(scores)
print('Mean Training Score: ', np.mean(scores['train']))
print('Mean Validaton Score: ', np.mean(scores['val']))

S_test.to_csv(f'../submissions/submission_{model_name}_v2.csv')

def save_predictions(preds, model_name, mode = 'train'):
    path = f'../submissions/Stacking_{mode}.csv'
    if os.path.exists(path):
        input = pd.read_csv(path, index_col=0)
        input[f'{model_name}'] = preds['num_sold'].sort_index()
        input.to_csv(path)
    else:
        out = preds['num_sold']
        out.name = model_name
        out.to_csv(path)

save_predictions(S_train, model_name)
save_predictions(S_test, model_name, mode = 'test')

Lecciones Aprendidas

  • Quien diga que Kaggle no sirve para la vida real no entiende nada. Creo que confirmo esto. Una de las grandes lecciones es que es muy fácil overfittear. Y Kaggle te invita a hacerlo. El Public Leaderboard es sólo una tentación a generar overfitting (una prueba de esto es que el número 1 en el Public Leaderboard terminó 322). Creo que todos los grandes Kagglers lo dicen, pero hay que confiar en tu estrategia de validación Local, y es dificil pero funciona.

En mi caso terminé 118 (Top 8%, lo cual hubiera sido bronce en una competencia real) y subí 184 puestos, lo cual me deja tranquilo de que no overfitié.

png

  • Mi modelo ganador no utilizó Stacking. Esto igual es interesante, porque hasta ahora el stacking sólo generó overfitting. Puede ser porque no lo implementé de manera correcta en series de tiempo, pero es algo en lo que tengo que trabajar.

  • No hay que creerle a un Kernel sólo porque tiene muchos votos. Varias veces me ví tentado a copiar algo en lo que no estaba de acuerdo, por ejemplo, StandardScaler antes del split, o usando KFold con fechas. Afortunadamente me quedo tranquilo que a pesar de que esos Kernels tenían buen puntaje público terminaron bien bajos en el privado 😈.

  • Una cosa muy desmotivante en Kaggle es que uno se acuesta en un buen puesto y al otro día 500 personas te pasaron porque copiaron y pegaron un Kernel.

png

En un momento estuve en el top 10!!! 😱😱😱😱😱😱 Y luego llegó la triste realidad😞.

  • La fase de feature engineering fue clave. El dataset tal como estaba era casi inservible. De las variables finales que utilicé, el 95% fueron creadas. Por lo tanto sostengo que esta es por lejos la parte más importante al momento de modelar.

  • Los modelos lineales son bakanes. Son más complicados de manejar, requieren mucho más expertise y crear features muy customizadas, pero creo que obligan al modelador a dedicarle mucho tiempo. Quizás esa es la razón porque los modelos boosting son tan populares. No sólo son muy poderosos sino que también no es necesario dedicarle tanto tiempo.

  • Tengo que dedicar tiempo al HPO. Nuevamente no alcancé a optimizar hiperparámetros, y esto podría haber reducido un par de puestos adicionales. Como dije esta competencia era puro evitar el overfitting, y estoy seguro que varios de mis modelos no alcanzaron a llegar a su óptimo.

  • Después de competir con tanta gente “Novata” tan buena, creo que jamás tendría la desfachatez de autoproclamarme experto en Machine Learning. Una de las cosas que aprendí es que sé menos de lo que creo y tengo un largo camino de aprendizaje.

  • Hay que atreverse a la competencia real. Tenía la convicción de que para poder estar en Kaggle hay que dedicar demasiado tiempo. Pero creo que si uno es inteligente y genera buenos scripts ordenados que uno puede eventualmente entrenar durante la noche y analizar durante el día, competir es completamente manejable con la vida. Así que me voy a ir con todo a Ubiquant. Otra competencia de Series de Tiempo, donde espero poder implementar harto de lo aprendido durante TPS (Y que ya les cuento que es un cacho porque la data es gigante).

Por implementar

Hay varias cosas que sin duda tengo que mejorar para las siguientes competencias, las cuales describo acá:

  • Sistemas automatizado de feature engineering y feature selection. Normalmente entre más y mejores features mejor, pero esto es difícil y super desgastante.

  • Ser ordenado y loguear con W&B. Me dio como latita esto, y creo que es super importante poder reproducir cada uno de los experimentos. Perdí varios experimentos que pudieron ser útiles para algún stacking por no ser ordenado. No sé por qué pero siento que tengo que seguir trabajando en esto.

  • Error Analysis. Actualmente no sé nada de esto, no lo tengo implementado y no sé como afrontarlo en mi framework de modelamiento. Tengo que ver qué hacer respecto a esto. Una librería que me gustó es deepcheck la cual voy a estar revisando para ver cómo me ayuda.

Espero este artículo sea de ayuda. Tengo otros artículos en la puerta del horno que no he podido terminar porque no tengo mucho tiempo. Espero tener pronto el tiempo para terminarlos.

El código de todo el proceso se puede encontrar en mi Github acá.

Hasta la otra,

Alfonso

Go to top