Módulo 7 · Visión por computadora
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.
| regla | valor |
|---|---|
| métrica | exactitud en prueba |
| archivo | CSV con las columnas id,clase, en ese orden |
| filas | una por imagen de prueba, en el orden de prueba |
| valores | clase es uno de los cuatro nombres, sin celdas vacías |
| envíos | uno |
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ódigo
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.
print(imagenes.shape, imagenes.dtype)
print(np.bincount(etiquetas))
print("clase:", CLASES[etiquetas[0]], " r =", tamanos[0])
print("\n".join(mostrar(imagenes[0])))
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.
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))
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?
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))
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.
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.
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ódigo
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.
cnn = crear_cnn(semilla=0)
print(cnn(X_val_t).shape)
print("parámetros:", sum(p.numel() for p in cnn.parameters()))
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.
| época | p_ent | p_val | ex_ent | ex_val |
|---|---|---|---|---|
| 10 | 0.3628 | 0.4810 | 0.842 | 0.783 |
| 20 | 0.2088 | 0.6034 | 0.906 | 0.792 |
| 30 | 0.0360 | 0.4386 | 1.000 | 0.858 |
| 40 | 0.0073 | 0.4549 | 1.000 | 0.858 |
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.
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?
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)
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?
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}")
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.
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?
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])
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.
| r | imágenes de círculo | predichas como cruz |
|---|---|---|
| 3 | 27 | 6 |
| 4 | 33 | 5 |
| 5 | 30 | 0 |
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.
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.
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))
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.
| modelo | exactitud en prueba |
|---|---|
| CNN con augmentación | 0.943 |
| logística | 0.593 |
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.
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:
- la partición estratificada con 150 imágenes de validación
- el dummy y la logística como baseline
- la tabla de la CNN sin y con augmentación, con tres semillas
- la matriz de confusión sumada y el par de clases que más se confunde
- el archivo revisado con una versión de
revisar_enviopara 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.Conv2dlanza unRuntimeErrorsobre la dimensión de la entrada, falta el eje de canal. Revisa que los tensores tengan forma(n, 1, 16, 16)conunsqueeze(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
aumentarse aplica a validación o aprueba, 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_enviofalla envalores permitidos, la columnaclaseprobablemente tiene los enteros de 0 a 3. Tradúcelos conCLASESantes 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
pruebase 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.