Bootcamp Data Science y Python - Kaggle

Entrenamiento de un modelo de ML

Clasificaión binaria
Índice de contenidos

Esta entrada tiene como objetivo documentar la solución del proyecto final del Bootcamp de Data Science y Python. Este proyecto está basado en una competición de Kaggle organizada por Microsoft en 2019 en la que se repartieron hasta 20000$ en premios. El objetivo de esta competición es predecir si una máquina va a sufrir malware o no a partir de 2 datasets, uno de entrenamiento, con label, de 892148 registros y otro de test, sin label, de 321173 registros.

En este proyecto se valora incluso más que los resultados la forma de llegar a ellos, es decir, el código debe ejecutar todas las transformaciones de forma automatizada y debe estar preparado para solventar futuribles problemas como, por ejemplo, cambios en los nombres de los campos, nuevas columnas añadidas, nuevos tipos de datos…

Planteamiento del problema

El objetivo de esta competición es predecir la probabilidad de que una máquina Windows se infecte por varias familias de malware, basándose en diferentes propiedades de esa máquina. Los datos telemétricos que contienen estas propiedades y las infecciones de las máquinas se generaron combinando informes de latidos e informes de amenazas recopilados por el programa de seguridad antivirus Windows Defender.

Cada fila de este conjunto de datos corresponde a una máquina, identificada por un MachineIdentifier. HasDetections es el label que indica si se ha detectado malware en la máquina. Utilizando la información y las etiquetas de train.csv, debe predecir el valor de HasDetections para cada máquina de test.csv.

La metodología de muestreo utilizada para crear este conjunto de datos fue diseñada para cumplir con ciertas restricciones de negocio, tanto en lo que respecta a la privacidad del usuario, así como el período de tiempo durante el cual la máquina estaba funcionando. La detección de malware es inherentemente un problema de series temporales, pero se complica con la introducción de nuevas máquinas, máquinas que se conectan y desconectan, máquinas que reciben parches, máquinas que reciben nuevos sistemas operativos, etc. Aunque el conjunto de datos proporcionado aquí se ha dividido aproximadamente en el tiempo, las complicaciones y los requisitos de muestreo mencionados anteriormente pueden significar que se observe una concordancia imperfecta entre las puntuaciones de validación cruzada, públicas y privadas.

