Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

2. Modelo final con validación segura

Proyecto Integrador — Heart Failure Prediction

Nombres: Juan Camilo Oñoro Araujo - 200177329 y María Carolina Cantillo Orozco - 200179105

Fecha: 30/09/2026

MACHINE LEARNING

Notebook 2 — Etapa 2: Modelado con validación segura

Este cuaderno contiene:

  1. División de los datos antes del escalado.

  2. Implementación de Pipeline con GridSearchCV.

  3. Evaluación del modelo con matriz de confusión, curva ROC y AUC.

Las decisiones de preprocesamiento vienen del EDA del notebook 1 (Parte 25).

En el notebook 1 se compararon modelos. Aquí se construye el modelo definitivo, el que va a usar la API.

Se separan los datos, se elige el modelo solo con validación cruzada, se evalúa una única vez con el conjunto de prueba y se guarda el Pipeline completo. Si el conjunto de prueba se usara para tomar decisiones, dejaría de ser una evaluación honesta de cómo se comportará el modelo con pacientes nuevos.

Parte 1. Librerías y configuraciones

pandas 2.1.0 | numpy 1.25.2 | scikit-learn 1.3.0 | joblib 1.3.2

Parte 2. Carga de datos y rutas del proyecto

918 pacientes | 11 variables | 55.3% con enfermedad
Loading...

Parte 3. División de los datos antes del escalado

El split se hace sobre los datos crudos, antes de calcular cualquier estadística. A partir de aquí el conjunto de prueba queda guardado y solo se usa en la Parte 8. Se usa el mismo split estratificado 80/20 y la misma semilla del notebook 1, para que los resultados se puedan comparar.

Los dos conjuntos se guardan en data/: train.csv servirá como referencia para el monitoreo de deriva de la Etapa 6.

Loading...
Guardados: E:\Documents\MachineLearningUN\heart-disease-mlops\data\train.csv y E:\Documents\MachineLearningUN\heart-disease-mlops\data\test.csv

Si el escalador se ajustara con todo el dataset, el mínimo y el máximo incluirían pacientes de prueba. Con el split primero, el escalador solo conoce train. La tabla compara los parámetros que aprendería MinMaxScaler en cada caso.

Loading...
Rango de test después de escalar con los parámetros de train:
Loading...

Los parámetros del escalador sí cambian según el orden. Con el split primero, el mínimo de age es 29 y no 28, y el máximo de oldpeak es 5.6 y no 6.2, porque el paciente de 28 años y el de oldpeak 6.2 quedaron en test. Si se escalara con todo el dataset, el modelo tendría información sobre esos pacientes de prueba antes de evaluarlos.

Por eso, en test aparecen valores escalados fuera de [0, 1]: −0.02 en age y 1.07 en oldpeak. No es un error. Es exactamente lo que pasará en producción cuando llegue un paciente con valores más extremos que los de entrenamiento.

Parte 4. Preprocesador para producción

En el notebook 1 los ceros imposibles se convertían en NaN antes del Pipeline. Aquí ese paso va dentro del Pipeline, con SimpleImputer(missing_values=0): el imputador trata directamente el 0 como faltante. Así el modelo exportado recibe los datos tal como los envía el usuario, y la API no tiene que repetir ninguna limpieza a mano.

GrupoColumnasTratamiento
numage, maxhr, oldpeakMinMaxScaler
num_cerosrestingbp, cholesterol0 → mediana de train + indicador de faltante, y después MinMaxScaler
catsex, chestpaintype, restingecg, exerciseangina, st_slopeOneHotEncoder(handle_unknown=“ignore”)
binfastingbsSin cambios
Variables de salida (21):
['Age', 'MaxHR', 'Oldpeak', 'RestingBP', 'Cholesterol', 'missingindicator_Cholesterol', 'Sex_F', 'Sex_M', 'ChestPainType_ASY', 'ChestPainType_ATA', 'ChestPainType_NAP', 'ChestPainType_TA', 'RestingECG_LVH', 'RestingECG_Normal', 'RestingECG_ST', 'ExerciseAngina_N', 'ExerciseAngina_Y', 'ST_Slope_Down', 'ST_Slope_Flat', 'ST_Slope_Up', 'FastingBS']
Medianas aprendidas con train: {'RestingBP': 130.0, 'Cholesterol': 238.0}

Parte 5. Pipeline con GridSearchCV

