Universidad Alfonso X el Sabio
Tema 2Introducción al caso: cáncer de mama y pCR
← Tema 2: todos los materiales · Material docente · versión web del cuaderno ya ejecutado

Cuaderno 5 · Introducción al caso: cáncer de mama, pCR y la imagen

Qué queremos predecir, por qué importa y qué hay dentro de una resonancia

Universidad Alfonso X el Sabio · Tema 2

Antes de construir ninguna red neuronal hay que entender el problema médico. Este cuaderno es la introducción al caso y va de lo clínico a lo técnico:

Parte Qué vas a entender Animación
0 El caso entero, de un vistazo 🎬 de la paciente a la red, y de vuelta a la patología
1 Qué es el cáncer de mama y cómo se diagnostica
2 La quimioterapia antes de operar y qué es la pCR 🎬 dos pacientes, mismo tratamiento
3 Por qué la pCR importa y qué dicen nuestros datos 🎬 la pCR por subtipo
4 Cómo se lee una resonancia de mama y dónde está el tumor 🎬 leer un corte, paso a paso
5 Qué son la intensidad y el tiempo en la imagen 🎬 un píxel del tumor y uno normal; curvas
6 Qué es un corte y por qué hay muchos por paciente 🎬 la mama vista de lado y sus cortes
7 El tratamiento de la imagen antes de la red 🎬 del PNG al tensor
8 Los kernels sobre la resonancia 🎬 kernels 3×3; 🎬 cómo 3 imágenes se convierten en 1
9 Del bloque 1 al bloque 2 🎬 paso a paso; 🎬 qué pasa si cambia un filtro

Cómo ver las animaciones 🎬: pulsa ▶, o usa los botones de paso para ir fotograma a fotograma. Cada una lleva antes una explicación de qué vas a ver y después un resumen de lo que hay que llevarse.

⚠️ Uso educativo. Este material explica un problema de investigación. No sirve para diagnosticar ni para decidir un tratamiento. Los esquemas clínicos están simplificados.

código
# Si falta algo, descomenta, ejecuta una vez y reinicia el kernel:
# %pip install numpy pandas matplotlib pillow scikit-learn scipy torch
# Datos (38.109 PNG, ~1,4 GB):  !python ../alumnos/breastdcedl/descargar_datos.py

from pathlib import Path
import sys
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from IPython.display import display, Image as ImagenArchivo
from PIL import Image
import torch
import torch.nn.functional as F

AQUI = next((p.resolve() for p in [Path.cwd(), Path.cwd() / 'ejemplo_clase',
                                    Path.cwd() / 'breastdcedl-usecase/ejemplo_clase']
             if (p / 'animaciones_clinica.py').exists()), None)
if AQUI is None:
    raise FileNotFoundError('Abre el cuaderno desde la carpeta ejemplo_clase.')
SOL, RAIZ = AQUI.parent / 'solution', AQUI.parent / 'alumnos/breastdcedl'
for carpeta in (SOL, AQUI):
    sys.path.insert(0, str(carpeta))
if not (RAIZ / 'metadata/samples.csv').exists():
    raise FileNotFoundError(f'No encuentro los datos en {RAIZ}. Descárgalos antes de seguir.')

from datos import leer_indice, CortesMama
from dibujos import estilo
import animaciones as anim
import animaciones_clinica as clin

estilo()
print('Datos:', RAIZ)
Datos: …/breastdcedl-usecase\alumnos\breastdcedl

La paciente que vamos a seguir

Para explicar la imagen usamos una paciente real del dataset en la que el tumor se ve con claridad: la hemos elegido por eso, para enseñar, no al azar. Leemos sus cortes y, en cada uno, localizamos de forma aproximada la zona que más capta contraste dentro de la mama (el dataset no trae el contorno exacto del tumor).

código
indice = leer_indice(RAIZ)
pacientes = pd.read_csv(RAIZ / 'metadata/patients.csv')
PACIENTE = 'ACRIN-6698-218503'                     # elegida porque el tumor se ve claro

cortes_p = indice[indice.patient_id == PACIENTE].sort_values('slice_index').reset_index(drop=True)
volumen = torch.stack([x for x, _ in CortesMama(cortes_p, RAIZ)]).numpy()      # (N, 3, 256, 256)
alturas = cortes_p.slice_index.tolist()
medio = len(cortes_p) // 2
corte = volumen[medio]                                                         # (3, 256, 256): PRE, EARLY, LATE
info = clin.localizar_mama(corte)
zonas = [clin.localizar_mama(v)['zona'] for v in volumen]

