Módulo 7 · Visión por computadora

Lección 5 de 5

Misión de visión

En esta páginaEl problema

El problema

Un concurso de visión publica imágenes sintéticas en escala de grises de 16 × 16 píxeles. Cada imagen contiene una figura: círculo, cuadrado, triángulo o cruz. La figura cambia de tamaño y de posición, y cada píxel lleva ruido gaussiano. Estas son las cuatro figuras sin ruido, con el mismo centro y el mismo tamaño (# marca los píxeles encendidos):

círculo            cuadrado           triángulo          cruz
................   ................   ................   ................
................   ................   ................   ................
................   ................   ................   ................
.......#........   ...#########....   .......#........   ......###.......
.....#####......   ...#########....   .......#........   ......###.......
....#######.....   ...#########....   ......###.......   ......###.......
....#######.....   ...#########....   ......###.......   ...#########....
...#########....   ...#########....   .....#####......   ...#########....
....#######.....   ...#########....   .....#####......   ...#########....
....#######.....   ...#########....   ....#######.....   ......###.......
.....#####......   ...#########....   ....#######.....   ......###.......
.......#........   ...#########....   ...#########....   ......###.......
................   ................   ................   ................
................   ................   ................   ................
................   ................   ................   ................
................   ................   ................   ................

El organizador entrega 480 imágenes etiquetadas y 400 imágenes de prueba sin etiqueta, cada una con un id. Guarda las 400 etiquetas y califica el envío con ellas.

reglavalor
métricaexactitud en prueba
archivoCSV con las columnas id,clase, en ese orden
filasuna por imagen de prueba, en el orden de prueba
valoresclase es uno de los cuatro nombres, sin celdas vacías
envíosuno

Partición y baseline

La celda plegada simula el concurso. dibujar(clase, cx, cy, r) devuelve la figura sin ruido como una matriz de 16 × 16 con unos y ceros. generar elige para cada imagen una clase, un tamaño r entre 3 y 5 y un centro. El centro deja al menos dos píxeles libres entre la figura y cada borde. El ruido tiene desviación estándar 0.3. CLASES guarda los cuatro nombres en el orden de las etiquetas 0 a 3. mostrar convierte una imagen en renglones de texto: # donde el píxel supera 0.5 y . en el resto.

Mostrar códigoOcultar códigoPython
Python 3 · ejemplo reproducible
import numpy as np
import pandas as pd
import torch
from torch import nn

CLASES = ["círculo", "cuadrado", "triángulo", "cruz"]


def dibujar(clase, cx, cy, r):
    yy, xx = np.mgrid[0:16, 0:16]
    if clase == 0:
        figura = (xx - cx) ** 2 + (yy - cy) ** 2 <= r**2
    elif clase == 1:
        figura = (abs(xx - cx) <= r) & (abs(yy - cy) <= r)
    elif clase == 2:
        figura = (yy >= cy - r) & (yy <= cy + r) & (abs(xx - cx) <= (yy - cy + r) / 2)
    else:
        figura = ((abs(xx - cx) <= 1) & (abs(yy - cy) <= r)) | (
            (abs(yy - cy) <= 1) & (abs(xx - cx) <= r)
        )
    return figura.astype(np.float32)


def generar(n, rng, ruido=0.3):
    etiquetas = np.repeat(np.arange(4), n // 4)
    rng.shuffle(etiquetas)
    imagenes = np.empty((n, 16, 16), dtype=np.float32)
    tamanos = np.empty(n, dtype=int)
    for i, clase in enumerate(etiquetas):
        r = rng.integers(3, 6)
        cx, cy = rng.integers(r + 2, 14 - r, size=2)
        imagenes[i] = dibujar(clase, cx, cy, r) + rng.normal(0, ruido, (16, 16))
        tamanos[i] = r
    return imagenes, etiquetas, tamanos


def mostrar(imagen):
    return ["".join("#" if v > 0.5 else "." for v in fila) for fila in imagen]


rng = np.random.default_rng(0)
imagenes, etiquetas, tamanos = generar(480, rng)
imagenes_prueba, respuestas_organizador, _ = generar(400, rng)
prueba = pd.DataFrame({"id": [f"I-{i:04d}" for i in range(1, 401)]})

imagenes y etiquetas son las imágenes etiquetadas, y tamanos guarda el r de cada una. imagenes_prueba son las 400 imágenes de prueba. La tabla prueba solo tiene la columna id. Las etiquetas de prueba quedan en respuestas_organizador; ninguna decisión las usa hasta la calificación final, en «Archivo de predicciones».

Pregunta. Escribe la forma de imagenes, cuántas imágenes tiene cada clase y si el umbral de 0.5 dejará ver la figura de la primera imagen sin ningún punto suelto.

Python 3 · ejemplo reproducible
print(imagenes.shape, imagenes.dtype)
print(np.bincount(etiquetas))
print("clase:", CLASES[etiquetas[0]], " r =", tamanos[0])
print("\n".join(mostrar(imagenes[0])))
Salida esperada

La salida esperada está oculta.

La partición se hace antes de entrenar. Se separan 120 imágenes de validación con stratify. tamanos se divide junto con las imágenes para poder leer después los errores por tamaño.

Pregunta. Escribe la forma de las dos partes y el número de imágenes de cada clase en validación.

Python 3 · ejemplo reproducible
from sklearn.model_selection import train_test_split

X_ent, X_val, y_ent, y_val, r_ent, r_val = train_test_split(
    imagenes, etiquetas, tamanos, test_size=120, stratify=etiquetas, random_state=0
)

print(X_ent.shape, X_val.shape)
print(np.bincount(y_val))
Salida esperada

La salida esperada está oculta.

El baseline tiene dos niveles. DummyClassifier predice siempre la misma clase. La regresión logística recibe cada imagen aplanada como un vector de 256 píxeles y aprende un peso por píxel y por clase.

Pregunta. ¿Qué exactitud de validación tendrá el dummy con cuatro clases balanceadas? ¿La logística quedará cerca de su exactitud de entrenamiento?

Python 3 · ejemplo reproducible
from sklearn.dummy import DummyClassifier
from sklearn.linear_model import LogisticRegression

planas_ent = X_ent.reshape(len(X_ent), -1)  # -1: une 16 × 16 en una sola columna por píxel
planas_val = X_val.reshape(len(X_val), -1)

dummy = DummyClassifier(strategy="most_frequent").fit(planas_ent, y_ent)
logistica = LogisticRegression(max_iter=2000).fit(planas_ent, y_ent)

print(planas_ent.shape)
print("dummy     val:", round(dummy.score(planas_val, y_val), 3))
print("logística ent:", round(logistica.score(planas_ent, y_ent), 3))
print("logística val:", round(logistica.score(planas_val, y_val), 3))
Salida esperada

La salida esperada está oculta.

CNN pequeña

La CNN aplica los mismos filtros de 3 × 3 en todas las posiciones. Tiene dos bloques de convolución, ReLU y max pooling, y una capa lineal final con cuatro salidas. Los tensores llevan un eje de canal: la forma de entrada es (n, 1, 16, 16). unsqueeze(1) inserta ese eje en la posición 1.

Python 3 · ejemplo reproducible
X_ent_t = torch.tensor(X_ent).unsqueeze(1)
y_ent_t = torch.tensor(y_ent)
X_val_t = torch.tensor(X_val).unsqueeze(1)
y_val_t = torch.tensor(y_val)

nn.Sequential encadena las capas en orden. nn.Conv2d(1, 8, kernel_size=3) aprende 8 filtros de 3 × 3 sobre un canal de entrada. nn.MaxPool2d(2) conserva el máximo de cada bloque de 2 × 2. nn.Flatten() une canales, alto y ancho en un vector antes de la capa lineal. torch.manual_seed fija los pesos iniciales: la misma semilla produce la misma red.

Python 3 · ejemplo reproducible
def crear_cnn(semilla):
    torch.manual_seed(semilla)
    return nn.Sequential(
        nn.Conv2d(1, 8, kernel_size=3, padding=1),  # padding=1: la salida conserva alto y ancho
        nn.ReLU(),
        nn.MaxPool2d(2),
        nn.Conv2d(8, 16, kernel_size=3, padding=1),
        nn.ReLU(),
        nn.MaxPool2d(2),
        nn.Flatten(),
        nn.Linear(16 * 4 * 4, 4),
    )

evaluar devuelve la pérdida de entropía cruzada y la exactitud sin calcular gradientes. predecir devuelve, para cada imagen, la clase con el logit más alto. entrenar recorre los datos de entrenamiento en batches de 32 durante 40 épocas con Adam. El DataLoader y la augmentación tienen generadores separados con la misma semilla. Así, dos configuraciones con la misma semilla reciben los mismos batches en el mismo orden. Cada 10 épocas se registran pérdida y exactitud en entrenamiento y validación: cada fila de la historia es (época, p_ent, ex_ent, p_val, ex_val). El argumento aumentar_datos se usa en la sección siguiente.

Mostrar códigoOcultar códigoPython
Python 3 · ejemplo reproducible
from torch.utils.data import DataLoader, TensorDataset

perdida_fn = nn.CrossEntropyLoss()


def predecir(modelo, X_t):
    modelo.eval()
    with torch.no_grad():
        return modelo(X_t).argmax(dim=1).numpy()


def evaluar(modelo, X_t, y_t):
    modelo.eval()
    with torch.no_grad():
        logits = modelo(X_t)
    perdida = perdida_fn(logits, y_t).item()
    exactitud = (logits.argmax(dim=1) == y_t).float().mean().item()
    return round(perdida, 4), round(exactitud, 3)


def entrenar(modelo, semilla, aumentar_datos=False, epocas=40):
    generador = torch.Generator().manual_seed(semilla)
    generador_aug = torch.Generator().manual_seed(semilla)
    cargador = DataLoader(
        TensorDataset(X_ent_t, y_ent_t),
        batch_size=32,
        shuffle=True,
        generator=generador,
    )
    optimizador = torch.optim.Adam(modelo.parameters(), lr=0.01)
    historia = []
    for epoca in range(1, epocas + 1):
        modelo.train()
        for xb, yb in cargador:
            if aumentar_datos:
                xb = aumentar(xb, generador_aug)
            optimizador.zero_grad()
            perdida_fn(modelo(xb), yb).backward()
            optimizador.step()
        if epoca % 10 == 0:
            historia.append(
                (epoca, *evaluar(modelo, X_ent_t, y_ent_t), *evaluar(modelo, X_val_t, y_val_t))
            )
    return historia

La red sin entrenar ya acepta las imágenes de validación. p.numel() es el número de valores de cada tensor de pesos o sesgos; la suma da los parámetros de la red.

Pregunta. Escribe la forma de la salida de la CNN para las 120 imágenes de validación.

Python 3 · ejemplo reproducible
cnn = crear_cnn(semilla=0)
print(cnn(X_val_t).shape)
print("parámetros:", sum(p.numel() for p in cnn.parameters()))
Salida esperada

La salida esperada está oculta.

entrenar(cnn, semilla=0) entrena esa misma red y devuelve una fila cada 10 épocas.

Pregunta. Tras 40 épocas, ¿la exactitud de validación superará a la de la logística? ¿La pérdida de validación bajará hasta el final?

Ver resultado

Cada fila es una época del entrenamiento de cnn con semilla 0. p_ent y p_val son la pérdida de entropía cruzada en entrenamiento y en validación; ex_ent y ex_val, la exactitud. Por ejemplo, la fila de la época 30 dice que la red acierta todas las imágenes de entrenamiento (1.000) y 103 de las 120 de validación (0.858), con pérdida de validación 0.4386.

épocap_entp_valex_entex_val
100.36280.48100.8420.783
200.20880.60340.9060.792
300.03600.43861.0000.858
400.00730.45491.0000.858
Lectura. La CNN llega a 0.858 en validación, frente a 0.692 de la logística: sus filtros reconocen la figura en cualquier posición. Desde la época 30, la exactitud de entrenamiento es 1.000 y la pérdida de entrenamiento sigue bajando hasta 0.007. La pérdida de validación no acompaña: oscila entre 0.439 y 0.603 y termina en 0.455, por encima de su valor en la época 30. La CNN también sobreajusta las 360 imágenes.

Augmentación

aumentar crea variantes de cada batch de entrenamiento. Voltea horizontalmente cada imagen con probabilidad 0.5 y desplaza cada una entre −2 y 2 píxeles en cada eje con torch.roll. torch.roll lleva al borde opuesto lo que sale por un lado. lote.flip(-1) invierte el último eje, el ancho, y torch.where elige para cada imagen entre la versión volteada y la original.

Python 3 · ejemplo reproducible
def aumentar(lote, generador):
    n = lote.shape[0]
    voltear = torch.rand(n, generator=generador) < 0.5
    # [:, None, None, None] da forma (n, 1, 1, 1): una decisión por imagen
    lote = torch.where(voltear[:, None, None, None], lote.flip(-1), lote)
    pasos = torch.randint(-2, 3, (n, 2), generator=generador).tolist()  # enteros de -2 a 2
    return torch.stack(
        [
            torch.roll(imagen, shifts=(dy, dx), dims=(-2, -1))
            for imagen, (dy, dx) in zip(lote, pasos)
        ]
    )

Pregunta. Un triángulo sin ruido con r=4 pasa por aumentar. Su centro es el más bajo y el más a la derecha que permite generar para ese tamaño. ¿Sigue siendo un triángulo con la punta hacia arriba? ¿Alguna parte de la figura cruza al borde opuesto?

Python 3 · ejemplo reproducible
triangulo = torch.tensor(dibujar(2, cx=9, cy=9, r=4)).reshape(1, 1, 16, 16)
variante = aumentar(triangulo, torch.Generator().manual_seed(0))

for antes, despues in zip(mostrar(triangulo[0, 0]), mostrar(variante[0, 0])):
    print(antes, "  ", despues)
Salida esperada

La salida esperada está oculta.

La comparación cambia un solo factor, aumentar_datos, y conserva la arquitectura, el optimizador, las 40 épocas, la partición y las tres semillas. Validación nunca pasa por aumentar. modelos guarda las seis redes entrenadas para la matriz de confusión; *_ descarta la época y la pérdida de entrenamiento.

Pregunta. ¿Subirá la exactitud de validación en las tres semillas? ¿La exactitud de entrenamiento seguirá llegando a 1.000?

Python 3 · ejemplo reproducible
modelos = {}
print("aug semilla  ex_ent  p_val   ex_val")
for aumentar_datos in (False, True):
    for semilla in (0, 1, 2):
        modelo = crear_cnn(semilla)
        *_, ex_ent, p_val, ex_val = entrenar(modelo, semilla, aumentar_datos)[-1]  # época 40
        modelos[(aumentar_datos, semilla)] = modelo
        print(f"{aumentar_datos!s:>5} {semilla:5d}   {ex_ent:.3f}  {p_val:.4f}  {ex_val:.3f}")
Salida esperada

La salida esperada está oculta.

La configuración elegida es la CNN con augmentación. La decisión usó validación, así que prueba sigue sin usar.

Errores por clase

La exactitud global no dice qué clase falla. La matriz de confusión cuenta, para cada clase real (fila), cuántas imágenes recibió cada clase predicha (columna). Aquí se suman las matrices de las tres semillas con augmentación: 90 imágenes por clase. El recall de una clase es la diagonal dividida entre el total de su fila.

confusion_matrix con labels=range(4) devuelve siempre una matriz de 4 × 4 en el orden de CLASES.

Python 3 · ejemplo reproducible
from sklearn.metrics import confusion_matrix

matriz = np.zeros((4, 4), dtype=int)
for semilla in (0, 1, 2):
    pred = predecir(modelos[(True, semilla)], X_val_t)
    matriz += confusion_matrix(y_val, pred, labels=range(4))

Para encontrar el par, np.fill_diagonal pone en cero los aciertos de una copia de la matriz. np.unravel_index convierte la posición del máximo en la matriz aplanada en un par (fila, columna).

Pregunta. ¿Qué par de clases se confunde más?

Python 3 · ejemplo reproducible
tabla = pd.DataFrame(matriz, index=CLASES, columns=CLASES)
tabla["recall"] = (matriz.diagonal() / matriz.sum(axis=1)).round(3)
print(tabla)

fuera = matriz.copy()
np.fill_diagonal(fuera, 0)
real, predicha = np.unravel_index(fuera.argmax(), fuera.shape)
print("par más confundido:", CLASES[real], "->", CLASES[predicha])
Salida esperada

La salida esperada está oculta.

r_val guarda el tamaño de cada imagen de validación, así que los errores del par se pueden separar por tamaño, sumando las tres semillas.

Pregunta. ¿En qué tamaño r se concentran los errores de círculo → cruz?

Ver resultado

Cada fila es un tamaño r. «imágenes de círculo» cuenta los círculos de validación con ese tamaño, sumados en las tres semillas, y «predichas como cruz» dice cuántos de ellos recibieron la clase cruz. Por ejemplo, la fila con 3, 27 y 6 dice que 6 de los 27 círculos con r=3 se predijeron como cruz.

rimágenes de círculopredichas como cruz
3276
4335
5300
Lectura. El círculo tiene el recall más bajo, 0.844: 11 de sus 14 errores son cruces. Los 11 caen en r=3 y r=4; con r=5 no hay ninguno. Sin ruido y con el mismo centro, el círculo y la cruz difieren en 12 píxeles con r=3, en 20 con r=4 y en 40 con r=5. Con ruido de desviación 0.3 en cada píxel, una diferencia de 12 píxeles es fácil de perder. Con r=5, la cruz deja vacías cuatro esquinas que el círculo llena. La segunda confusión, 6 cuadrados predichos como círculo, es menor. La exactitud global de 0.925 (333 de 360) no muestra ninguno de estos patrones.

Archivo de predicciones

El envío usa la CNN con augmentación y semilla 0. Las tres semillas dan el rango de exactitud esperado; la cifra de una sola semilla no basta. revisar_envio convierte cada regla del concurso en un assert; el texto de cada assert nombra la regla que falla.

Python 3 · ejemplo reproducible
def revisar_envio(envio, prueba):
    assert envio.columns.tolist() == ["id", "clase"], "columnas"
    assert len(envio) == len(prueba), "número de filas"
    assert envio["id"].tolist() == prueba["id"].tolist(), "orden de id"
    assert envio["clase"].notna().all(), "celdas vacías"
    assert set(envio["clase"].unique()) <= set(CLASES), "valores permitidos"
    return "formato correcto"

La columna clase lleva los nombres, no los enteros de 0 a 3. Sin ruta, to_csv(index=False) devuelve el texto del CSV sin la columna del índice. io.StringIO deja que pd.read_csv lea ese texto como si fuera un archivo.

Pregunta. Escribe las tres primeras líneas del CSV y el número total de líneas.

Python 3 · ejemplo reproducible
import io

X_prueba_t = torch.tensor(imagenes_prueba).unsqueeze(1)
pred_prueba = predecir(modelos[(True, 0)], X_prueba_t)
envio = pd.DataFrame({"id": prueba["id"], "clase": [CLASES[k] for k in pred_prueba]})

texto = envio.to_csv(index=False)
leido = pd.read_csv(io.StringIO(texto))

print("\n".join(texto.splitlines()[:3]))
print("líneas:", len(texto.splitlines()))
print(revisar_envio(leido, prueba))
Salida esperada

La salida esperada está oculta.

El organizador califica el envío con las etiquetas que guardó. Como referencia, también se califica la logística.

Pregunta. Con la tabla de la augmentación, escribe la exactitud esperada de la CNN en prueba como un rango.

Ver resultado

Cada fila es un modelo y la columna da la fracción de las 400 imágenes de prueba que acierta según las etiquetas del organizador. Por ejemplo, la fila de la CNN con 0.943 dice que acierta 377 de las 400.

modeloexactitud en prueba
CNN con augmentación0.943
logística0.593
Lectura. La CNN obtiene 0.943 en prueba: 377 de 400, dentro del rango de 0.892 a 0.950 de las tres semillas en validación. La logística baja de 0.692 en validación a 0.593 en prueba. Con solo 120 imágenes, la exactitud de validación de un modelo puede alejarse varios puntos de la de prueba; en la logística se alejó 10. La distancia de 35 puntos entre la CNN y la logística es mucho mayor que esa variación.

Práctica

Traza guiada

Antes de procesar, escribe la forma del tensor después de cada una de las ocho capas de crear_cnn para un batch de cinco imágenes.

Python 3 · ejemplo reproducible
x = torch.zeros(5, 1, 16, 16)
red = crear_cnn(semilla=0)

Comprueba con un ciclo for capa in red: que aplique x = capa(x) e imprima x.shape. Si la forma después de Flatten no es (5, 256), revisa cuántas veces reduce MaxPool2d(2) el alto y el ancho.

Volteo vertical

Añade a aumentar un volteo vertical (lote.flip(-2)) aplicado a cada imagen con probabilidad 0.5. Antes de procesar, predice si la exactitud de validación cambia y en qué clases. Entrena las tres semillas con la nueva augmentación y compara su tabla y su matriz de confusión con las de la augmentación original. Después, dibuja un triángulo con dibujar, aplica solo el volteo vertical y describe qué imagen recibe la red con la etiqueta triángulo.

Concurso de cinco clases

Añade a dibujar una quinta clase, anillo: los píxeles con ((r - 1) ** 2 <= d2) & (d2 <= r ** 2), donde d2 es la distancia al centro al cuadrado. Ajusta generar y la capa final de crear_cnn a cinco clases. Genera un concurso nuevo con 600 imágenes etiquetadas y 500 de prueba, con np.random.default_rng(1). Produce:

  1. la partición estratificada con 150 imágenes de validación
  2. el dummy y la logística como baseline
  3. la tabla de la CNN sin y con augmentación, con tres semillas
  4. la matriz de confusión sumada y el par de clases que más se confunde
  5. el archivo revisado con una versión de revisar_envio para cinco clases

Comprueba con assert que la salida de la CNN tenga forma (n, 5) y que leido tenga 500 filas.

Reto opcional

Repite la comparación entre la logística y la CNN con augmentación para ruido igual a 0.2, 0.4 y 0.5. Para cada nivel, genera los datos con np.random.default_rng(0) y entrena la CNN con semilla 0. Registra la exactitud de validación de los dos modelos en cada nivel y cómo cambia la distancia entre ellos.

Errores que revisar

  • Si nn.Conv2d lanza un RuntimeError sobre la dimensión de la entrada, falta el eje de canal. Revisa que los tensores tengan forma (n, 1, 16, 16) con unsqueeze(1).
  • Si una clase pierde recall después de añadir una transformación, dibuja esa clase sin ruido, aplica solo la transformación y compárala con las figuras del concurso.
  • Si aumentar se aplica a validación o a prueba, la exactitud mide imágenes que el organizador no califica. La augmentación va solo en el batch de entrenamiento.
  • Si un desplazamiento lleva parte de la figura al borde opuesto, la imagen aumentada ya no es una figura del concurso. Compara el desplazamiento máximo con el margen libre que deja generar.
  • Si revisar_envio falla en valores permitidos, la columna clase probablemente tiene los enteros de 0 a 3. Tradúcelos con CLASES antes de escribir el CSV.

Cierre

Escribe el reporte de tres líneas que acompañaría el envío: exactitud esperada con un rango, par de clases que concentra los errores con el tamaño en que ocurren, y por qué las dos transformaciones de aumentar conservan la etiqueta.

Lista de comprobación

  • ¿Por qué un peso por píxel falla cuando la misma figura aparece en otra posición?
  • ¿Qué transformaciones conservan la forma de las figuras de este concurso y cuál la cambia?
  • ¿Por qué la exactitud de entrenamiento baja con augmentación mientras la de validación sube?
  • ¿Qué muestra la matriz de confusión que la exactitud global no muestra?
  • ¿Por qué la exactitud esperada en prueba se da como un rango de tres semillas?

Documentación oficial

  • torch.nn.Conv2d: parámetros de la convolución 2D y forma de entrada (N, C, H, W).
  • torch.roll: desplazamiento circular de un tensor a lo largo de los ejes indicados.
  • Training a Classifier: entrenamiento y evaluación de una CNN pequeña sobre imágenes en PyTorch.
  • confusion_matrix: filas como clase real y columnas como clase predicha.
  • DataFrame.to_csv: escritura de CSV, separador e index.
Cargando progreso…