En lugar de una búsqueda por modelo, se hace una sola búsqueda en la que el propio clasificador también es un hiperparámetro. El paso “clf” del Pipeline se reemplaza por cada candidato, y GridSearchCV prueba todas las combinaciones con la misma validación cruzada de 5 folds. Así todos los modelos se comparan exactamente en las mismas condiciones.

Los candidatos son los 5 mejores del notebook 1. Se descartan el árbol de decisión y Naive Bayes, que quedaron claramente por debajo. Las grillas son las mismas del notebook 1. Se registran AUC y Accuracy, y el mejor modelo se elige por AUC.

90 combinaciones x 5 folds = 450 ajustes en 12.8 s
Mejor modelo: RandomForestClassifier
Mejores hiperparámetros: {'max_depth': None, 'max_features': 'sqrt', 'min_samples_leaf': 5}
AUC CV del mejor: 0.9343

Parte 6. Selección del modelo por validación cruzada

Para cada familia de modelos se muestra su mejor combinación. El orden es por AUC de validación cruzada, sin usar el conjunto de prueba.

Loading...
Loading...
RandomForestClassifier vs GradientBoostingClassifier: diferencia media = +0.0014, t pareada p = 0.304

RandomForest es el mejor por AUC de validación cruzada (0.9343), seguido muy de cerca por GradientBoosting (0.9329). La diferencia entre los dos es de +0.0014 y no es significativa (t pareada sobre los 5 folds, p = 0.304). Después vienen la regresión logística (0.9292), KNN (0.9273) y SVC (0.9269). Los cinco quedan en un rango de 0.008 de AUC.

La tabla de folds muestra que la variación entre folds es mucho mayor que entre modelos. El fold 2 es difícil para todos (AUC entre 0.861 y 0.887) y el fold 4 es fácil para todos (entre 0.957 y 0.972). Ningún modelo es mejor en todos los folds.

Se elige RandomForest (max_depth = None, max_features = sqrt, min_samples_leaf = 5) porque es el primero por el criterio fijado de antemano (AUC de CV), no porque sea claramente superior. Su brecha train–CV (0.039) es moderada: min_samples_leaf = 5 evita que los árboles memoricen cada paciente.

Parte 7. Validación cruzada anidada

El AUC de la Parte 6 (grid.best_score_) es un poco optimista, porque es el máximo entre 90 combinaciones evaluadas con los mismos folds. La validación cruzada anidada estima cuánto rinde el procedimiento completo (buscar hiperparámetros y elegir el modelo):

  1. El bucle externo (5 folds) separa datos que la búsqueda nunca ve.

  2. El bucle interno es el mismo GridSearchCV de la Parte 5.

Todo se hace dentro de train, así que el conjunto de prueba sigue sin usarse.

Validación anidada en 56.9 s
Loading...
AUC anidado     : 0.9263 ± 0.0118
Accuracy anidado: 0.8488 ± 0.0083
AUC de la búsqueda simple (Parte 5): 0.9343

La validación anidada da AUC = 0.9263 ± 0.0118, un poco por debajo del 0.9343 de la búsqueda simple. La diferencia (0.008) es el optimismo de escoger el máximo entre 90 combinaciones evaluadas con los mismos folds. 0.926 es la estimación honesta de cuánto rinde el procedimiento completo con datos nuevos.

En los 5 folds externos, la búsqueda interna eligió RandomForest 3 veces y GradientBoosting 2 veces. Esto confirma que los dos están empatados y que la elección entre ellos depende de qué pacientes caen en cada fold. El Accuracy anidado es 0.849 ± 0.008.

Parte 8. Evaluación final en el conjunto de prueba

El modelo elegido ya está reentrenado con todo train (refit=True). Se evalúa una sola vez con los 184 pacientes de prueba, que no participaron en ninguna decisión anterior. Se usa el umbral de 0.5, el mismo que usará la API.

Loading...
AUC test = 0.9341  (IC 95 % bootstrap: 0.8929 – 0.9682)
Verdaderos negativos = 68 | Falsos positivos = 14 | Falsos negativos = 6 | Verdaderos positivos = 96

                precision    recall  f1-score   support

Sin enfermedad     0.9189    0.8293    0.8718        82
Con enfermedad     0.8727    0.9412    0.9057       102

      accuracy                         0.8913       184
     macro avg     0.8958    0.8852    0.8887       184
  weighted avg     0.8933    0.8913    0.8906       184