Unavailable or self-documenting column names are marked with an «NA».

  • MachineIdentifier: Individual machine ID

  • ProductName: Defender state information e.g. win8defender

  • EngineVersion: Defender state information e.g. 1.1.12603.0

  • AppVersion: Defender state information e.g. 4.9.10586.0

  • AvSigVersion: Defender state information e.g. 1.217.1014.0

  • IsBeta: Defender state information e.g. false

  • RtpStateBitfield: NA

  • IsSxsPassiveMode: NA

  • DefaultBrowsersIdentifier: ID for the machine’s default browser

  • AVProductStatesIdentifier: ID for the specific configuration of a user’s antivirus software

  • AVProductsInstalled: NA

  • AVProductsEnabled: NA

  • HasTpm: True if machine has TPM

  • CountryIdentifier: ID for the country the machine is located in

  • CityIdentifier: ID for the city the machine is located in

  • OrganizationIdentifier: ID for the organization the machine belongs in, organization ID is mapped to both specific companies and broad industries

  • GeoNameIdentifier: ID for the geographic region a machine is located in

  • LocaleEnglishNameIdentifier: English name of Locale ID of the current user

  • Platform: Calculates platform name (of OS related properties and processor property)

  • Processor: This is the process architecture of the installed operating system

  • OsVer: Version of the current operating system

  • OsBuild: Build of the current operating system

  • OsSuite: Product suite mask for the current operating system.

  • OsPlatformSubRelease: Returns the OS Platform sub-release (Windows Vista, Windows 7, Windows 8, TH1, TH2)

  • OsBuildLab: Build lab that generated the current OS. Example: 9600.17630.amd64fre.winblue_r7.150109-2022

  • SkuEdition: The goal of this feature is to use the Product Type defined in the MSDN to map to a ‘SKU-Edition’ name that is useful in population reporting. The valid Product Type are defined in %sdxroot%\data\windowseditions.xml. This API has been used since Vista and Server 2008, so there are many Product Types that do not apply to Windows 10. The ‘SKU-Edition’ is a string value that is in one of three classes of results. The design must hand each class.

  • IsProtected: This is a calculated field derived from the Spynet Report’s AV Products field. Returns: a. TRUE if there is at least one active and up-to-date antivirus product running on this machine. b. FALSE if there is no active AV product on this machine, or if the AV is active, but is not receiving the latest updates. c. null if there are no Anti Virus Products in the report. Returns: Whether a machine is protected.

  • AutoSampleOptIn: This is the SubmitSamplesConsent value passed in from the service, available on CAMP 9+

  • PuaMode: Pua Enabled mode from the service

  • SMode: This field is set to true when the device is known to be in ‘S Mode’, as in, Windows 10 S mode, where only Microsoft Store apps can be installed

  • IeVerIdentifier: NA

  • SmartScreen: This is the SmartScreen enabled string value from the registry. This is obtained by checking in order, HKLM\SOFTWARE\Policies\Microsoft\Windows\System\SmartScreenEnabled and HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\SmartScreenEnabled. If the value exists but is blank, the value «ExistsNotSet» is sent in telemetry.

  • Firewall: This attribute is true (1) for Windows 8.1 and above if the Windows firewall is enabled, as reported by the service.

  • UacLuaenable: This attribute reports whether or not the «administrator in Admin Approval Mode» user type is disabled or enabled in UAC. The value reported is obtained by reading the regkey HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System\EnableLUA.

  • Census_MDC2FormFactor: A grouping based on a combination of Device Census level hardware characteristics. The logic used to define Form Factor is rooted in business and industry standards and aligns with how people think about their devices. (Examples: Smartphone, Small Tablet, All in One, Convertible…)

  • Census_DeviceFamily: AKA DeviceClass. Indicates the type of device that an edition of the OS is intended for. Example values: Windows.Desktop, Windows.Mobile, and iOS.Phone

  • Census_OEMNameIdentifier: NA

  • Census_OEMModelIdentifier: NA

  • Census_ProcessorCoreCount: Number of logical cores in the processor

  • Census_ProcessorManufacturerIdentifier: NA

  • Census_ProcessorModelIdentifier: NA

  • Census_ProcessorClass: A classification of processors into high/medium/low. Initially used for Pricing Level SKU. No longer maintained and updated

  • Census_PrimaryDiskTotalCapacity: Amount of disk space on the primary disk of the machine in MB

  • Census_PrimaryDiskTypeName: Friendly name of Primary Disk Type – HDD or SSD

  • Census_SystemVolumeTotalCapacity: The size of the partition that the System volume is installed on in MB

  • Census_HasOpticalDiskDrive: True indicates that the machine has an optical disk drive (CD/DVD)

  • Census_TotalPhysicalRAM: Retrieves the physical RAM in MB

  • Census_ChassisTypeName: Retrieves a numeric representation of what type of chassis the machine has. A value of 0 means xx

  • Census_InternalPrimaryDiagonalDisplaySizeInInches: Retrieves the physical diagonal length in inches of the primary display

  • Census_InternalPrimaryDisplayResolutionHorizontal: Retrieves the number of pixels in the horizontal direction of the internal display.

  • Census_InternalPrimaryDisplayResolutionVertical: Retrieves the number of pixels in the vertical direction of the internal display

  • Census_PowerPlatformRoleName: Indicates the OEM preferred power management profile. This value helps identify the basic form factor of the device

  • Census_InternalBatteryType: NA

  • Census_InternalBatteryNumberOfCharges: NA -Census_OSVersion – Numeric OS version Example- 10.0.10130.0

  • Census_OSArchitecture: Architecture on which the OS is based. Derived from OSVersionFull. Example – amd64

  • Census_OSBranch: Branch of the OS extracted from the OsVersionFull. Example- OsBranch = fbl_partner_eeap where OsVersion = 6.4.9813.0.amd64fre.fbl_partner_eeap.140810-0005

  • Census_OSBuildNumber: OS Build number extracted from the OsVersionFull. Example – OsBuildNumber = 10512 or 10240

  • Census_OSBuildRevision: OS Build revision extracted from the OsVersionFull. Example- OsBuildRevision = 1000 or 16458

  • Census_OSEdition: Edition of the current OS. Sourced from HKLM\Software\Microsoft\Windows NT\CurrentVersion@EditionID in the registry. Example: Enterprise

  • Census_OSSkuName: OS edition friendly name (currently Windows only)

  • Census_OSInstallTypeName: Friendly description of what install was used on the machine i.e. clean

  • Census_OSInstallLanguageIdentifier: NA

  • Census_OSUILocaleIdentifier: NA

  • Census_OSWUAutoUpdateOptionsName: Friendly name of the WindowsUpdate auto-update settings on the machine.

  • Census_IsPortableOperatingSystem: Indicates whether the OS is booted up and running via Windows-To-Go on a USB stick.

  • Census_GenuineStateName: Friendly name of OSGenuineStateID. 0 = Genuine

  • Census_ActivationChannel: Retail license key or Volume license key for a machine.

  • Census_IsFlightingInternal: NA

  • Census_IsFlightsDisabled: Indicates if the machine is participating in flighting.

  • Census_FlightRing: The ring that the device user would like to receive flights for. This might be different from the ring of the OS, which is currently installed if the user changes the ring after getting a flight from a different ring.

  • Census_ThresholdOptIn: NA

  • Census_FirmwareManufacturerIdentifier: NA

  • Census_FirmwareVersionIdentifier: NA

  • Census_IsSecureBootEnabled: Indicates if Secure Boot mode is enabled.

  • Census_IsWIMBootEnabled: NA

  • Census_IsVirtualDevice: Identifies a Virtual Machine (machine learning model)

  • Census_IsTouchEnabled: Is this a touch device?

  • Census_IsPenCapable: Is the device capable of pen input?

  • Census_IsAlwaysOnAlwaysConnectedCapable: Retrieves information about whether the battery enables the device to be AlwaysOnAlwaysConnected.

  • Wdft_IsGamer: Indicates whether the device is a gamer device or not based on its hardware combination.

  • Wdft_RegionIdentifier: NA

Organización del código

Teniendo claro ya cuál es el problema que debemos resolver y cómo son los datos con los que vamos a trabajar, podemos empezar a profundizar en la solución.

Este proyecto está dividido en 3 cuadernos .ipynb:

  • Notebook de pruebas: Es el cuaderno de desarrollo, en él se prueban las nuevas transformaciones que queremos aplicar al modelo, se comprueban las métricas y se realiza el ajuste de hiperparámetros y la selección del modelo.

  • Notebook de entrenamiento: Es uno de los 2 cuadernos de puesta en producción, en este cuaderno no se comprueban los resultados, su única función es entrenar el modelo con los nuevos datos que vayan llegando y guardar todas las transformaciones aplicadas para implementarlas en el cuaderno de inferencia.

  • Notebook de inferencia: Es el otro cuaderno de puesta en producción, su función es predecir resultados. Lee los datos de entrada en un Dataframe de pandas, le aplica las transformaciones pertinentes y devuelve las predicciones.

