
Proyecto Asadi — Sesión 4: enseñando a la red a no ignorar la clase pequeña
En la Sesión 3 nos quedamos con una sospecha pendiente: si la exhaustividad (recall) de Benigno salía mucho más baja que la de Maligno, no era un fallo del código — era el desequilibrio de clases (504 imágenes benignas frente a 2.752 malignas) pasando factura. Hoy vamos a hacer algo al respecto.
Por qué la red “ignora” a la clase pequeña
Pensadlo desde el punto de vista de la red durante el entrenamiento: por cada imagen benigna que ve, ve más de 5 malignas. Si se equivoca con una benigna, el error que recibe es minúsculo comparado con el error total que acumula acertando o fallando con las malignas. Es como si un profesor pusiera un examen con 500 preguntas de un tema y 100 de otro: para sacar buena nota, conviene centrarse en el tema de las 500, aunque eso signifique flojear en el de 100.
Keras tiene una forma directa de corregir este desequilibrio: decirle al modelo, durante el entrenamiento, que un error en la clase pequeña “pesa” más que un error en la clase grande.
El parámetro class_weight
La idea es sencilla: calculamos un peso por clase, inversamente proporcional a cuántos ejemplos tiene. Cuantas menos imágenes tiene una clase, más peso recibe.
import os
n_benigno = len(os.listdir("imagenes_asadi/Original_binario/Benigno"))
n_maligno = len(os.listdir("imagenes_asadi/Original_binario/Maligno"))
total = n_benigno + n_maligno
peso_clases = {
0: total / (2 * n_benigno), # Benigno (la clase pequeña, más peso)
1: total / (2 * n_maligno), # Maligno (la clase grande, menos peso)
}
print(peso_clases)

Fijaos en el orden: 0 y 1 no son los que nosotros elegimos a mano, son los índices que Keras asigna según el orden alfabético de las carpetas (entrenamiento.class_names os lo confirma: ['Benigno', 'Maligno'], así que 0 es Benigno y 1 es Maligno).
Ese diccionario se lo pasamos a fit() — pero ojo: para que la comparación sea justa, no vale reentrenar el mismo modelo de la Sesión 2 (que ya venía entrenado sin class_weight, así que estaríamos mezclando las dos cosas). Hace falta construir dos redes gemelas desde cero — misma arquitectura, mismas épocas — y entrenar cada una por separado, una con class_weight y otra sin él.
Comparando con y sin class_weight
Para no repetir la arquitectura dos veces, la metemos en una función que construye un modelo nuevo cada vez que la llamamos — exactamente la misma red de la Sesión 2:
from tensorflow.keras import layers
def construir_modelo():
modelo = keras.Sequential([
layers.Input(shape=(224, 224, 3)),
layers.Rescaling(1./255),
layers.Conv2D(16, 3, activation="relu"),
layers.MaxPooling2D(),
layers.Conv2D(32, 3, activation="relu"),
layers.MaxPooling2D(),
layers.Flatten(),
layers.Dense(64, activation="relu"),
layers.Dense(1, activation="sigmoid"),
])
modelo.compile(optimizer="adam", loss="binary_crossentropy", metrics=["accuracy"])
return modelo
# Modelo A: sin class_weight
modelo_sin_peso = construir_modelo()
modelo_sin_peso.fit(entrenamiento, validation_data=prueba, epochs=10)
# Modelo B: con class_weight
modelo_con_peso = construir_modelo()
modelo_con_peso.fit(entrenamiento, validation_data=prueba, epochs=10, class_weight=peso_clases)

Ahora sí tenemos dos modelos entrenados de forma comparable, listos para enfrentarlos con la matriz de confusión de la Sesión 3:
from sklearn.metrics import confusion_matrix, classification_report
import numpy as np
y_verdadero = np.concatenate([etiquetas for imagenes, etiquetas in prueba], axis=0)
prob_a = modelo_sin_peso.predict(prueba)
pred_a = (prob_a > 0.5).astype(int).flatten()
prob_b = modelo_con_peso.predict(prueba)
pred_b = (prob_b > 0.5).astype(int).flatten()
print("--- SIN class_weight ---")
print(classification_report(y_verdadero, pred_a, target_names=entrenamiento.class_names))
print("--- CON class_weight ---")
print(classification_report(y_verdadero, pred_b, target_names=entrenamiento.class_names))

[COMPLETAR CON VUESTROS DATOS REALES: exhaustividad de Benigno sin peso = ___ | exhaustividad de Benigno con peso = ___. ¿Ha subido?]
Lo que probablemente vais a ver — y por qué no es gratis
Si class_weight funciona como se espera, la exhaustividad de Benigno debería subir: la red detecta más de los casos benignos reales. Pero fijaos también en la precisión de Benigno y en la exactitud general — es fácil que bajen un poco. Al obligar a la red a prestar más atención a la clase pequeña, es normal que a veces marque como “Benigno” algún caso que en realidad era “Maligno”.
Esto no es un error vuestro ni del modelo: es una decisión real que toma cualquier equipo de ciencia de datos al trabajar con clases desequilibradas — ¿qué error preferimos cometer menos? En un contexto clínico como este, dejar pasar un caso maligno como si fuera benigno (falso negativo) suele considerarse más grave que al revés, así que aceptar algo de exactitud general a cambio de mejor exhaustividad en la clase pequeña suele ser la elección correcta — pero es una elección, no algo automático, y hay que poder explicarla.
Próximo paso
Con esto cerramos el bloque de “entrenar y diagnosticar” de la CNN: primera red (Sesión 2), matriz de confusión (Sesión 3) y corrección del desequilibrio (Sesión 4). En una próxima sesión probaremos el modelo, ya entrenado, con imágenes que nunca ha visto — igual que hicimos en Proyecto Javierre con pacientes nuevos.



