XGBoost -empresas en Reorganización

XGBoost -empresas en Reorganización#

import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import matplotlib as mpl
import seaborn as sns
from sklearn.preprocessing import StandardScaler
from sklearn.model_selection import train_test_split
from sklearn.metrics import (
    classification_report, confusion_matrix,
    accuracy_score, f1_score, roc_auc_score,
    RocCurveDisplay, ConfusionMatrixDisplay
)
import xgboost.callback as xgb_callback
from xgboost import XGBClassifier, plot_importance

import warnings
warnings.filterwarnings("ignore")
path = "BD empresas en re organización.xlsx"

xls = pd.ExcelFile(path)

df = pd.read_excel(path, sheet_name=xls.sheet_names[0])

df.head()
Razón Social Margen EBIT Carga financiera Margen neto CxC CxP Solvencia Apalancamiento En Reorganización
0 AACER SAS 0.071690 0.000000 0.042876 0.104095 0.153192 1.877078 1.642505 0
1 ABARROTES EL ROMPOY SAS 0.017816 0.000000 0.010767 0.018414 0.000000 0.000000 0.865044 0
2 ABASTECIMIENTOS INDUSTRIALES SAS 0.144646 0.054226 0.059784 0.227215 0.025591 1.077412 1.272299 0
3 ACME LEON PLASTICOS SAS 0.004465 0.000000 -0.013995 0.073186 0.127866 0.000000 1.391645 0
4 ADVANCED PRODUCTS COLOMBIA SAS 0.141829 0.050810 0.053776 0.398755 0.147678 0.675073 2.118774 0
df.info()
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 629 entries, 0 to 628
Data columns (total 9 columns):
 #   Column             Non-Null Count  Dtype
---  ------             --------------  -----
 0   Razón Social       629 non-null    object
 1   Margen EBIT        629 non-null    float64
 2   Carga financiera   629 non-null    float64
 3   Margen neto        629 non-null    float64
 4   CxC                629 non-null    float64
 5   CxP                629 non-null    float64
 6   Solvencia          629 non-null    float64
 7   Apalancamiento     629 non-null    float64
 8   En Reorganización  629 non-null    int64
dtypes: float64(7), int64(1), object(1)
memory usage: 44.4+ KB

Ajuste con todas las variables#

# ------------------------
# Selección de variables
# ------------------------
variables_seleccionadas = ['Margen EBIT',
                           'Carga financiera',
                           'Margen neto',
                           'CxC',
                           'CxP',
                           'Solvencia',
                           'Apalancamiento']

# Variable objetivo
target = 'En Reorganización'

# ------------------------
# Preparar datos
# ------------------------
X = df[variables_seleccionadas]
y = df[target]

# Estandarizar variables
scaler = StandardScaler()
X_scaled = scaler.fit_transform(X)

# Dividir en entrenamiento y prueba (70%-30%)
X_train, X_test, y_train, y_test = train_test_split(X_scaled, y, test_size=0.3, random_state=35, stratify=y)

XGBoost#

Definición del Modelo:

model = XGBClassifier(
    # --- Tasa de aprendizaje e iteraciones ---
    learning_rate=0.1,         # η: contracción de cada árbol
    n_estimators=500,          # techo de árboles (early stopping decide el real)
    early_stopping_rounds=50,  # Early Stopping


    # --- Estructura del árbol ---
    max_depth=5,               # profundidad máxima por árbol
    min_child_weight=3,        # suma mínima de hessianos en nodo hijo
    gamma=0.1,                 # ganancia mínima para aceptar un split

    # --- Regularización directa ---
    reg_lambda=1,              # λ: penalización L2 sobre pesos de hojas
    reg_alpha=0.1,             # α: penalización L1 sobre pesos de hojas (sparsity)

    # --- Muestreo (stochastic boosting) ---
    subsample=0.8,             # fracción de filas por árbol
    colsample_bytree=0.8,      # fracción de columnas por árbol

    # --- Configuración general ---
    objective='binary:logistic',
    eval_metric='logloss',
    random_state=36
)

Entrenamiento con Early Stopping:

¿Por qué se hace un segundo split?

Early stopping necesita dos conjuntos con roles distintos:

  • Datos de entrenamiento → el modelo aprende de ellos (ajusta pesos, construye árboles).

  • Datos de validación → el modelo no aprende de ellos, solo los usa como “alarma” para saber cuándo parar.

Si monitoreas la pérdida sobre los mismos datos con los que entrenas, la pérdida casi siempre seguirá bajando (porque el modelo puede memorizar), y early stopping nunca se activaría.

Es por esto que X_train se divide otra vez.

# Separar una porción del train para monitoreo
X_tr, X_val, y_tr, y_val = train_test_split(
    X_train, y_train, test_size=0.2, random_state=35, stratify=y_train
)