A continuación adjuntaré cada uno de los 3 notebooks con todas las celdas de código documentadas en el propio notebook y con algunos de los outputs relevantes. 

Notebook de pruebas (Desarrollo)

Importar librerías

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

from sklearn.model_selection import train_test_split
from sklearn.impute import SimpleImputer
from sklearn.preprocessing import StandardScaler
import category_encoders as ce
from sklearn.preprocessing import OneHotEncoder

import lightgbm as lgb
import xgboost as xgb
from sklearn.metrics import accuracy_score, confusion_matrix, auc, roc_curve, classification_report
# from sklearn.feature_selection import RFECV
# from sklearn.model_selection import GridSearchCV, RandomizedSearchCV

warnings.filterwarnings('once')

Carga de datos

df = pd.read_csv('Data/MasterBI_Train.csv', index_col='MachineIdentifier')

EDA

Selección de variables

Variables máscara

Si una variable tiene más de 2 ‘.’ en sus valores, la guardo en un diccionario junto a el número de variables que se formarán a partir de separar por el delimitador.
to_search_mask = df.select_dtypes(include=['object', 'category'])
mask_cols = {}
for col in to_search_mask:
    try:
        if len(df[col].str.split('.')[0]) > 2:
            mask_cols[col] = len(df[col].str.split('.')[0])
    except:
        pass

print(mask_cols)
Genero nuevas variables a partir de la variable máscara original
for col, splits_num in mask_cols.items():
    col_names = []
    for i in range(1, splits_num+1):
        col_names.append(f'{col}_{str(i)}')
    df[col_names] = df[col].str.split('.', expand=True)

df.drop(columns=mask_cols.keys(), inplace=True)

Elimino variables que no aportan valor

  • Columnas con un único valor
  • Columnas con el más del 95% de registros con un mismo valor
to_drop = [col for col in df.columns if df[col].nunique() == 1]

for col in df.columns:
    rate = df[col].value_counts(normalize=True, dropna=False).values[0]
    if rate > 0.95:
        if col not in to_drop:
            to_drop.append(col)

df.drop(columns=to_drop, inplace=True)

Variables booleanas

Las convierto en categóricas
bool_cols = [col for col in df.columns if set(df[col].dropna().unique()) == {0, 1}]
df[bool_cols] = df[bool_cols].astype('category')

De numéricas a categóricas

De las columnas numéricas, las que no superen un umbral de valores únicos las convierto en categóricas
umbral_valores_unicos = 250
numerics = ['int8', 'int16', 'int32', 'int64', 'float16', 'float32', 'float64']

num_cols = df.select_dtypes(include=numerics)
num_to_categorical = [col for col in num_cols.columns if num_cols[col].nunique() < umbral_valores_unicos]

df[num_to_categorical] = df[num_to_categorical].astype('category')
Tras investigar cada una de las variables numéricas detecto que algunas de ellas deberían ser categóricas pero tras unas cuantas iteraciones veo que al tiparlas como categóricas el AUC del modelo baja considerablemente y, por lo tanto, de momento las dejo como numéricas.

Esta es la celda de código alternativa, con las falsas columnas numéricas tipadas como categóricas:
Atención: Esta transformación reduce considerablemente el AUC de las predicciones y, por lo tanto, no se utiliza de momento. Posible implementación en un futuro
# num_to_categorical = ['AVProductStatesIdentifier',
#     'AVProductsInstalled',
#     'CountryIdentifier',
#     'CityIdentifier',
#     'OrganizationIdentifier',
#     'GeoNameIdentifier',
#     'LocaleEnglishNameIdentifier',
#     'OsBuild',
#     'OsSuite',
#     'IeVerIdentifier',
#     'Census_OEMNameIdentifier',
#     'Census_OEMModelIdentifier',
#     'Census_ProcessorManufacturerIdentifier',
#     'Census_ProcessorModelIdentifier',
#     'Census_OSBuildNumber',
#     'Census_OSBuildRevision',
#     'Census_OSInstallLanguageIdentifier',
#     'Census_OSUILocaleIdentifier',
#     'Census_FirmwareManufacturerIdentifier',
#     'Census_FirmwareVersionIdentifier',
#     'Wdft_RegionIdentifier']

# df[num_to_categorical] = df[num_to_categorical].astype('category')

Variables 'object' como categóricas

Convierto a categóricas las variables tipadas automáticamente como ‘object’
object_cols = df.select_dtypes(include='object').columns
df[object_cols] = df[object_cols].astype('category')

Train/Test split

Separo el dataset de entrenamiento original en 2 subconjuntos de datos, uno de entrenamiento y otro de validación. Hago la separación antes de aplicar las transformaciones para que los resultados de las predicciones sean más realistas.
features = df.drop(columns='HasDetections')
label = df['HasDetections'].astype('bool')
train_features, test_features, train_label, test_label = train_test_split(
    features,
    label,
    random_state=1)

Elimino variables con demasiados nulos (+80%)

threshold = 0.8 # Porcentaje de nulos a eliminar

porcentaje_nulos = train_features.isnull().mean()
variables_con_demasiados_nulos = porcentaje_nulos[porcentaje_nulos > 0.8].index
print(f'Columnas con más del 80% de valores nulos (Eliminadas): {variables_con_demasiados_nulos.tolist()}')