ficha = pacientes.set_index('pid').loc[PACIENTE]
print(f'Paciente {PACIENTE}')
print(f'  cohorte: {ficha.dataset}   ·   edad: {ficha.age:.0f}   ·   subtipo: {ficha.HR_HER2_STATUS}   ·   pCR: {int(ficha.pCR)}')
print(f'  {len(cortes_p)} cortes (alturas {alturas[0]} a {alturas[-1]}); cada corte = 3 imágenes PNG de 256×256')
Paciente ACRIN-6698-218503
  cohorte: spy2   ·   edad: 52   ·   subtipo: HRposHER2neg   ·   pCR: 0
  10 cortes (alturas 5 a 14); cada corte = 3 imágenes PNG de 256×256

0. El caso entero, de un vistazo

Esta ilustración, de la documentación del caso, resume la historia de dos pacientes: la resonancia del principio, la quimioterapia, la pieza quirúrgica analizada al microscopio y el resultado: respuesta completa (✔) o enfermedad residual (✘).

código
figura = RAIZ / 'documentation/figures/pcr_two_patient_paths.png'
if figura.exists():
    display(ImagenArchivo(filename=str(figura), width=900))
Figura 1
Figura 1. El caso entero, de un vistazo.

0.1. Nuestro problema, en 8 viñetas

Pulsa ▶. Cada viñeta se enciende con una frase arriba. Fíjate en la flecha verde del final: el reto es usar la imagen de antes (viñeta 2) para anticipar un resultado que solo se conoce meses después (viñeta 7).

código
clin.animar_mapa_del_caso(volumen, info)
Animación 1. Nuestro problema, en 8 viñetas. Pulsa ▶ o avanza fotograma a fotograma.

1. El cáncer de mama y cómo se diagnostica

Qué es

La mama está formada sobre todo por grasa y por tejido fibroglandular: las glándulas que producen leche (lobulillos) y los conductos que la llevan al pezón. El cáncer de mama aparece cuando células de esos conductos o lobulillos crecen sin control. Si rompen la pared del conducto e invaden el tejido de alrededor, se habla de carcinoma invasivo; si siguen dentro, de carcinoma in situ.

Es el cáncer más frecuente en mujeres: aproximadamente 1 de cada 8 lo tendrá a lo largo de su vida.

El camino del diagnóstico

Paso Qué se hace Qué aporta
1. Sospecha mamografía de cribado, o un bulto que nota la paciente una zona sospechosa
2. Ecografía se mira la zona con ultrasonidos forma y tamaño; guía la aguja
3. Biopsia con una aguja se saca un cilindro de tejido y lo analiza el patólogo confirma que es cáncer, su tipo y su subtipo
4. Resonancia magnética imágenes de las dos mamas con contraste tamaño real, si hay más de un foco, cómo capta el contraste
5. Estudio de extensión otras pruebas, según el caso si se ha extendido a otros órganos
6. Comité oncólogos, cirujanos, radiólogos, patólogos deciden el tratamiento

El subtipo: la biología del tumor

En la biopsia se mira si las células tienen ciertas «antenas» (receptores) en su superficie. Eso define el subtipo, que cambia el tratamiento y, como veremos, la probabilidad de responder:

Subtipo Qué significa Tratamiento dirigido
HR+ / HER2− tiene receptores hormonales (estrógeno, progesterona): crece con las hormonas tratamiento hormonal
HER2+ tiene muchos receptores HER2: crece deprisa fármacos anti-HER2
Triple negativo no tiene ni receptores hormonales ni HER2 no hay diana: quimioterapia

En patients.csv están el subtipo de cada paciente (HR_HER2_STATUS) y su edad.

2. La quimioterapia antes de operar y qué es la pCR

En muchas pacientes, sobre todo con tumores grandes o de subtipos agresivos (triple negativo, HER2+), el tratamiento no empieza por la cirugía, sino por la quimioterapia neoadyuvante: medicación antes de operar, durante varios meses. Tiene dos ventajas: puede reducir el tumor (y permitir una cirugía menor) y deja ver cómo responde el tumor al tratamiento.