model.fit(
    X_tr, y_tr,
    eval_set=[(X_val, y_val)],
    verbose=False,
)
XGBClassifier(base_score=None, booster=None, callbacks=None,
              colsample_bylevel=None, colsample_bynode=None,
              colsample_bytree=0.8, device=None, early_stopping_rounds=50,
              enable_categorical=False, eval_metric='logloss',
              feature_types=None, feature_weights=None, gamma=0.1,
              grow_policy=None, importance_type=None,
              interaction_constraints=None, learning_rate=0.1, max_bin=None,
              max_cat_threshold=None, max_cat_to_onehot=None,
              max_delta_step=None, max_depth=5, max_leaves=None,
              min_child_weight=3, missing=nan, monotone_constraints=None,
              multi_strategy=None, n_estimators=500, n_jobs=None,
              num_parallel_tree=None, ...)
In a Jupyter environment, please rerun this cell to show the HTML representation or trust the notebook.
On GitHub, the HTML representation is unable to render, please try loading this page with nbviewer.org.

Resultados#

print(f"Árboles solicitados (n_estimators):  {model.n_estimators}")
print(f"Árboles construidos (best_iteration): {model.best_iteration}")
print(f"Mejor logloss en validación:          {model.best_score:.4f}")
Árboles solicitados (n_estimators):  500
Árboles construidos (best_iteration): 21
Mejor logloss en validación:          0.4752
resultados = model.evals_result()

plt.figure(figsize=(10, 5))
plt.plot(resultados['validation_0']['logloss'], label='Validación', color='steelblue')
plt.axvline(x=model.best_iteration, color='red', linestyle='--', label=f'Mejor iteración ({model.best_iteration})')
plt.xlabel('Número de árboles')
plt.ylabel('Log Loss')
plt.title('Curva de Aprendizaje')
plt.legend()
plt.grid(True, alpha=0.3)
plt.show()
../../../_images/output_14_0.png
importancia = pd.DataFrame({
    'Feature': variables_seleccionadas,
    'Gain': [model.get_booster().get_score(importance_type='gain').get(f'f{i}', 0) for i in range(len(variables_seleccionadas))],
    'Weight': [model.get_booster().get_score(importance_type='weight').get(f'f{i}', 0) for i in range(len(variables_seleccionadas))],
    'Cover': [model.get_booster().get_score(importance_type='cover').get(f'f{i}', 0) for i in range(len(variables_seleccionadas))],
})

importancia = importancia.sort_values('Gain', ascending=False).reset_index(drop=True)
print(importancia.to_string(index=False))
         Feature     Gain  Weight     Cover
  Apalancamiento 4.085507    86.0 27.509918
             CxC 3.897206    87.0 24.689163
             CxP 3.717442    88.0 23.958036
     Margen neto 3.180511    83.0 25.785898
       Solvencia 3.067891    73.0 20.580566
Carga financiera 1.666710    44.0 19.381701
     Margen EBIT 1.559393    56.0 17.179787

La tabla muestra qué tan importante fue cada variable para el modelo, medida de tres formas distintas. Las tres cuentan historias complementarias.

Gain es la más informativa. Cada vez que un árbol usa una variable para hacer un split, ese split produce una reducción en la función de pérdida (la “ganancia” que discutimos con gamma). El Gain de la tabla es el promedio de esas ganancias a lo largo de todos los árboles. Apalancamiento tiene el Gain más alto (4.08), lo que significa que cada vez que el modelo usó Apalancamiento para partir un nodo, en promedio mejoró la predicción más que con cualquier otra variable. Carga financiera y Margen EBIT tienen los Gain más bajos (1.66 y 1.55), lo que indica que cuando se usaron, su contribución a reducir el error fue menor.

Weight es simplemente cuántas veces apareció cada variable como criterio de split en todos los árboles. CxP fue la más usada (88 veces), Carga financiera la menos usada (44 veces). Pero esta métrica puede ser engañosa: una variable ruidosa podría aparecer muchas veces haciendo splits pequeños e inútiles, inflando su Weight sin realmente contribuir. Por eso CxP tiene el Weight más alto pero no el Gain más alto: se usó mucho pero cada uso individual aportó un poco menos que Apalancamiento.

Cover es el número promedio de muestras que pasaron por los nodos donde se usó esa variable. Apalancamiento tiene el Cover más alto (27.5), lo que significa que cuando el modelo la usó, afectó a más muestras en promedio. Margen EBIT tiene el más bajo (17.1): sus splits tendieron a ocurrir en nodos más pequeños, afectando a menos observaciones. Si Apalancamiento tiene Cover de 27.5, significa que en promedio, cada vez que el modelo usó Apalancamiento para partir un nodo, ese nodo contenía unas 27-28 muestras. Los splits con Apalancamiento ocurrieron en nodos “grandes”, cerca de la raíz, donde todavía había muchas muestras juntas. La decisión afectó a muchas observaciones de un solo golpe.