Matriz de confusión

<Figure size 715x605 with 1 Axes>

Se observa que la cantidad de falsos negativos esla mejor, lo cual tiene sentido, poruqe en el entorno médico se prefiere dar un tratamiento a alguien, aún si no tiene la enfermedad. SIn embargo, tampco ose quiere un modelo que diga siempre que sí la tienes, porque eso implica costos y otros riesgos adicionales como efectos secundarios de medicamentos.

Curva ROC y AUC

<Figure size 748x693 with 1 Axes>

En los 184 pacientes de prueba, el modelo obtiene AUC = 0.934, con un intervalo de confianza del 95 % por bootstrap de 0.893 a 0.968, y Accuracy = 0.891. El AUC de test coincide con el de validación cruzada (0.934) y queda dentro del rango de la validación anidada. No hay señales de sobreajuste en la selección.

En la matriz de confusión, con umbral 0.5:

  1. Detecta 96 de los 102 enfermos (sensibilidad 0.941). Solo hay 6 falsos negativos, que son el error más grave en un tamizaje cardíaco, porque es un paciente enfermo que se va a casa sin tratamiento.

  2. Hay 14 falsos positivos de 82 sanos (especificidad 0.829). Es el punto débil del modelo: como se señaló en el notebook 1, un falso positivo puede llevar a tratamientos innecesarios.

El intervalo de confianza es amplio (±0.04) porque el conjunto de prueba es pequeño. Por eso las diferencias de 0.003 a 0.008 entre modelos del notebook 1 no permiten decir que uno sea mejor que otro.

No vale la pena tener una sensibilidad más alta, ya que subirían los falsos positivos, se tiene una sensibilidad bastante buena para un modelo general, sin embargo, se podría mejorar si se tuviesen más datos o si se hicieran más preguntas clave en el dataset, como hacer o no ejericio,fumar o no, beber alcohol o no.

Parte 9. Umbral de decisión

La API usa 0.5, igual que el enunciado, pero el umbral es una decisión clínica: bajarlo detecta más enfermos a cambio de más falsos positivos, y subirlo hace lo contrario. Para elegir un umbral alternativo sin tocar test, se usan las probabilidades fuera de fold de train (cross_val_predict) y el índice de Youden (sensibilidad + especificidad − 1). Después se muestra cómo se comportan varios umbrales en test.

Umbral de Youden (elegido con train, fuera de fold): 0.550
Loading...

El umbral de Youden, elegido con las predicciones fuera de fold de train, es 0.55. En test, frente a 0.5, reduce los falsos positivos de 14 a 11 (especificidad de 0.829 a 0.866) a cambio de 2 falsos negativos más (de 6 a 8), y sube el accuracy de 0.891 a 0.897.

La tabla muestra el compromiso:

  1. Con 0.3 solo quedan 4 falsos negativos, pero hay 26 falsos positivos.

  2. Con 0.7 solo hay 5 falsos positivos, pero se escapan 23 enfermos.

En tamizaje suele preferirse un umbral bajo, porque perder un enfermo es más costoso. Si la preocupación son los tratamientos innecesarios, el umbral de Youden es un punto intermedio razonable. La API usará 0.5, como pide el enunciado, y el umbral de Youden queda guardado en los metadatos por si se quiere cambiar.

Parte 10. Importancia de las variables (permutación)

Se desordena una variable a la vez en el conjunto de prueba y se mide cuánto cae el AUC. Como se aplica sobre el Pipeline completo, la importancia sale por variable original y no por columna One-Hot. Se repite 30 veces por variable.

<Figure size 880x550 with 1 Axes>
Loading...

st_slope es la variable más importante: desordenarla hace caer el AUC en 0.105. Le siguen chestpaintype (0.018), sex (0.018) y cholesterol (0.016). Coincide con el EDA, donde st_slope tuvo la mayor V de Cramér (0.62).

Hay dos resultados que se explican por cómo funciona la permutación:

  1. cholesterol aparece más importante que en el EDA, donde tenía un efecto pequeño. Al desordenarla también se desordenan los ceros, y con ellos el indicador de faltante, que según la Parte 24 del notebook 1 está muy asociado con la enfermedad. Buena parte de su importancia es la del indicador, es decir, la del hospital de origen, y no la del colesterol en sí.

  2. maxhr, age y oldpeak salen poco importantes aunque en el EDA separaban bien las clases. Están correlacionadas con st_slope, exerciseangina y chestpaintype (Parte 22 del notebook 1). Al desordenar una de ellas, el bosque sigue teniendo la misma información en las otras, así que la permutación subestima la importancia de las variables redundantes.