Después se opera, y el patólogo analiza al microscopio todo el tejido extirpado, de la mama y de los ganglios de la axila. Ahí nace la etiqueta de nuestro problema:

pCR (respuesta patológica completa): tras la quimioterapia neoadyuvante, no queda carcinoma invasivo ni en la mama ni en los ganglios axilares (en nomenclatura patológica, ypT0/is ypN0; puede quedar carcinoma in situ).

2.1. Dos pacientes, el mismo tratamiento

Qué vas a ver: dos líneas de tiempo. El círculo oscuro dentro de la mama es el tumor. Durante los ciclos de quimioterapia encoge: en la paciente A desaparece; en la B no del todo. La flecha naranja marca cuándo queremos predecir (al diagnóstico) y la verde, cuándo se conoce la respuesta (tras la cirugía).

código
clin.animar_recorrido_paciente()
Animación 2. Dos pacientes, el mismo tratamiento. Pulsa ▶ o avanza fotograma a fotograma.

Lo que hay que llevarse: la pCR no se ve en la imagen del principio: es un resultado que se conoce meses después, al microscopio. Nuestra red intentará anticiparlo con la resonancia tomada antes del tratamiento.

3. Por qué la pCR es tan importante

1. Dice mucho del pronóstico. Las pacientes que alcanzan pCR tienen, en conjunto, mejor supervivencia libre de enfermedad y mejor supervivencia global que las que tienen enfermedad residual. La asociación es especialmente fuerte en los tumores triple negativos y HER2+, y más débil en los HR+/HER2− (análisis agrupado CTNeoBC, Cortazar et al., The Lancet, 2014).

2. Se usa para evaluar tratamientos. Se conoce en meses, no en años, así que se usa como objetivo en ensayos clínicos. I-SPY 2, de donde vienen muchas imágenes de este dataset, es un ensayo que usa la pCR para decidir qué fármacos avanzan.

3. Cambia lo que se hace después. Si queda enfermedad residual, en algunos subtipos se añade tratamiento tras la cirugía (por ejemplo, en los ensayos CREATE-X, con tumores HER2−, y KATHERINE, con HER2+, eso mejoró los resultados).

4. Por eso interesaría predecirla antes. Saber pronto qué paciente probablemente no responderá permitiría, en teoría, cambiar de estrategia a tiempo. Hoy es un problema de investigación: ningún modelo sustituye a la anatomía patológica.

3.1. Qué dicen nuestros datos

Qué vas a ver: a la izquierda, 100 cuadraditos que representan a 100 pacientes; los verdes son las que alcanzan pCR. A la derecha, la proporción de pCR por subtipo. Las cifras salen del dataset (patients.csv, parte de entrenamiento), no de un libro.

código
train = pacientes[pacientes.split == 'train']
nombres = {'HER2pos': 'HER2 positivo', 'TripleNeg': 'Triple negativo', 'HRposHER2neg': 'HR+ / HER2−'}
por_subtipo = (train.groupby('HR_HER2_STATUS').pCR.agg(['count', 'mean']).reindex(list(nombres))
               .rename(index=nombres))
display(por_subtipo.rename(columns={'count': 'pacientes', 'mean': 'proporción con pCR'}).round(3))
display(train.groupby('dataset').pCR.agg(['count', 'mean']).rename(
    columns={'count': 'pacientes', 'mean': 'proporción con pCR'}).round(3))

clin.animar_pcr_en_datos(len(train), int(train.pCR.sum()),
                         [(n, int(f['count']), float(f['mean'])) for n, f in por_subtipo.iterrows()])
pacientes proporción con pCR
HR_HER2_STATUS
HER2 positivo 287 0.429
Triple negativo 354 0.379
HR+ / HER2− 453 0.141
pacientes proporción con pCR
dataset
duke 209 0.211
spy1 104 0.250
spy2 784 0.321
Animación 3. Qué dicen nuestros datos. Pulsa ▶ o avanza fotograma a fotograma.

Lo que hay que llevarse:

4. Cómo se lee una resonancia de mama, y dónde está el tumor

Antes de pedirle nada a una red, hay que saber qué hay en la imagen.

4.1. Leer un corte, paso a paso

Qué vas a ver: el corte central de nuestra paciente y, a la derecha, una explicación que cambia en cada fotograma. Recorremos, en orden: qué tipo de imagen es, el aire, la piel, el lado del tórax, el interior de la mama, qué pasa al inyectar el contraste, dónde está el tumor y por qué no todo lo que brilla es tumor.