# Elimino las variables con más del 80% de nulos en el conjunto de datos de entrenamiento y las elimino tanto en el conjunto de datos de entrenamiento,
# como en el de validación.
train_features.drop(columns=variables_con_demasiados_nulos, inplace=True)
test_features.drop(columns=variables_con_demasiados_nulos, inplace=True)

to_drop.extend(variables_con_demasiados_nulos.tolist())

Correlación entre variables

Creo una lista con las variables categóricas y otra con las numéricas

cat_cols = list(train_features.select_dtypes(include=['object', 'category']).columns)
num_cols = list(train_features.select_dtypes(include=['int8', 'int16', 'int32', 'int64', 'float16', 'float32', 'float64']).columns)

Pinto la matriz de correlación inicial

matriz_correlacion = train_features[num_cols].corr()
mascara_superior = np.triu(np.ones(matriz_correlacion.shape), k=1).astype(bool)

plt.figure(figsize=(12, 10))
sns.heatmap(matriz_correlacion, annot=True, cmap='coolwarm', fmt=".2f", linewidths=.5, cbar_kws={"shrink": 0.8}, mask=mascara_superior)
plt.show()
Si hay alguna variable con más del 90% de correlación con otra, la elimino. Luego vuelvo a calcular la matriz de correlación:
  • Si hay alguna más vuelvo a hacer lo mismo
  • Si no hay más, no hago nada más

Independientemente de que haya más de 1 variable demasiado correlacionada, sólamente elimino 1 y vuelvo a calcular la correlación.

while True:
    matriz_correlacion = train_features[num_cols].corr().abs()
    upper_tri = matriz_correlacion.where(np.triu(np.ones(matriz_correlacion.shape), k=1).astype(bool))
    col_to_drop = [column for column in upper_tri.columns if any(upper_tri[column] > 0.90)]
    if len(col_to_drop) > 0:
        train_features.drop(columns=col_to_drop[0], inplace=True)
        test_features.drop(columns=col_to_drop[0], inplace=True)
        num_cols.remove(col_to_drop[0])
        print("Columna con más del 90% de correlación, eliminada:", col_to_drop[0])
    else:
        break
Vuelvo a pintar la matriz de correlación
matriz_correlacion = train_features[num_cols].corr()
mascara_superior = np.triu(np.ones(matriz_correlacion.shape), k=1).astype(bool)

plt.figure(figsize=(12, 10))
sns.heatmap(matriz_correlacion, annot=True, cmap='coolwarm', fmt=".2f", linewidths=.5, cbar_kws={"shrink": 0.8}, mask=mascara_superior)
plt.show()

Imputación de nulos

Imputo las variables numéricas por la media
imp_num = SimpleImputer(missing_values=np.nan, strategy='mean')

# Ajusto la imputación con el conjunto de datos de entrenamiento
imp_num.fit(train_features[num_cols])

# Aplico la imputación tanto al conjunto de datos de entrenamiento como al conjunto de datos de validación
train_features[num_cols] = imp_num.transform(train_features[num_cols])
test_features[num_cols] = imp_num.transform(test_features[num_cols])
Imputación de nulos en variables categóricas:
  • Si alguna de sus categorías es ‘UNKNOWN’, ‘UNK’ o ‘Unknown’, sustituyo los nulos por ese valor
  • Si no, imputo nulos por la moda
unk_list = ['UNKNOWN', 'UNK', 'Unknown']
imp_categoric_unk = {}

for unk in unk_list:
    for col in cat_cols:
        if unk in train_features[col].cat.categories:
            train_features[col].fillna(unk, inplace=True)
            test_features[col].fillna(unk, inplace=True)
            imp_categoric_unk[col] = unk
imp_cat = SimpleImputer(missing_values=np.nan, strategy='most_frequent')

# Ajusto la imputación con el conjunto de datos de entrenamiento
imp_cat.fit(train_features[cat_cols])

# Aplico la imputación tanto al conjunto de datos de entrenamiento como al conjunto de datos de validación
train_features[cat_cols] = imp_cat.transform(train_features[cat_cols])
test_features[cat_cols] = imp_cat.transform(test_features[cat_cols])

train_features[cat_cols] = train_features[cat_cols].astype('category')
test_features[cat_cols] = test_features[cat_cols].astype('category')

Imputación de outliers

Calculo el rango intercuartílico de cada una de las variables numéricas y defino un umbral para identificar los valores atípicos.

Los valores inferiores al límite superior los reemplazo por ese mismo límite y los valores mayores al límite superior, los reemplazo por el límite superior.
for col in num_cols:
    Q1 = train_features[col].quantile(0.25)
    Q3 = train_features[col].quantile(0.75)
    umbral_outliers = 1.5

    IQR = Q3 - Q1
    limite_inferior = Q1 - umbral_outliers * IQR
    limite_superior = Q3 + umbral_outliers * IQR

    train_features[col] = np.clip(train_features[col], limite_inferior, limite_superior)
    test_features[col] = np.clip(test_features[col], limite_inferior, limite_superior)

Codificación de variables

Variables numéricas

Estandarización de las variables numéricas utilizando StandardScaler()
scaler = StandardScaler()

scaler.fit(train_features[num_cols])

train_features[num_cols] = scaler.transform(train_features[num_cols])
test_features[num_cols] = scaler.transform(test_features[num_cols])

Variables categóricas

  • OneHotEncoding para las variables categóricas de 5 o menos valores distintos
  • TargetEncoding para variables categóricas con más de 5 valores únicos
umbral_valores_unicos = 5
cols_to_TargetEncoder = [col for col in cat_cols if train_features[col].nunique() > umbral_valores_unicos]
cols_to_OneHotEncoder = [col for col in cat_cols if train_features[col].nunique() <= umbral_valores_unicos]

