6 - Pocas imágenes: pesos de clase, aumento de datos y aprendizaje por transferencia¶
En el capítulo 6, el proyecto de dermatología choca con el problema habitual en medicina: Ana necesitaría varios miles de imágenes, y Carmen solo encuentra 280 utilizables en el archivo, 180 benignas, 70 sospechosas y 30 malignas, no todas de buena calidad. Ana propone tres remedios: técnicas para el desequilibrio de clases (sobremuestreo, ajuste de pesos), el aumento de datos (rotaciones, cambios de iluminación) y el aprendizaje por transferencia (partir de un modelo preentrenado con un conjunto grande y adaptarlo). Meses después, con el doble de imágenes, Luis presenta un modelo mucho mejor.
En este cuaderno medimos cada remedio con la red del libro (en PyTorch) y 280 imágenes sintéticas:
- Importar las bibliotecas.
- Las imágenes: 280 del archivo de Minermont.
- La red y cómo la medimos.
- El punto de partida: entrenar desde cero.
- El desequilibrio de clases: pesos de clase y sobremuestreo.
- El aumento de datos.
- El aprendizaje por transferencia desde las imágenes de otro hospital.
- El doble de imágenes.
- Resumen.
Paso 1: Importar las bibliotecas¶
Usamos PyTorch para la red, como en los cuadernos del capítulo 7; la arquitectura es la misma que construye el cuaderno de Keras.
import copy
import numpy as np
import matplotlib.pyplot as plt
import torch
import torch.nn as nn
torch.set_num_threads(8)
Paso 2: Las imágenes: 280 del archivo de Minermont¶
Las lesiones salen del mismo generador que el cuaderno de Keras. Para imitar un archivo de calidad desigual, capture da a cada foto su propia exposición (brillo y contraste), su propia cantidad de ruido y, en el 40% de los casos, un ligero desenfoque. Tomamos 280 imágenes de entrenamiento con el reparto del libro y, para medir los resultados con precisión, 900 imágenes de prueba tomadas de la misma forma. El paso 8 usará 280 más.
def make_lesions(n, seed, shares=(0.5, 0.3, 0.2)):
"""Imágenes sintéticas de 28x28 en escala de grises de lesiones cutáneas: 0 = benigna, 1 = sospechosa, 2 = maligna."""
rng = np.random.default_rng(seed)
labels = rng.choice(3, n, p=shares)
yy, xx = np.mgrid[0:28, 0:28].astype(float)
images = np.empty((n, 28, 28), dtype=np.float32)
# las reglas ocultas: tamaño (r), irregularidad del borde (irr), pigmento heterogéneo (het),
# lóbulo extra asimétrico (lobe) y lo difuminado que está el borde (soft); los rangos se solapan a propósito
params = {0: dict(r=(4.0, 7.0), irr=(0.0, 0.10), het=(0.0, 0.06), lobe=0.05, soft=(1.2, 2.0)),
1: dict(r=(5.0, 8.5), irr=(0.07, 0.20), het=(0.04, 0.12), lobe=0.35, soft=(0.8, 1.6)),
2: dict(r=(6.0, 10.0), irr=(0.15, 0.32), het=(0.10, 0.22), lobe=0.75, soft=(0.5, 1.2))}
for i, c in enumerate(labels):
p = params[c]
cx, cy = rng.uniform(11, 17, 2)
r0, irr = rng.uniform(*p['r']), rng.uniform(*p['irr'])
het, soft = rng.uniform(*p['het']), rng.uniform(*p['soft'])
theta, dist = np.arctan2(yy - cy, xx - cx), np.hypot(xx - cx, yy - cy)
radius = r0 * (1 + sum(irr / k ** 0.5 * rng.uniform(0.5, 1.0) * np.cos(k * theta + rng.uniform(0, 2 * np.pi))
for k in range(2, 7)))
mask = 1 / (1 + np.exp(-(radius - dist) / soft))
if rng.random() < p['lobe']:
a = rng.uniform(0, 2 * np.pi)
lx, ly = cx + 0.8 * r0 * np.cos(a), cy + 0.8 * r0 * np.sin(a)
mask = np.maximum(mask, 1 / (1 + np.exp(-(0.55 * r0 - np.hypot(xx - lx, yy - ly)) / soft)))
pigment = rng.uniform(0.45, 0.6) + het * rng.normal(0, 1, (28, 28))
for _ in range(3): # manchas más oscuras y más claras
bx, by = cx + rng.normal(0, r0 / 2), cy + rng.normal(0, r0 / 2)
pigment += het * 2.5 * rng.choice([-1, 1]) * np.exp(-((xx - bx) ** 2 + (yy - by) ** 2) / (2 * (r0 / 3) ** 2))
skin = 0.82 + 0.04 * (xx - 14) / 14 * rng.normal() + 0.03 * rng.normal(0, 1, (28, 28))
images[i] = np.clip(skin - mask * pigment, 0, 1)
return images, labels
def capture(images, rng):
"""El archivo de Minermont: cada foto con su propia exposición, ruido y enfoque."""
out = np.empty_like(images)
for i, img in enumerate(images):
x = (img - 0.5) * rng.uniform(0.75, 1.25) + 0.5 + rng.uniform(-0.12, 0.12) # contraste y brillo
if rng.random() < 0.4: # desenfocada: desenfoque de 3 x 3
p = np.pad(x, 1, mode='edge')
x = sum(p[a:a + 28, b:b + 28] for a in range(3) for b in range(3)) / 9
out[i] = np.clip(x + rng.normal(0, rng.uniform(0.01, 0.06), x.shape), 0, 1) # ruido del sensor
return out
def archive(seed, counts):
"""Exactamente counts[c] imágenes de cada clase."""
images, labels = make_lesions(int(sum(counts) * 3.5), seed=seed)
idx = np.concatenate([np.where(labels == c)[0][:n] for c, n in enumerate(counts)])
return images[idx], labels[idx]
class_names = ['benigna', 'sospechosa', 'maligna']
clean, y_small = archive(1, (180, 70, 30))
X_small = capture(clean, np.random.default_rng(11))
X_more, y_more = archive(2, (180, 70, 30))
X_more = capture(X_more, np.random.default_rng(12))
X_test, y_test = make_lesions(900, seed=3)
X_test = capture(X_test, np.random.default_rng(13))
print("Imágenes de entrenamiento por clase:", dict(zip(class_names, np.bincount(y_small).tolist())))
print("Imágenes de prueba por clase: ", dict(zip(class_names, np.bincount(y_test).tolist())))
fig, axes = plt.subplots(2, 6, figsize=(12, 4.4))
examples = [np.where(y_small == c)[0][k] for c in range(3) for k in range(2)]
for col, idx in enumerate(examples):
axes[0, col].imshow(clean[idx], cmap='gray', vmin=0, vmax=1)
axes[1, col].imshow(X_small[idx], cmap='gray', vmin=0, vmax=1)
axes[0, col].set_title(class_names[y_small[idx]])
for ax in axes.ravel():
ax.axis('off')
fig.suptitle('Las mismas lesiones antes (arriba) y después de la captura en el archivo (abajo)'); plt.show()
Imágenes de entrenamiento por clase: {'benigna': 180, 'sospechosa': 70, 'maligna': 30}
Imágenes de prueba por clase: {'benigna': 460, 'sospechosa': 276, 'maligna': 164}
Paso 3: La red y cómo la medimos¶
La CNN del libro: dos convoluciones de 16 y 32 filtros con max-pooling, una capa densa de 64 neuronas y 3 salidas, con un dropout opcional, que apaga al azar una parte de las neuronas durante el entrenamiento para que la red no dependa de ninguna en concreto. train tiene una opción para cada remedio del libro: pesos para las clases, aumento de datos y qué parámetros se entrenan. Adam, 40 épocas, lotes de 32.
Con 280 imágenes, el resultado de un entrenamiento depende mucho de la inicialización aleatoria, así que cada configuración se entrena 5 veces con semillas distintas, y evaluate da la media y la desviación típica de la exactitud y del recall de cada clase en las 900 imágenes de prueba: la proporción de lesiones de esa clase que reconoce la red, el "85% en benignos, 70% en sospechosos, 65% en malignos" del primer prototipo de Luis.
def tensor(images):
return torch.tensor(images[:, None], dtype=torch.float32)
class CNN(nn.Module):
def __init__(self, dropout=0.0):
super().__init__()
self.features = nn.Sequential(nn.Conv2d(1, 16, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2),
nn.Conv2d(16, 32, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2), nn.Flatten())
self.classifier = nn.Sequential(nn.Dropout(dropout), nn.Linear(1568, 64), nn.ReLU(), nn.Dropout(dropout), nn.Linear(64, 3))
def forward(self, x):
return self.classifier(self.features(x))
def augment(x, g):
"""Volteos, cuartos de vuelta, brillo y contraste aleatorios: una lesión no tiene 'arriba', y las fotos varían."""
if torch.rand(1, generator=g) < 0.5:
x = x.flip(3)
if torch.rand(1, generator=g) < 0.5:
x = x.flip(2)
x = torch.rot90(x, int(torch.randint(0, 4, (1,), generator=g)), (2, 3))
brightness = (torch.rand(len(x), 1, 1, 1, generator=g) - 0.5) * 0.1
contrast = 1 + (torch.rand(len(x), 1, 1, 1, generator=g) - 0.5) * 0.2
return ((x - 0.5) * contrast + 0.5 + brightness).clamp(0, 1)
def train(model, X, y, epochs=40, lr=1e-3, class_weights=None, augmentation=False, only_classifier=False, seed=0):
torch.manual_seed(seed)
g = torch.Generator().manual_seed(seed)
if only_classifier: # congelar las convoluciones
for p in model.features.parameters():
p.requires_grad = False
optimizer = torch.optim.Adam([p for p in model.parameters() if p.requires_grad], lr=lr)
weights = None if class_weights is None else torch.tensor(class_weights, dtype=torch.float32)
Xt, yt = tensor(X), torch.tensor(y)
for _ in range(epochs):
model.train()
for batch in torch.randperm(len(yt), generator=g).split(32):
x = augment(Xt[batch], g) if augmentation else Xt[batch]
loss = nn.functional.cross_entropy(model(x), yt[batch], weight=weights)
optimizer.zero_grad()
loss.backward()
optimizer.step()
return model
def scores(model):
model.eval()
with torch.no_grad():
pred = model(tensor(X_test)).argmax(dim=1).numpy()
return [np.mean(pred == y_test)] + [np.mean(pred[y_test == c] == c) for c in range(3)]
results = {}
def evaluate(name, make_model, seeds=5):
runs = []
for seed in range(seeds):
torch.manual_seed(seed) # los pesos iniciales también dependen de la semilla
runs.append(scores(make_model(seed)))
runs = np.array(runs)
results[name] = runs.mean(axis=0)
spread = f" (±{runs[:, 0].std():.3f})" if seeds > 1 else ""
print(f"{name:<42} exactitud {runs[:, 0].mean():.3f}{spread:<9} | recall: benigna {runs[:, 1].mean():.3f}, "
f"sospechosa {runs[:, 2].mean():.3f}, maligna {runs[:, 3].mean():.3f} (±{runs[:, 3].std():.3f})")
Paso 4: El punto de partida: entrenar desde cero¶
La red empieza con pesos aleatorios y aprende solo de las 280 imágenes.
evaluate('Desde cero', lambda seed: train(CNN(), X_small, y_small, seed=seed))
Desde cero exactitud 0.710 (±0.033) | recall: benigna 0.927, sospechosa 0.442, maligna 0.556 (±0.126)
La red aprende la clase frecuente: reconoce el 0,927 de las lesiones benignas, pero solo el 0,442 de las sospechosas y el 0,556 de las malignas, con mucha variación de un entrenamiento a otro (±0,126): con 30 imágenes malignas, que la red las aprenda depende de la suerte. La exactitud global, 0,710, lo esconde. Es el mismo patrón que el primer prototipo de Luis, bueno con las benignas y peor con las malignas, "que son los que más nos interesan", como dice Ana.
Paso 5: El desequilibrio de clases: pesos de clase y sobremuestreo¶
Las dos técnicas de balanceo que menciona Ana:
- Pesos de clase, un ajuste de la función de pérdida: cada error en una imagen de la clase $c$ cuenta $w_c = \frac{N}{3\,N_c}$ veces, así que las clases raras pesan en total lo mismo que la frecuente. Con 180, 70 y 30 imágenes, $w = (0{,}52;\ 1{,}33;\ 3{,}11)$: un error en una lesión maligna cuesta seis veces más que un error en una benigna.
- Sobremuestreo: las imágenes de las clases raras se repiten, extraídas al azar con reemplazo, hasta que las tres clases tienen 180 cada una. Cada época tiene entonces 540 imágenes, así que entrenamos 20 épocas para mantener un número parecido de actualizaciones.
class_weights = len(y_small) / (3 * np.bincount(y_small))
print("Pesos de clase:", np.round(class_weights, 2))
def oversample(X, y, rng):
idx = np.concatenate([rng.choice(np.where(y == c)[0], np.bincount(y).max()) for c in range(3)])
return X[idx], y[idx]
evaluate('Desde cero + pesos de clase', lambda seed: train(CNN(), X_small, y_small, class_weights=class_weights, seed=seed))
evaluate('Desde cero + sobremuestreo', lambda seed: train(CNN(), *oversample(X_small, y_small, np.random.default_rng(seed)),
epochs=20, seed=seed))
Pesos de clase: [0.52 1.33 3.11]
Desde cero + pesos de clase exactitud 0.746 (±0.009) | recall: benigna 0.863, sospechosa 0.584, maligna 0.690 (±0.047)
Desde cero + sobremuestreo exactitud 0.724 (±0.019) | recall: benigna 0.769, sospechosa 0.643, maligna 0.738 (±0.056)
Las dos técnicas inclinan el equilibrio hacia las clases raras. Con pesos de clase, el recall de las lesiones malignas sube de 0,556 a 0,690 y el de las sospechosas de 0,442 a 0,584, mientras que las benignas bajan de 0,927 a 0,863; la exactitud sube a 0,746 y los resultados son mucho más estables (±0,009). El sobremuestreo va más lejos, 0,738 en las malignas y 0,643 en las sospechosas, a cambio de más errores en las benignas, 0,769. Ninguna de las dos crea información: solo cambian lo que la red considera un error caro.
Paso 6: El aumento de datos¶
La otra idea de Ana: "rotando, cambiando la iluminación o aplicando pequeñas distorsiones a las imágenes existentes". En cada lote, augment voltea las imágenes, las gira un número aleatorio de cuartos de vuelta (una lesión no tiene arriba ni abajo) y cambia un poco su brillo y su contraste, así que la red nunca ve dos veces exactamente la misma imagen. Lo combinamos con los pesos de clase y un dropout de 0,3. Primero, ocho versiones de la misma lesión maligna:
g = torch.Generator().manual_seed(0)
one = tensor(X_small[np.where(y_small == 2)[0][:1]])
fig, axes = plt.subplots(1, 8, figsize=(14, 2))
for ax in axes:
ax.imshow(augment(one, g)[0, 0], cmap='gray', vmin=0, vmax=1)
ax.axis('off')
plt.show()
evaluate('Desde cero + pesos + aumento de datos', lambda seed: train(CNN(dropout=0.3), X_small, y_small, class_weights=class_weights,
augmentation=True, seed=seed))
Desde cero + pesos + aumento de datos exactitud 0.755 (±0.017) | recall: benigna 0.865, sospechosa 0.562, maligna 0.770 (±0.025)
Las ocho versiones son la misma lesión girada, volteada e iluminada de otra forma, y para la red son ocho ejemplos distintos. Con el aumento de datos y el dropout añadidos a los pesos de clase, el recall de las lesiones malignas sube a 0,770, con menos variación (±0,025), y la exactitud a 0,755. Ayuda, pero menos de lo que cabría esperar: el aumento de datos varía lo que la red ya tiene, no añade lesiones nuevas.
Paso 7: El aprendizaje por transferencia desde las imágenes de otro hospital¶
El aprendizaje por transferencia, en palabras de Ana, "consiste en tomar un modelo que ya ha sido preentrenado en un dataset grande y adaptarlo a nuestro problema específico". Nuestro conjunto grande son 6.000 imágenes etiquetadas de otro hospital, el tipo de imágenes que proponía usar Carmen: el mismo tipo de lesiones, pero tomadas con otros equipos, más claras y con otro contraste, y con otras proporciones de las clases. Preentrenamos la red con ellas y después:
- La usamos tal cual con las imágenes de prueba de Minermont.
- Congelamos sus convoluciones, el extractor de características, y reentrenamos solo las capas densas con las 280 imágenes.
- Ajustamos (fine-tuning) la red completa con las 280 imágenes, con una tasa de aprendizaje más pequeña.
X_other, y_other = make_lesions(6000, seed=4, shares=(0.4, 0.3, 0.3))
X_other = capture(np.clip(0.25 + 0.75 * X_other ** 1.4, 0, 1).astype(np.float32), np.random.default_rng(5)) # otra cámara
torch.manual_seed(0)
pretrained = train(CNN(dropout=0.3), X_other, y_other, epochs=8)
print(f"Preentrenada con {len(y_other):,} imágenes de otro hospital\n")
evaluate('Preentrenada en otro sitio, usada tal cual', lambda seed: pretrained, seeds=1)
evaluate('Transferencia: convoluciones congeladas', lambda seed: train(copy.deepcopy(pretrained), X_small, y_small, class_weights=class_weights,
augmentation=True, only_classifier=True, seed=seed))
evaluate('Transferencia: red completa ajustada', lambda seed: train(copy.deepcopy(pretrained), X_small, y_small, lr=3e-4,
class_weights=class_weights, augmentation=True, seed=seed))
Preentrenada con 6,000 imágenes de otro hospital Preentrenada en otro sitio, usada tal cual exactitud 0.741 | recall: benigna 0.748, sospechosa 0.601, maligna 0.957 (±0.000)
Transferencia: convoluciones congeladas exactitud 0.813 (±0.007) | recall: benigna 0.869, sospechosa 0.678, maligna 0.883 (±0.009)
Transferencia: red completa ajustada exactitud 0.824 (±0.010) | recall: benigna 0.904, sospechosa 0.658, maligna 0.880 (±0.007)
Usada tal cual, la red del otro hospital llega a una exactitud de 0,741, pero no está adaptada a Minermont: detecta el 0,957 de las lesiones malignas y solo el 0,748 de las benignas, porque aprendió con otra cámara y con más lesiones malignas (el 30% de sus imágenes). Reentrenar solo sus capas densas con nuestras 280 imágenes da 0,813, y ajustar la red completa 0,824, con un recall de las malignas de alrededor de 0,88 y muy poca variación entre entrenamientos (de ±0,007 a ±0,010). Las convoluciones aprendidas con 6.000 imágenes son un punto de partida mejor que unos pesos aleatorios, y nuestras propias imágenes las adaptan a nuestras cámaras y a nuestras proporciones: justo las dos caras del argumento de Ana.
Paso 8: El doble de imágenes¶
Dos meses después, el equipo ha duplicado el conjunto de datos. Lo simulamos con 280 imágenes más, tomadas como las primeras, y repetimos la mejor receta sin transferencia y la receta con transferencia con 560 imágenes, 360 benignas, 140 sospechosas y 60 malignas.
X_double, y_double = np.concatenate([X_small, X_more]), np.concatenate([y_small, y_more])
evaluate('560 imágenes: desde cero + pesos + aumento', lambda seed: train(CNN(dropout=0.3), X_double, y_double, class_weights=class_weights,
augmentation=True, seed=seed))
evaluate('560 imágenes: transferencia, ajustada', lambda seed: train(copy.deepcopy(pretrained), X_double, y_double, lr=3e-4,
class_weights=class_weights, augmentation=True, seed=seed))
560 imágenes: desde cero + pesos + aumento exactitud 0.763 (±0.027) | recall: benigna 0.839, sospechosa 0.598, maligna 0.830 (±0.043)
560 imágenes: transferencia, ajustada exactitud 0.827 (±0.003) | recall: benigna 0.905, sospechosa 0.662, maligna 0.884 (±0.007)
Con 560 imágenes, la red entrenada desde cero mejora claramente: el recall de las lesiones malignas sube de 0,770 a 0,830 y la exactitud de 0,755 a 0,763. La red transferida apenas cambia, de 0,824 a 0,827 de exactitud y de 0,880 a 0,884 en las malignas: la mayor parte de lo que necesitaba ya venía del preentrenamiento. Ni siquiera con el doble de imágenes llega la red entrenada desde cero a la que partió de las imágenes del otro hospital con la mitad.
Paso 9: Resumen¶
names = list(results)
values = np.array([results[n] for n in names])
fig, ax = plt.subplots(figsize=(11, 5.5))
y_pos = np.arange(len(names))
ax.barh(y_pos - 0.2, values[:, 0], height=0.4, label='exactitud')
ax.barh(y_pos + 0.2, values[:, 3], height=0.4, label='recall de las lesiones malignas')
ax.set_yticks(y_pos, names); ax.invert_yaxis(); ax.set_xlim(0, 1); ax.grid(axis='x')
ax.legend(loc='upper right'); ax.set_title('Media de 5 entrenamientos con las 900 imágenes de prueba'); plt.tight_layout(); plt.show()
Las barras naranjas cuentan la historia que importa en dermatología: del 0,556 de las lesiones malignas detectadas por la primera red a alrededor de 0,88 con el aprendizaje por transferencia. Son imágenes sintéticas y los números son nuestros, no los del libro, pero los remedios son los que usa el equipo en el libro.
Conclusión¶
- Con pocas imágenes, una red entrenada desde cero aprende la clase frecuente y descuida las raras, que en medicina suelen ser las que importan.
- Los pesos de clase y el sobremuestreo le dicen a la red que los errores en las clases raras cuestan más. Suben el recall de las lesiones malignas a cambio de algunos errores en las benignas.
- El aumento de datos crea variaciones plausibles de las imágenes que ya tiene y, junto con el dropout, hace que la red dependa menos de los detalles de cada foto.
- El aprendizaje por transferencia parte de características aprendidas con un conjunto grande. Usado tal cual, un modelo de otro hospital no está adaptado a nuestras imágenes ni a nuestras proporciones, como advierte Ana; adaptado con nuestras 280 imágenes, es la mejor de las recetas.
- Más datos propios ayudan sobre todo donde la red tiene que aprenderlo todo de ellos: duplicar el conjunto de datos mejora mucho más la red entrenada desde cero que la transferida.