Es mejor verla fotograma a fotograma con los botones de paso.

código
clin.animar_leer_resonancia(corte, info)
Animación 4. Leer un corte, paso a paso. Pulsa ▶ o avanza fotograma a fotograma.

Lo que hay que llevarse:

5. Qué son la «intensidad» y el «tiempo» en la imagen

Intensidad

Intensidad es, simplemente, lo brillante que se ve un píxel. En nuestras imágenes es un número entre 0 (negro) y 1 (blanco). En resonancia, un tejido brilla más o menos según su composición y, sobre todo aquí, según cuánto contraste ha llegado a él.

Tiempo

La resonancia de mama con contraste (DCE-RM, dynamic contrast-enhanced) no es una foto: son varias fotos seguidas del mismo sitio.

  1. Se hace una imagen antes de inyectar nada: PRE.
  2. Se inyecta por una vena un contraste (gadolinio), que viaja con la sangre.
  3. Se repiten imágenes pasados unos minutos. En el dataset hay dos: EARLY (temprana) y LATE (tardía). En protocolos como el de I-SPY, aproximadamente a los 2-3 y a los 7-8 minutos; cada hospital puede usar tiempos algo distintos.

Así que PRE, EARLY y LATE son el mismo corte en tres momentos. Lo que cambia de una a otra es cuánto brilla cada píxel con el paso del tiempo.

5.1. Un píxel del tumor y un píxel normal

Qué vas a ver:

código
clin.animar_intensidad_tiempo(corte, info)
Animación 5. Un píxel del tumor y un píxel normal. Pulsa ▶ o avanza fotograma a fotograma.

Lo que hay que llevarse: el tumor (A) sube mucho al llegar el contraste; el tejido normal (B) casi no cambia. Esa diferencia en el tiempo es la principal pista de la imagen. Por eso a la red le daremos las tres fases juntas.

(Si ves que A llega a 1,00, es porque la escala del dataset satura: 1 es el máximo brillo representable.)

5.2. Las formas de la curva

Los radiólogos se fijan en cómo evoluciona la intensidad de una lesión. Hay tres formas típicas:

Qué vas a ver: a la izquierda, un esquema de las tres curvas dibujándose (no son datos de pacientes). A la derecha, lo que tenemos de verdad en el dataset para los puntos A y B: solo tres valores, PRE, EARLY y LATE. Con tres puntos no se dibuja la curva entera, pero sí se ve si sube, se mantiene o baja.

código
suave = np.stack([clin.ndi.uniform_filter(corte[j], 5) for j in range(3)])
reales = {'A · tumor': suave[:, info['centro'][0], info['centro'][1]],
          'B · tejido normal': suave[:, info['normal'][0], info['normal'][1]]}
clin.animar_curvas_cineticas(reales)
Animación 6. Las formas de la curva. Pulsa ▶ o avanza fotograma a fotograma.

6. Qué es un corte y por qué hay muchos por paciente

La resonancia no es una imagen plana: es un volumen en 3D. El ordenador lo guarda como una pila de rebanadas horizontales, igual que un pan de molde cortado. Cada rebanada es un corte, y su posición es la altura z.

6.1. La mama vista de lado y sus cortes

Qué vas a ver:

Mira cómo el tumor cambia de tamaño y de forma al subir y bajar.

código
clin.animar_volumen_cortes(volumen, alturas, zonas)
Animación 7. La mama vista de lado y sus cortes. Pulsa ▶ o avanza fotograma a fotograma.

Lo que hay que llevarse: la red verá cortes sueltos, cada uno con sus 3 fases, pero la decisión final es por paciente. Por eso, más adelante, habrá que juntar las predicciones de todos sus cortes, y separar los datos por paciente, nunca por corte.

7. El tratamiento de la imagen

Lo que ya viene hecho en el dataset

Las resonancias originales son volúmenes médicos con tamaños, resoluciones y escalas distintas según el hospital. Para este caso ya se han preparado así:

Lo que hacemos nosotros