Target Encoding:

te = ce.TargetEncoder()
te.fit(train_features[cols_to_TargetEncoder], train_label)

train_features[cols_to_TargetEncoder] = te.transform(train_features[cols_to_TargetEncoder])
test_features[cols_to_TargetEncoder] = te.transform(test_features[cols_to_TargetEncoder])

One Hot Encoding:

oe = OneHotEncoder(sparse=False, handle_unknown='ignore')

# Entreno OneHotEncoder con el dataset de entrenamiento
oe.fit(train_features[cols_to_OneHotEncoder])
col_names = oe.get_feature_names_out(cols_to_OneHotEncoder)

# Aplico OneHotEncoder al dataset de entrenamiento
train_features_oe = oe.transform(train_features[cols_to_OneHotEncoder])
train_features[col_names] = train_features_oe

# Aplico OneHotEncoder al dataset de validación
test_features_oe = oe.transform(test_features[cols_to_OneHotEncoder])
test_features[col_names] = test_features_oe

# Elimino las columnas originales
train_features.drop(columns=cols_to_OneHotEncoder, inplace=True)
test_features.drop(columns=cols_to_OneHotEncoder, inplace=True)

RFE

# rfe_vars = []
# LGB_best_hyperparameters = {'reg_lambda': 0.4, 'reg_alpha': 0.4, 'num_leaves': 200, 'n_estimators': 200, 'min_child_samples': 4, 'max_depth': 10, 'learning_rate': 0.1, 'bagging_fraction': 0.5, 'random_state': 1}
# rfe = RFECV(estimator=lgb.LGBMClassifier(**LGB_best_hyperparameters), min_features_to_select=5, cv=5, scoring='roc_auc')
# rfe.fit(train_features, train_label)

# for i, col in enumerate(train_features.columns):
#     if rfe.support_[i]:
#         rfe_vars.append(col)

Selección del modelo

LightGBM

Búsqueda de hiperparámetros

# params = {'n_estimators': [100, 150, 200, 250, 300],
#     'feature_fraction': [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9],
#     'bagging_fraction': [0.5 , 0.55, 0.6 , 0.65, 0.7 , 0.75, 0.8 , 0.85],
#     'num_leaves': [200, 250, 300, 400],
#     'learning_rate': [0.01, 0.06, 0.11, 0.16, 0.21, 0.26, 0.31, 0.36],
#     'max_depth': [5, 10, 20, 30, 40],
#     'min_child_samples': [2, 3, 4, 5, 6],
#     'reg_alpha': [0.2, 0.4, 0.6, 0.8],
#     'reg_lambda': [0.2, 0.4, 0.6, 0.8],
#     'colsample_bytree': [0.2, 0.4, 0.6, 0.8]}

# random_search = RandomizedSearchCV(estimator=lgb.LGBMClassifier(), param_distributions=params, n_iter=100,
#     scoring='roc_auc', cv=3, random_state=1)

# random_search.fit(train_features, train_label)

Definición y entrenamiento del modelo

LGB_best_hyperparameters = {'reg_lambda': 0.4, 'reg_alpha': 0.4, 'num_leaves': 200, 'n_estimators': 200, 'min_child_samples': 4, 'max_depth': 10, 'learning_rate': 0.1, 'bagging_fraction': 0.5, 'random_state': 1}

LGB_model = lgb.LGBMClassifier(**LGB_best_hyperparameters)
LGB_model.fit(train_features, train_label)
LGB_pred_proba = LGB_model.predict_proba(test_features)
LGB_predictions = [1 if x >= 0.5 else 0 for x in LGB_pred_proba[:,1]]

LGB_accuracy = accuracy_score(test_label, LGB_predictions)

fpr1, tpr1, _ = roc_curve(test_label, LGB_pred_proba[:,1])
roc_auc1 = auc(fpr1, tpr1)

print(f'AUC: {roc_auc1}%')

Importancia de variables

fig, ax = plt.subplots(figsize=(10,6))
lgb.plot_importance(LGB_model, ax=ax, max_num_features=20)

XGBoost

Definición y entrenamiento del modelo

XGB_best_hyperparametrers = {'colsample_bytree': 0.8, 'learning_rate': 0.1, 'max_depth': 10, 'n_estimators': 200, 'random_state': 1, 'subsample': 1.0}
XGB_model = xgb.XGBClassifier(**XGB_best_hyperparametrers)

XGB_model.fit(train_features, train_label)
XGB_pred_proba = XGB_model.predict_proba(test_features)
XGB_predictions = [1 if x >= 0.5 else 0 for x in XGB_pred_proba[:,1]]
XGB_accuracy = accuracy_score(test_label, XGB_predictions)

fpr2, tpr2, _ = roc_curve(test_label, XGB_pred_proba[:,1])
roc_auc2 = auc(fpr2, tpr2)

print(f'AUC: {roc_auc2}%')

Importancia de variables

fig, ax = plt.subplots(figsize=(10,6))
xgb.plot_importance(XGB_model, ax=ax, max_num_features=20)

Comparación de modelos

Métricas

for model, preds in zip(['LightGBM', 'XGBoost'], [LGB_predictions, XGB_predictions]):
    print(model)
    print(classification_report(test_label, preds, digits=3, zero_division=True))
    print('-'*80)

Matriz de confusión

rows, cols = 1, 2
sns.set(font_scale=1.4)
fig = plt.figure(figsize=(20, 4))