Evaluación del ajuste#

# Probabilidades:
y_prob_train = model.predict(X_train)
y_prob = model.predict(X_test)

# Definición de las clases con umbral:
y_pred_train  = np.where(y_prob_train > 0.5, 1, 0)
y_pred = np.where(y_prob > 0.5, 1, 0)

# ------------------------
# Evaluación del modelo
# ------------------------

# =========================================================
# 1. Matrices de confusión
# =========================================================
cm_train = confusion_matrix(y_train, y_pred_train, labels=[0, 1])
cm_test  = confusion_matrix(y_test, y_pred, labels=[0, 1])

cm_df_train = pd.DataFrame(
    cm_train,
    index=["Real: No Reorg.", "Real: Reorg."],
    columns=["Pred: No Reorg.", "Pred: Reorg."]
)

cm_df_test = pd.DataFrame(
    cm_test,
    index=["Real: No Reorg.", "Real: Reorg."],
    columns=["Pred: No Reorg.", "Pred: Reorg."]
)

# =========================================================
# 2. Estilo visual
# =========================================================
cmap = mpl.colormaps["viridis"]

BG_FIG   = "#f7f7f7"
BG_AX    = "#ffffff"
GRID_COL = "#d9d9d9"
TEXT_COL = "#1f1f1f"
SUB_COL  = "#4d4d4d"

TITLE_FS    = 20
SUBTITLE_FS = 12
LABEL_FS    = 12
TICK_FS     = 11
ANNOT_FS    = 16

sns.set_theme(style="white")

# =========================================================
# 3. Figura con dos paneles
# =========================================================
fig, axes = plt.subplots(1, 2, figsize=(12, 5.5), facecolor=BG_FIG)

fig.suptitle(
    "Matrices de confusión",
    fontsize=TITLE_FS,
    fontweight="bold",
    color=TEXT_COL,
    y=0.98
)

# =========================================================
# 4. Función para dibujar cada heatmap
# =========================================================
def plot_conf_matrix(ax, cm_df, title):
    ax.set_facecolor(BG_AX)

    hm = sns.heatmap(
        cm_df,
        annot=True,
        fmt="d",
        cmap=cmap,
        cbar=True,
        linewidths=0.8,
        linecolor=GRID_COL,
        square=True,
        annot_kws={
            "fontsize": ANNOT_FS,
            "fontweight": "bold",
            "color": TEXT_COL
        },
        cbar_kws={"shrink": 0.85},
        ax=ax
    )

    ax.set_title(
        title,
        fontsize=15,
        fontweight="bold",
        color=TEXT_COL,
        pad=10
    )

    ax.set_xlabel(
        "Clase predicha",
        fontsize=LABEL_FS,
        fontweight="bold",
        color=SUB_COL
    )

    ax.set_ylabel(
        "Clase real",
        fontsize=LABEL_FS,
        fontweight="bold",
        color=SUB_COL
    )

    ax.tick_params(axis='x', labelsize=TICK_FS, colors=TEXT_COL, rotation=0)
    ax.tick_params(axis='y', labelsize=TICK_FS, colors=TEXT_COL, rotation=0)

    for lbl in ax.get_xticklabels() + ax.get_yticklabels():
        lbl.set_fontweight("bold")

    # Estilo del colorbar
    cbar = hm.collections[0].colorbar
    cbar.ax.tick_params(labelsize=10, colors=TEXT_COL)
    for t in cbar.ax.get_yticklabels():
        t.set_fontweight("bold")

    for spine in ax.spines.values():
        spine.set_edgecolor(GRID_COL)
        spine.set_linewidth(0.8)

# =========================================================
# 5. Dibujar train y test
# =========================================================
plot_conf_matrix(axes[0], cm_df_train, "Train")
plot_conf_matrix(axes[1], cm_df_test, "Test")

plt.tight_layout(rect=[0.03, 0.08, 0.98, 0.92])
plt.show()

print("\n=== Reporte de Clasificación - train ===")
print(classification_report(y_train, y_pred_train))

print("\n=== Reporte de Clasificación - test ===")
print(classification_report(y_test, y_pred))
../../../_images/output_18_01.png
=== Reporte de Clasificación - train ===
              precision    recall  f1-score   support

           0       0.83      0.91      0.87       201
           1       0.91      0.85      0.88       239

    accuracy                           0.87       440
   macro avg       0.87      0.88      0.87       440
weighted avg       0.88      0.87      0.87       440


=== Reporte de Clasificación - test ===
              precision    recall  f1-score   support

           0       0.76      0.84      0.80        86
           1       0.85      0.78      0.81       103

    accuracy                           0.80       189
   macro avg       0.80      0.81      0.80       189
weighted avg       0.81      0.80      0.80       189