Paso Qué se hace Por qué
1 leer los 3 PNG vienen como números enteros de 0 a 255
2 dividir entre 255 números entre 0 y 1: la escala que prefiere la red
3 apilar las 3 fases un tensor (3, 256, 256), siempre en el orden PRE, EARLY, LATE
4 y 5 mirar las restas EARLY − PRE (realce) y EARLY − LATE (lavado): la información está ahí
6 voltear las 3 fases juntas aumento de datos correcto
7 voltear solo una el error típico: rompe la resta
8 reducir el tamaño más rápido, pero se pierde detalle

7.1. La cadena, con nuestro corte

Qué vas a ver: 8 fotogramas, uno por paso de la tabla, con las imágenes antes y después y, en algunos, el histograma de valores. Fíjate sobre todo en el paso 7: aparecen «realces» fantasma que no existen.

código
fila = cortes_p.iloc[medio]
crudo = {f: np.asarray(Image.open(RAIZ / fila[f'path_{f.lower()}'])) for f in ['PRE', 'EARLY', 'LATE']}
pre, early, late = corte
realce_img, lavado_img = early - pre, early - late
early_128 = F.avg_pool2d(torch.tensor(early)[None, None], 2)[0, 0].numpy()
early_64 = F.avg_pool2d(torch.tensor(early)[None, None], 4)[0, 0].numpy()

pasos = [
    dict(titulo='el PNG tal cual', hist=(crudo['EARLY'], 'valor del píxel en EARLY (0 a 255)'),
         paneles=[(crudo[f], 'gray', 0, 255, f'{f}: enteros de 0 a 255') for f in ['PRE', 'EARLY', 'LATE']],
         texto='Tres PNG en gris de 8 bits. Misma escala de brillo en las tres: se pueden comparar.'),
    dict(titulo='dividir entre 255', hist=(early, 'valor del píxel en EARLY (0 a 1)'),
         paneles=[(v, 'gray', 0, 1, f'{f}: de 0 a 1') for f, v in zip(['PRE', 'EARLY', 'LATE'], [pre, early, late])],
         texto='Números pequeños y en la misma escala. A la vista la imagen no cambia: cambian los números.'),
    dict(titulo='apilar las 3 fases: tensor (3, 256, 256)',
         paneles=[(pre, 'gray', 0, 1, 'canal 0: PRE'), (early, 'gray', 0, 1, 'canal 1: EARLY'),
                  (late, 'gray', 0, 1, 'canal 2: LATE'), (corte.transpose(1, 2, 0), None, None, None, 'las 3 pintadas como R, G, B')],
         texto='Canal = momento. El orden (PRE, EARLY, LATE) tiene que ser SIEMPRE el mismo en todas las pacientes.'),
    dict(titulo='la resta EARLY − PRE: el realce', hist=(realce_img, 'EARLY − PRE'),
         paneles=[(pre, 'gray', 0, 1, 'PRE'), (early, 'gray', 0, 1, 'EARLY'),
                  (realce_img, 'RdBu_r', -.6, .6, 'EARLY − PRE (rojo = sube)')],
         texto='Lo que se ilumina al llegar el contraste: el tumor, y también el tórax.'),
    dict(titulo='la resta EARLY − LATE: el lavado', hist=(lavado_img, 'EARLY − LATE'),
         paneles=[(early, 'gray', 0, 1, 'EARLY'), (late, 'gray', 0, 1, 'LATE'),
                  (lavado_img, 'RdBu_r', -.4, .4, 'EARLY − LATE (rojo = baja después)')],
         texto='Dónde la señal baja de EARLY a LATE: el patrón de lavado de la curva de tipo III.'),
    dict(titulo='aumento de datos BIEN hecho: voltear las 3 fases juntas',
         paneles=[(realce_img, 'RdBu_r', -.6, .6, 'realce original'),
                  (early[:, ::-1], 'gray', 0, 1, 'EARLY volteada'),
                  (early[:, ::-1] - pre[:, ::-1], 'RdBu_r', -.6, .6, 'realce con las 3 volteadas')],
         texto='Una mama volteada sigue siendo una mama válida, y la resta sigue comparando el mismo tejido.'),
    dict(titulo='aumento de datos MAL hecho: voltear solo EARLY',
         paneles=[(early[:, ::-1], 'gray', 0, 1, 'EARLY volteada'), (pre, 'gray', 0, 1, 'PRE sin voltear'),
                  (early[:, ::-1] - pre, 'RdBu_r', -.6, .6, 'EARLY − PRE: ¡rota!')],
         texto='Se restan tejidos distintos: aparecen realces fantasma. Por eso datos.py voltea las tres fases a la vez.'),
    dict(titulo='reducir el tamaño: 256 → 128 → 64',
         paneles=[(early, 'gray', 0, 1, '256×256'), (early_128, 'gray', 0, 1, '128×128'),
                  (early_64, 'gray', 0, 1, '64×64')],
         texto='Entrena mucho más rápido, pero se pierde detalle: un tumor pequeño queda en muy pocos píxeles.'),
]
clin.animar_pasos(pasos)
Animación 8. La cadena, con nuestro corte. Pulsa ▶ o avanza fotograma a fotograma.