fig.add_subplot(1, 4, 1)
sns.heatmap(confusion_matrix(test_label, LGB_predictions), cmap="Blues", annot=True, annot_kws={"size": 12})
plt.title('LightGBM')

fig.add_subplot(1, 4, 2)
sns.heatmap(confusion_matrix(test_label, XGB_predictions), cmap="Blues", annot=True, annot_kws={"size": 12})
plt.title('XGBoost')

Curva ROC

plt.figure(figsize=(10,10))
lw = 2
plt.plot(fpr1, tpr1, color='blue',
lw=lw, label=f'LightGBM (área = {roc_auc1})')
plt.plot(fpr2, tpr2, color='red',
lw=lw, label=f'XGBoost (área = {roc_auc2})')

plt.plot([0, 1], [0, 1], color='navy', lw=lw, linestyle='--')
plt.xlim([0.0, 1.0])
plt.ylim([0.0, 1.05])
plt.xlabel('False Positive Rate')
plt.ylabel('True Positive Rate')
plt.legend(loc="lower right")
plt.show()

Notebook de entrenamiento

Importar librerías

import pandas as pd
import numpy as np
import warnings
import pickle

from sklearn.impute import SimpleImputer
from sklearn.preprocessing import StandardScaler
import category_encoders as ce
from sklearn.preprocessing import OneHotEncoder
from sklearn.model_selection import train_test_split

import lightgbm as lgb

warnings.filterwarnings('once')

Carga de datos

df = pd.read_csv('Data/MasterBI_Train.csv', index_col='MachineIdentifier')

Feature engineering

Selección de variables

Variables máscara

Si una variable tiene más de 2 ‘.’ en sus valores, la guardo en un diccionario junto a el número de variables que se formarán a partir de separar por el delimitador.
to_search_mask = df.select_dtypes(include=['object', 'category'])
mask_cols = {}
for col in to_search_mask:
    try:
        if len(df[col].str.split('.')[0]) > 2:
            mask_cols[col] = len(df[col].str.split('.')[0])
    except:
        pass
Genero nuevas variables a partir de la variable máscara original
for col, splits_num in mask_cols.items():
    col_names = []
    for i in range(1, splits_num+1):
        col_names.append(f'{col}_{str(i)}')
    df[col_names] = df[col].str.split('.', expand=True)

df.drop(columns=mask_cols.keys(), inplace=True)

Elimino variables que no aportan valor

  • Columnas con un único valor
  • Columnas con el más del 95% de registros con un mismo valor
to_drop = [col for col in df.columns if df[col].nunique() == 1]

for col in df.columns:
    rate = df[col].value_counts(normalize=True, dropna=False).values[0]
    if rate > 0.95:
        if col not in to_drop:
            to_drop.append(col)

df.drop(columns=to_drop, inplace=True)

Variables booleanas

Las convierto en categóricas
bool_cols = [col for col in df.columns if set(df[col].dropna().unique()) == {0, 1}]
df[bool_cols] = df[bool_cols].astype('category')

Falsas columnas numéricas

De las columnas numéricas, las que no superen un umbral de valores únicos las convierto en categóricas
umbral_valores_unicos = 250
numerics = ['int8', 'int16', 'int32', 'int64', 'float16', 'float32', 'float64']

num_cols = df.select_dtypes(include=numerics)
num_to_categorical = [col for col in num_cols.columns if num_cols[col].nunique() < umbral_valores_unicos]

df[num_to_categorical] = df[num_to_categorical].astype('category')

Variables 'object' como categóricas

Convierto a categóricas las variables tipadas automáticamente como ‘object’
object_cols = df.select_dtypes(include='object').columns
df[object_cols] = df[object_cols].astype('category')

Separo la variable objetivo (label) del resto de variables

features = df.drop(columns='HasDetections')
label = df['HasDetections'].astype('bool')

Elimino variables con demasiados nulos (+80%)

threshold = 0.8

porcentaje_nulos = features.isnull().mean()
variables_con_demasiados_nulos = porcentaje_nulos[porcentaje_nulos > 0.8].index

features.drop(columns=variables_con_demasiados_nulos, inplace=True)
to_drop.extend(variables_con_demasiados_nulos.tolist())

Correlación entre variables

Creo una lista con las variables categóricas y otra con las numéricas

cat_cols = list(train_features.select_dtypes(include=['object', 'category']).columns)
num_cols = list(train_features.select_dtypes(include=['int8', 'int16', 'int32', 'int64', 'float16', 'float32', 'float64']).columns)
Si hay alguna variable con más del 90% de correlación con otra, la elimino. Luego vuelvo a calcular la matriz de correlación:
  • Si hay alguna más vuelvo a hacer lo mismo
  • Si no hay más, no hago nada más

Independientemente de que haya más de 1 variable demasiado correlacionada, sólamente elimino 1 y vuelvo a calcular la correlación.

while True:
    matriz_correlacion = features[num_cols].corr().abs()
    upper_tri = matriz_correlacion.where(np.triu(np.ones(matriz_correlacion.shape),k=1).astype(bool))
    col_to_drop = [column for column in upper_tri.columns if any(upper_tri[column] > 0.90)]
    if len(col_to_drop) > 0:
        features.drop(columns=col_to_drop[0], inplace=True)
        num_cols.remove(col_to_drop[0])
        to_drop.append(col_to_drop[0])
        print("Columna con más del 90% de correlación, eliminada:", col_to_drop[0])
    else:
        break

Imputación de nulos

Imputo las variables numéricas por la media
imp_num = SimpleImputer(missing_values=np.nan, strategy='mean')