Parte 11. Exportación del modelo

Se guarda el Pipeline completo (preprocesador + clasificador), tal como indica el enunciado:

joblib.dump(grid.best_estimator_, “app/model.joblib”)

La ruta es relativa a la raíz del proyecto, porque la API se ejecuta desde ahí. Junto al modelo se guarda un archivo de metadatos con el orden y los valores válidos de las variables, las métricas y la versión de scikit-learn. Un modelo guardado con joblib solo se puede cargar de forma confiable con la misma versión de scikit-learn, así que esa versión se fijará en docker/requirements.txt.

Modelo guardado   : E:\Documents\MachineLearningUN\heart-disease-mlops\app\model.joblib (2222.7 KB)
Metadatos guardados: E:\Documents\MachineLearningUN\heart-disease-mlops\app\model_metadata.json
{
  "modelo": "RandomForestClassifier",
  "hiperparametros": {
    "max_depth": null,
    "max_features": "sqrt",
    "min_samples_leaf": 5
  },
  "columnas_entrada": [
    "Age",
    "Sex",
    "ChestPainType",
    "RestingBP",
    "Cholesterol",
    "FastingBS",
    "RestingECG",
    "MaxHR",
    "ExerciseAngina",
    "Oldpeak",
    "ST_Slope"
  ],
  "categorias": {
    "Sex": [
      "F",
      "M"
    ],
    "ChestPainType": [
      "ASY",
      "ATA",
      "NAP",
      "TA"
    ],
    "RestingECG": [
      "LVH",
      "Normal",
      "ST"
    ],
    "ExerciseAngina": [
      "N",
      "Y"
    ],
    "ST_Slope": [
      "Down",
      "Flat",
      "Up"
    ]
  },
  "rangos_train": {
    "Age": [
      29.0,
      77.0
    ],
    "MaxHR": [
      60.0,
      202.0
    ],
    "Oldpeak": [
      -2.6,
      5.6
    ],
    "RestingBP": [
      92.0,
      200.0
    ],
    "Cholesterol": [
      85.0,
      603.0
    ]
  },
  "umbral": 0.5,
  "umbral_youden": 0.5503,
  "auc_cv": 0.9343,
  "auc_cv_anidado": 0.9263,
  "metricas_test": {
    "AUC": 0.9341,
    "Accuracy": 0.8913,
    "Precision": 0.8727,
    "Recall (sensibilidad)": 0.9412,
    "Especificidad": 0.8293,
    "F1": 0.9057
  },
  "sklearn_version": "1.3.0",
  "fecha_entrenamiento": "2026-09-30"
}

Verificación: cargar el modelo y predecir un paciente nuevo, como lo hará la API

El modelo cargado reproduce exactamente las probabilidades de test.
Probabilidad de enfermedad cardíaca: 0.985 -> predicción: 1

Conclusiones de la Etapa 2

  1. Datos: el split estratificado 80/20 se hizo antes de cualquier escalado o imputación. Train y test quedaron guardados en data/ para el monitoreo de la Etapa 6.

  2. Pipeline: el modelo exportado incluye todo el preprocesamiento. Los ceros imposibles se tratan con SimpleImputer(missing_values=0), así que el modelo recibe los datos crudos del paciente.

  3. Selección: un solo GridSearchCV comparó 5 modelos y 90 combinaciones con los mismos folds. Ganó RandomForest (AUC CV = 0.934), empatado en la práctica con GradientBoosting (p = 0.304). La validación anidada estima el rendimiento real del procedimiento en AUC = 0.926 ± 0.012.

  4. Evaluación en test: AUC = 0.934 (IC 95 % 0.893–0.968), Accuracy = 0.891, sensibilidad 0.941 y especificidad 0.829, con 6 falsos negativos y 14 falsos positivos. El umbral de Youden (0.55) reduce los falsos positivos a 11.

  5. Exportación: el modelo está en app/model.joblib y los metadatos en app/model_metadata.json. El modelo cargado reproduce exactamente las predicciones. Fue entrenado con scikit-learn 1.3.0, y esa versión debe fijarse en docker/requirements.txt para que la API de la Etapa 3 lo pueda cargar.