8. Convoluciones con distintos kernels

Un kernel (o filtro) es una tablita de números, por ejemplo de 3×3. La convolución lo va poniendo encima de cada píxel de la imagen, multiplica cada número del kernel por el píxel que tiene debajo, suma los 9 productos y escribe el resultado en ese píxel de una imagen nueva.

El cálculo es siempre el mismo. Lo que cambia es el kernel, y con él lo que se resalta. Una red neuronal convolucional es, en el fondo, muchos kernels cuyos números aprende sola.

8.1. Kernels de 3×3 sobre la fase EARLY

Qué vas a ver:

Pista para leerlo: si los 9 números suman 0, el kernel ignora el brillo y solo ve cambios (bordes), en rojo y azul según el signo. Si suman 1, la imagen se sigue pareciendo a la original.

código
FILTROS = {
    'Identidad': ([[0, 0, 0], [0, 1, 0], [0, 0, 0]],
                  'Un 1 en el centro: cada píxel\nse queda igual. La imagen no cambia.'),
    'Difuminar': ([[1 / 9] * 3] * 3,
                  'La media de 9 píxeles vecinos:\nmenos ruido, pero bordes borrosos.'),
    'Bordes verticales': ([[-1, 0, 1], [-2, 0, 2], [-1, 0, 1]],
                          'Resta la izquierda y suma la derecha:\nmarca el contorno de la piel\ny del tumor en vertical.'),
    'Bordes horizontales': ([[-1, -2, -1], [0, 0, 0], [1, 2, 1]],
                            'Resta arriba y suma abajo:\ncontornos en horizontal.'),
    'Todos los bordes': ([[-1, -1, -1], [-1, 8, -1], [-1, -1, -1]],
                         'El centro contra sus 8 vecinos:\ncualquier contorno. También realza\nel ruido.'),
    'Realzar': ([[0, -1, 0], [-1, 5, -1], [0, -1, 0]],
                'La imagen más sus bordes:\nmás nitidez.'),
}
anim.animar_filtros_foto(early, FILTROS)
Animación 9. Kernels de 3×3 sobre la fase EARLY. Pulsa ▶ o avanza fotograma a fotograma.

8.2. ¿Cómo pasan 3 imágenes a ser 1?

En la red, la primera convolución no recibe una imagen, sino las 3 fases a la vez. ¿Cómo sale una imagen de tres? Así:

  1. El kernel no tiene una capa de 3×3: tiene 3 capas, una para cada fase (27 números).
  2. Cada capa hace una convolución normal con su fase: la capa PRE con la imagen PRE, la capa EARLY con la EARLY, la capa LATE con la LATE. Salen 3 «aportaciones».
  3. Las 3 aportaciones se suman píxel a píxel: sale una sola imagen.

Por ejemplo, con −1 en la capa PRE, +1 en la capa EARLY y 0 en la capa LATE, el resultado es EARLY − PRE: el kernel ha aprendido a medir el cambio en el tiempo.

Qué vas a ver: el primer kernel se construye en 4 pasos (las fases, las capas, las 3 aportaciones y la suma), con las flechas y los signos + y =. Después pasan otros cinco kernels de golpe, con su explicación debajo.

código
def capas(pre=None, early=None, late=None):
    """Un kernel (3, 3, 3) a partir de una tabla 3×3 por fase (None = todo ceros)."""
    cero = np.zeros((3, 3))
    return np.stack([np.asarray(c if c is not None else cero, dtype=np.float32) for c in (pre, early, late)])

centro = np.array([[0, 0, 0], [0, 1, 0], [0, 0, 0]], dtype=np.float32)
media = np.ones((3, 3), dtype=np.float32) / 9
laplace = np.array([[-1, -1, -1], [-1, 8, -1], [-1, -1, -1]], dtype=np.float32) / 8