imp_num.fit(features[num_cols])
features[num_cols] = imp_num.transform(features[num_cols])
Imputación de nulos en variables categóricas:
  • Si alguna de sus categorías es ‘UNKNOWN’, ‘UNK’ o ‘Unknown’, sustituyo los nulos por ese valor
  • Si no, imputo nulos por la moda
unk_list = ['UNKNOWN', 'UNK', 'Unknown']
imp_categoric_unk = {}

for unk in unk_list:
    for col in cat_cols:
        if unk in features[col].cat.categories:
            features[col].fillna(unk, inplace=True)
            imp_categoric_unk[col] = unk
imp_cat = SimpleImputer(missing_values=np.nan, strategy='most_frequent')
imp_cat.fit(features[cat_cols])
features[cat_cols] = imp_cat.transform(features[cat_cols])

features[cat_cols] = features[cat_cols].astype('category')

Imputación de outliers

Calculo el rango intercuartílico de cada una de las variables numéricas y defino un umbral para identificar los valores atípicos.

Los valores inferiores al límite superior los reemplazo por ese mismo límite y los valores mayores al límite superior, los reemplazo por el límite superior.
# **Diccionario que almacena el nombre de la columna y sus límites superior e inferior para aplicar la misma transformación en el cuaderno de inferencia
dicc_outliers = {}

for col in num_cols:
    Q1 = features[col].quantile(0.25)
    Q3 = features[col].quantile(0.75)
    umbral_outliers = 1.5

    IQR = Q3 - Q1
    limite_inferior = Q1 - umbral_outliers * IQR
    limite_superior = Q3 + umbral_outliers * IQR

    features[col] = np.clip(features[col], limite_inferior, limite_superior)
    dicc_outliers[col] = [limite_inferior, limite_superior] # **

Codificación de variables

Variables numéricas

Estandarización de las variables numéricas utilizando StandardScaler()
scaler = StandardScaler()
scaler.fit(features[num_cols])

features[num_cols] = scaler.transform(features[num_cols])

Variables categóricas

  • OneHotEncoding para las variables categóricas de 5 o menos valores distintos
  • TargetEncoding para variables categóricas con más de 5 valores únicos
umbral_valores_unicos = 5
cols_to_TargetEncoder = [col for col in cat_cols if train_features[col].nunique() > umbral_valores_unicos]
cols_to_OneHotEncoder = [col for col in cat_cols if train_features[col].nunique() <= umbral_valores_unicos]

Target Encoding:

te = ce.TargetEncoder()
te.fit(features[cols_to_TargetEncoder], label)

features[cols_to_TargetEncoder] = te.transform(features[cols_to_TargetEncoder], label)

One Hot Encoding:

oe = OneHotEncoder(sparse=False, handle_unknown='ignore')
oe.fit(features[cols_to_OneHotEncoder])
col_names = oe.get_feature_names_out(cols_to_OneHotEncoder)

features_oe = oe.transform(features[cols_to_OneHotEncoder])
features[col_names] = features_oe

features.drop(columns=cols_to_OneHotEncoder, inplace=True)

Definición y entrenamiento del modelo

# Hiperparámetros configurados en 'Dev_Train-Test.ipynb' utilizando RandomizedSearchCV()
LGB_best_hyperparameters = {'reg_lambda': 0.4, 'reg_alpha': 0.4, 'num_leaves': 200, 'n_estimators': 200, 'min_child_samples': 4, 'max_depth': 10, 'learning_rate': 0.1, 'bagging_fraction': 0.5, 'random_state': 1}
LGB_model = lgb.LGBMClassifier(**LGB_best_hyperparameters)
LGB_model.fit(features, label)

Guardo los objetos usados en el entrenamiento para aplicarlos en inferencia

# Modelo
with open('objects/LGB_model.pkl', 'wb') as c:
    pickle.dump(LGB_model, c)

# Objetos de transformación
with open('objects/num_imputer.pkl', 'wb') as c:
    pickle.dump(imp_num, c)

with open('objects/imp_categoric_unk.pkl', 'wb') as c:
    pickle.dump(imp_categoric_unk, c)

with open('objects/cat_imputer.pkl', 'wb') as c:
    pickle.dump(imp_cat, c)

with open('objects/dicc_outliers.pkl', 'wb') as c:
    pickle.dump(dicc_outliers, c)

with open('objects/StdScaler.pkl', 'wb') as c:
    pickle.dump(scaler, c)

with open('objects/TargetEncoder.pkl', 'wb') as c:
    pickle.dump(te, c)

with open('objects/OneHotEncoder.pkl', 'wb') as c:
    pickle.dump(oe, c)

# Listas de columnas
with open('objects/columns.pkl', 'wb') as c:
    pickle.dump(cols, c)

with open('objects/mask_cols.pkl', 'wb') as c:
    pickle.dump(mask_cols, c)

with open('objects/cols_to_drop.pkl', 'wb') as c:
    pickle.dump(to_drop, c)

with open('objects/cat_cols.pkl', 'wb') as c:
    pickle.dump(cat_cols, c)

with open('objects/num_cols.pkl', 'wb') as c:
    pickle.dump(num_cols, c)

with open('objects/cols_to_TargetEncoder.pkl', 'wb') as c:
    pickle.dump(cols_to_TargetEncoder, c)

with open('objects/cols_to_OneHotEncoder.pkl', 'wb') as c:
    pickle.dump(cols_to_OneHotEncoder, c)

Notebook de inferencia

Importar librerías

import pandas as pd
import numpy as np
import warnings
import pickle

warnings.filterwarnings('once')

Carga de datos