FILTROS_3FASES = {
    'realce: EARLY − PRE': (capas(pre=-centro, early=centro),
                            'Resta el antes del después: rojo donde la señal SUBE al llegar el contraste.'),
    'solo PRE': (capas(pre=centro),
                 'Copia la fase PRE (las otras capas son ceros): la anatomía antes del contraste.'),
    'media de las 3 fases': (capas(centro / 3, centro / 3, centro / 3),
                             'Promedia los tres momentos: se ve la mama, pero SE PIERDE el tiempo.'),
    'realce suavizado': (capas(pre=-media, early=media),
                         'La misma resta, promediada en 3×3: menos ruido.'),
    'lavado: EARLY − LATE': (capas(early=centro, late=-centro),
                             'Rojo donde la señal BAJA de EARLY a LATE: el patrón de lavado.'),
    'bordes del realce': (capas(pre=-laplace, early=laplace),
                          'Bordes de la imagen EARLY − PRE: el contorno de lo que capta contraste.'),
}
clin.animar_filtros_3fases(corte, FILTROS_3FASES)
Animación 10. ¿Cómo pasan 3 imágenes a ser 1? Pulsa ▶ o avanza fotograma a fotograma.

Lo que hay que llevarse: un kernel de la primera capa mezcla las tres fases en una imagen. Según sus 27 números puede copiar una fase, promediarlas (y perder el tiempo) o restarlas (y medir el cambio). Nadie elige esos números en la red de verdad: los aprende al entrenar.

9. Del bloque 1 al bloque 2

Una red convolucional se organiza en bloques. Cada bloque tiene varios kernels, y cada kernel produce un mapa (una imagen que dice dónde encontró «lo suyo»). Lo importante:

El bloque 2 no ve la resonancia. Ve los mapas que salen del bloque 1, y los combina.

Para entenderlo sin perderse, construimos una red de juguete con kernels que ya conocemos:

Bloque 1 Qué detecta
filtro 0: realce EARLY − PRE dónde sube la señal al llegar el contraste
filtro 1: bordes en EARLY los contornos
filtro 2: lavado EARLY − LATE dónde la señal baja después
filtro 3: brillo en EARLY lo que es brillante, capte contraste o no

Bloque 2: un solo filtro que suma los 4 mapas con estos pesos: +1,0 (realce), +0,6 (bordes), +0,8 (lavado) y −0,4 (brillo). En palabras: «zona que capta contraste, con borde y con lavado, que no sea simplemente brillante».

9.1. Paso a paso

Qué vas a ver, en 8 fotogramas:

  1. Entra el corte (sus 3 fases).
  2. a 5. Cada filtro del bloque 1, con sus 3 capas arriba, produce su mapa (fila del medio). Se van acumulando.
  3. ReLU y MaxPool: lo negativo se apaga y los mapas se reducen a la mitad.
  4. El bloque 2 da un peso a cada mapa (verde suma, rojo resta).
  5. La salida del bloque 2: la suma de los 4 mapas con sus pesos.
código
FILTROS_BLOQUE1 = [
    ('realce EARLY − PRE', capas(pre=-media, early=media), 'Positivo donde la señal SUBE al llegar el contraste.'),
    ('bordes en EARLY', capas(early=laplace), 'Positivo en los contornos, donde el brillo cambia de golpe.'),
    ('lavado EARLY − LATE', capas(early=media, late=-media), 'Positivo donde la señal BAJA de EARLY a LATE.'),
    ('brillo en EARLY', capas(early=media), 'Positivo donde EARLY es brillante, capte contraste o no.'),
]
PESOS_BLOQUE2 = [1.0, 0.6, 0.8, -0.4]
clin.animar_bloque1_a_bloque2(corte, FILTROS_BLOQUE1, PESOS_BLOQUE2)
Animación 11. Paso a paso. Pulsa ▶ o avanza fotograma a fotograma.

Lo que hay que llevarse: la salida del bloque 2 brilla donde coinciden los mapas con peso positivo. Es una idea más compleja que la de cualquier filtro del bloque 1 por separado. Así es como una red construye, bloque a bloque, detectores cada vez más elaborados.

9.2. ¿Y si cambiamos un filtro del bloque 1?

Ahora dejamos el bloque 2 igual y cambiamos solo el filtro 0 del bloque 1. Si el bloque 2 depende de lo que le llega, su salida debería cambiar.

Qué vas a ver:

Predice antes de pulsar: si el filtro 0 deja de calcular el realce y pasa a difuminar la imagen, ¿qué le pasará a la zona del tumor en el bloque 2?

código
fijos = [(n, k) for n, k, _ in FILTROS_BLOQUE1[1:]]
sobel_h = np.array([[-1, -2, -1], [0, 0, 0], [1, 2, 1]], dtype=np.float32) / 4
opciones = [
    ('realce: EARLY − PRE', capas(pre=-media, early=media),
     'Referencia: el bloque 2 marca lo que capta\ncontraste y además tiene borde o lavado.'),
    ('difuminar EARLY', capas(early=media),
     'Ya no mira el tiempo, solo el brillo: entran\nzonas brillantes que no captan contraste.'),
    ('bordes horizontales en EARLY', capas(early=sobel_h),
     'Solo contornos: el bloque 2 pierde la\ninformación del contraste en el tumor.'),
    ('realce tardío: LATE − PRE', capas(pre=-media, late=media),
     'Parecido al realce: el bloque 2 se parece\nmucho a la referencia.'),
    ('todo a cero', capas(),
     'El filtro 0 no aporta nada: el bloque 2 solo\npuede usar los otros tres mapas.'),
]
clin.animar_efecto_bloque2(corte, fijos, opciones, PESOS_BLOQUE2)
Animación 12. ¿Y si cambiamos un filtro del bloque 1? Pulsa ▶ o avanza fotograma a fotograma.

Lo que hay que llevarse:

🧪 Prueba tú: cambia PESOS_BLOQUE2 (por ejemplo, el del lavado a 0 o el del brillo a +1) y vuelve a ejecutar las dos celdas de animación.

Resumen de la clase

Idea En una frase
Diagnóstico sospecha → ecografía → biopsia (confirma y da el subtipo) → resonancia (tamaño y captación)
Neoadyuvancia quimioterapia antes de operar, durante meses
pCR tras ella, no queda carcinoma invasivo en mama ni ganglios; se sabe tras la cirugía
Por qué importa mejor pronóstico (sobre todo TN y HER2+), objetivo de ensayos, cambia el tratamiento posterior
Nuestros datos 29 % con pCR; 43 % HER2+, 38 % triple negativo, 14 % HR+/HER2−
La imagen rebanadas de la mama; el tumor se ilumina con el contraste, pero no todo lo que brilla es tumor
Intensidad el brillo de un píxel: un número de 0 a 1
Tiempo PRE, EARLY y LATE son el mismo corte en tres momentos; la pista está en cómo cambia
Cortes ~10 por paciente, todos con la etiqueta de la paciente
Kernels tablitas de números; en 3 fases, 3 capas que se suman en una imagen
Bloques el bloque 2 combina los mapas del bloque 1; si cambia el bloque 1, cambia todo lo demás

Preguntas para abrir el debate

  1. ¿Por qué no basta con medir el tamaño del tumor en la resonancia para predecir la pCR?
  2. Si el modelo acertara mucho en HR+/HER2− solo diciendo «no pCR», ¿sería un buen modelo? ¿Cómo lo detectarías?
  3. Las cohortes se procesaron de forma distinta. ¿Cómo podría la red aprender «de qué hospital viene la imagen» en lugar de «si habrá pCR»?
  4. ¿Qué información útil para predecir la pCR no está en la imagen?
Algunas pistas
  1. Porque la pCR depende de la biología (subtipo, vascularización, sensibilidad al fármaco), no solo del tamaño. Parte de esa biología se refleja en cómo capta el contraste.
  2. No: estaría aprovechando el desbalance. Hay que mirar métricas por subtipo, sensibilidad, especificidad y AUC.
  3. Si una cohorte tiene más pCR y sus imágenes «se ven» distintas, la red puede usar ese atajo. Se detecta evaluando por cohorte.
  4. El subtipo, el fármaco concreto, la edad, el grado… Están en patients.csv, pero el enunciado pide trabajar con la imagen.

Siguientes cuadernos: CNN_2_resonancias_mama.ipynb (la red recorriendo una resonancia) y CNN_3_resonancias_arquitectura.ipynb (3 canales, N cortes por paciente y la arquitectura línea a línea).