datos = pd.read_csv('Data/MasterBI_Test.csv', index_col='MachineIdentifier')

Feature engineering

Selección de variables

Cargo el diccionario con las variables máscara y el número de splits, creo nuevas variables separando por el delimitador ‘.’ y elimino las variables originales
with open('objects/mask_cols.pkl', 'rb') as c:
    mask_cols = pickle.load(c)

for col, splits_num in mask_cols.items():
    col_names = []
    for i in range(1, splits_num+1):
        col_names.append(f'{col}_{str(i)}')
    datos[col_names] = datos[col].str.split('.', expand=True)

datos.drop(columns=mask_cols.keys(), inplace=True)
Cargo una lista con todas las variables categóricas, las tipo correctamente, y cargo otra lista con todas las variables numéricas
with open('objects/cat_cols.pkl', 'rb') as c:
    cat_cols = pickle.load(c)

datos[cat_cols] = datos[cat_cols].astype('category')

with open('objects/num_cols.pkl', 'rb') as c:
    num_cols = pickle.load(c)
Cargo la lista de variables a eliminar, y las elimino
with open('objects/cols_to_drop.pkl', 'rb') as c:
    to_drop = pickle.load(c)

datos.drop(columns=to_drop, inplace=True)

Imputación de nulos

Cargo el imputador de variables numéricas configurado en el cuaderno de entrenamiento para sustituir los nulos por la media
with open('objects/num_imputer.pkl', 'rb') as c:
    imp_num = pickle.load(c)

datos[num_cols] = imp_num.transform(datos[num_cols])
Cargo el diccionario con las columnas categóricas que tienen un valor ‘UNKNOWN‘ como categoría, creado en el cuaderno de entrenamiento, y sustituyo los nulos de esas columnas por ese mismo valor.
with open('objects/imp_categoric_unk.pkl', 'rb') as c:
    imp_categoric_unk = pickle.load(c)

for col, unk_value in imp_categoric_unk.items():
    datos[col].fillna(unk_value, inplace=True)
Cargo el imputador de variables categóricas configurado en el cuaderno de entrenamiento para sustituir los nulos por la moda
with open('objects/cat_imputer.pkl', 'rb') as c:
    imp_cat = pickle.load(c)

datos[cat_cols] = imp_cat.transform(datos[cat_cols])

Imputación de outliers

Cargo el diccionario con cada una de las columnas y sus límites inferior y superior y sustituyo los outliers de los nuevos datos por el límite correspondiente.
with open('objects/dicc_outliers.pkl', 'rb') as c:
    dicc_outliers = pickle.load(c)

for col, limites in dicc_outliers.items():
    datos[col] = np.clip(datos[col], limites[0], limites[1])

Codificación de variables

Cargo y aplico el objeto StandardScaler() con la estandarización de variables numéricas configurado en el cuaderno de entrenamiento.
with open('objects/StdScaler.pkl', 'rb') as c:
    scaler = pickle.load(c)

datos[num_cols] = scaler.transform(datos[num_cols])
Cargo la codificación TargetEncoding configuarada en el cuadeno de entrenamiento y la lista de variables a las que le tengo que implementar esta codificación.
with open('objects/cols_to_TargetEncoder.pkl', 'rb') as c:
    cols_to_TargetEncoder = pickle.load(c)

with open('objects/TargetEncoder.pkl', 'rb') as c:
    te = pickle.load(c)

datos[cols_to_TargetEncoder] = te.transform(datos[cols_to_TargetEncoder])
Cargo la codificación OneHotEncoding configuarada en el cuadeno de entrenamiento y la lista de variables a las que le tengo que implementar esta codificación.
with open('objects/cols_to_OneHotEncoder.pkl', 'rb') as c:
    cols_to_OneHotEncoder = pickle.load(c)

with open('objects/OneHotEncoder.pkl', 'rb') as c:
    oe = pickle.load(c)

new_data_oe = oe.transform(datos[cols_to_OneHotEncoder])
col_names = oe.get_feature_names_out(cols_to_OneHotEncoder)
datos[col_names] = new_data_oe

datos.drop(columns=cols_to_OneHotEncoder, inplace=True)

Predicción de los resultados

Cargo el modelo ya entrenado y hago mis predicciones sobre el nuevo Dataset
with open('objects/LGB_model.pkl', 'rb') as c:
    LGB_model = pickle.load(c)

y_hat = LGB_model.predict(datos)
Creo y guardo el csv con los resultados
results = pd.DataFrame()
results['MachineIdentifier'] = datos.reset_index()['MachineIdentifier']
results['HasDetections'] = y_hat.astype('float64')

results.to_csv('results.csv', index=False)

results.csv es el archivo que se sube a Kaggle para la validación de los resultados

Resultados y conclusión

Independientemente de los resultados de Kaggle, que incluso mejoraban aplicando muchas menos transformaciones que, en teoría, deberían mejorar el modelo, y que, como se mencionó al inicio, no eran el enfoque principal del proyecto, estoy muy satisfecho con la solución propuesta que incluye los 3 cuadernos con transformaciones automatizadas y realizadas en base a una serie de parámetros ajustados manualmente.

Este proyecto me ha permitido poner en práctica gran parte de los conocimientos adquiridos en el Bootcamp de Data Science y Python, especialmente en el ámbito de los algoritmos tradicionales de Machine Learning.

Además, me ha brindado la oportunidad de reforzar mis habilidades en pandas y de llevar a cabo mi primer análisis exploratorio de datos desconocidos desde cero.

Volver al inicio

Seguir viendo proyectos de...

Ciencia de datos

Ingenieria de datos

Análisis de datos