13  Clasificación de imágenes con CNN

deep-learning
pytorch
cnn
mnist
Autor/a

Wilder Ramírez Delgado

Fecha de publicación

23 de octubre de 2026

13.1 El problema que vas a resolver

Quieres recibir una imagen con un solo dígito manuscrito y devolver un número entre 0 y 9. La parte interesante empieza antes de entrenar: debes saber cómo se ve la entrada, qué significan sus dimensiones y cómo conservar esa preparación al guardar la red. Trabajarás con MNIST y cerrarás el recorrido leyendo un PNG nuevo desde disco.

  • Tiempo estimado de lectura: 55 minutos, más cerca de 2 minutos de ejecución en CPU.
  • Nivel: Básico. Tipo de capítulo: teórico-aplicado.
  • Conocimientos previos: Python, fundamentos de CNN y el Apéndice A.
  • Herramientas: PyTorch, torchvision, NumPy, pandas, matplotlib, scikit-learn y Pillow para archivos de imagen.

Al terminar podrás explorar los dígitos, preparar sus tensores, explicar el tamaño de cada capa, elegir una versión por validación y revisar sus errores en prueba. También podrás guardar pesos e información de entrada, reconstruir la red y predecir a partir de un archivo nuevo.

Primero observarás los datos y sus etiquetas. Después construirás la CNN, seguirás sus curvas y examinarás una representación interna. Abrirás prueba al cerrar las decisiones y guardarás el modelo. El último paso será usar el archivo guardado con una imagen que tú puedes sustituir por la tuya.

TipLo que necesitas recordar
Concepto En una línea Dónde se explica
Tensor, forma, lote y época Organización de números, sus dimensiones y unidad de trabajo Apéndice A
Pérdida, gradiente, tasa y optimizador Medir el error y corregir los parámetros Apéndice A
Semilla, CPU y state_dict Control del azar, procesador y estado de la red Apéndice A
Logits, softmax y CE Puntuaciones, probabilidades y pérdida de clasificación MLP
Adam Optimizador que adapta sus actualizaciones Optimización
Entrenamiento, validación y prueba Ajustar, elegir e informar el resultado final Evaluación
Exactitud, precisión, sensibilidad, F1 y matriz de confusión Resumen de aciertos y análisis por categoría Evaluación
Checkpoint, paciencia, parada temprana y dropout Recuperar una versión, detener a tiempo y regularizar Regularización
Convolución, ReLU, pooling y Flatten Extraer mapas, transformar respuestas, resumir y aplanar Fundamentos CNN
Píxel, canal y mapa de activación Posición de imagen, tabla de intensidades y respuesta de un filtro Fundamentos CNN

13.2 Conocer los dígitos antes de construir la red

NotaClasificación multiclase y etiqueta

La clasificación multiclase elige una categoría entre varias opciones. La etiqueta es la categoría que conocemos para un ejemplo. Aquí cada imagen tiene una etiqueta entera entre 0 y 9 y la red produce diez puntuaciones.

MNIST reúne imágenes de dígitos en gris, de 28 × 28 píxeles. El fondo suele ser oscuro y el trazo claro. La misma cifra puede escribirse con inclinaciones y grosores diferentes; reconocer un 2 exige aprender algo más que una plantilla fija de 784 posiciones.

Los datos se atribuyen a Yann LeCun, Corinna Cortes y Christopher J. C. Burges. Se descargan con torchvision.datasets.MNIST desde su fuente de distribución. La documentación de MNIST en Keras identifica su licencia como CC BY-SA 3.0. Las muestras aquí mostradas conservan esa atribución; las figuras señalan cuándo se transforman sus intensidades o su tamaño.

Usarás un subconjunto estratificado de 15000 de las 60000 imágenes oficiales de desarrollo: 12000 para entrenamiento y 3000 para validación. La prueba oficial conserva sus 10000 imágenes. Es una selección para trabajar con rapidez en CPU; sus índices se guardan para poder repetirla.

La primera celda configura el entorno y la semilla. El apoyo documentado, apoyo_cnn.py, se muestra completo en el desplegable final.

from pathlib import Path
import copy
import json
import platform
import time
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import torch
import torchvision
from PIL import Image, ImageDraw
from torch import nn
from torch.nn import functional as F
from IPython.display import display
import apoyo_cnn as apoyo

SEMILLA = 42
np.random.seed(SEMILLA)
torch.manual_seed(SEMILLA)
torch.set_num_threads(2)
dispositivo = torch.device("cpu")
artefactos = Path("artefactos_pytorch")
artefactos.mkdir(exist_ok=True)
versiones = {
    "python": platform.python_version(), "torch": str(torch.__version__),
    "torchvision": torchvision.__version__, "numpy": np.__version__,
    "pandas": pd.__version__, "dispositivo": str(dispositivo), "hilos": 2,
}
print(versiones)
{'python': '3.13.14', 'torch': '2.14.1+cpu', 'torchvision': '0.29.1+cpu', 'numpy': '2.5.1', 'pandas': '3.0.5', 'dispositivo': 'cpu', 'hilos': 2}

La salida python, torch, torchvision y dispositivo registra dónde se ejecutó este ejemplo. La comparación de cifras tiene sentido junto con esa configuración y la semilla 42.

Carga ahora MNIST y observa doce imágenes del entrenamiento en su orden de selección. La tabla de frecuencias te permitirá comparar la cantidad de cada dígito en ajuste y validación.

datos = apoyo.datos("mnist", 15000, SEMILLA)
ajuste = datos["entrenamiento"]
validacion = datos["validacion"]
prueba = datos["prueba"]
nombres = [str(numero) for numero in range(10)]
conteos = pd.DataFrame({"Dígito": nombres,
    "Entrenamiento": np.bincount(ajuste.tensors[1].numpy()),
    "Validación": np.bincount(validacion.tensors[1].numpy())})
display(conteos)
figura, ejes = plt.subplots(3, 4, figsize=(8, 6))
for indice, eje in enumerate(ejes.flat):
    eje.imshow(ajuste.tensors[0][indice, 0], cmap="gray")
    eje.set_title(f"Etiqueta: {ajuste.tensors[1][indice].item()}")
    eje.axis("off")
figura.tight_layout()
plt.show()
np.savez_compressed(artefactos / "particiones.npz",
                    ajuste=datos["indices_ajuste"], validacion=datos["indices_validacion"])
Dígito Entrenamiento Validación
0 0 1185 296
1 1 1348 337
2 2 1192 298
3 3 1226 307
4 4 1168 292
5 5 1084 271
6 6 1184 296
7 7 1253 313
8 8 1170 293
9 9 1190 297
(a) Dígitos de entrenamiento
Doce imágenes de entrenamiento muestran variaciones de trazo; cada imagen indica su etiqueta.
(b)
Figura 13.1

El dígito 1 es el más frecuente del ajuste, con 1348 imágenes, y el 5 tiene 1084. Validación conserva esa diferencia: 337 unos y 271 cincos. Las escrituras de la galería muestran por qué contar ejemplos y mirar sus trazos son comprobaciones complementarias antes de entrenar.

13.3 Preparar una entrada que la CNN pueda usar

NotaPreprocesamiento

El preprocesamiento transforma el dato recibido en la entrada que utiliza el modelo. Aquí consiste en mantener un canal de gris, convertir intensidades a float32, dividir por 255 y organizar el lote como (N, 1, 28, 28).

Piensa en medir una receta siempre con la misma taza. Si al usarla cambias de unidad, las cantidades significan otra cosa. Del mismo modo, la red aprenderá con intensidades entre 0 y 1 y debe recibir esa escala al predecir. Un gris original de 128 se convierte en 128 / 255 ≈ 0.501961.

Las etiquetas se conservan como enteros long. La CE recibe diez logits por imagen y un entero que indica cuál corresponde al dígito real. Softmax se usará después para leer las probabilidades. De esta manera cada posición de salida tiene un significado fijo: la primera corresponde a 0 y la última a 9.

Dos fuentes, imagen MNIST y archivo PNG, llegan a una preparación común: gris, fondo oscuro con trazo claro, escala de cero a uno y forma uno por uno por 28 por 28 para una imagen. La red recibe el mismo tipo de entrada en ambas rutas.

La preparación de entrenamiento y la de uso convergen en la misma forma.

Comprueba un valor intermedio y las formas de los tensores. La celda de datos ya creó las entradas listas para usar; aquí verificas sus propiedades.

print("128 / 255 =", round(128 / 255, 6))
print("Entrada:", tuple(ajuste.tensors[0].shape), ajuste.tensors[0].dtype)
print("Rango:", ajuste.tensors[0].min().item(), ajuste.tensors[0].max().item())
print("Etiquetas:", tuple(ajuste.tensors[1].shape), ajuste.tensors[1].dtype)
128 / 255 = 0.501961
Entrada: (12000, 1, 28, 28) torch.float32
Rango: 0.0 1.0
Etiquetas: (12000,) torch.int64

La entrada tiene forma (12000, 1, 28, 28), tipo torch.float32 y rango de 0.0 a 1.0. Las etiquetas tienen forma (12000,) y tipo torch.int64, equivalente al long que necesita esta CE. El valor 128 se transforma en 0.501961, igual que la cuenta de la receta de escala.

13.4 Por qué usar una CNN en este problema

En una MLP, una neurona de la primera capa recibe los 784 píxeles. En la CNN, cada filtro empieza mirando nueve vecinos y reutiliza sus pesos sobre el resto del dígito. Un segmento del 7 puede estar unas posiciones más arriba o abajo; compartir la operación permite buscar trazos semejantes en varias posiciones.

El capítulo anterior comparó ambos diseños con prendas. Aquí concentrarás el trabajo en una sola CNN para seguir todas las etapas de uso. El razonamiento espacial se mantiene: combinar trazos locales antes de reunirlos en una decisión para el dígito completo.

13.5 La arquitectura, capa por capa

Usaremos dos convoluciones de 3 × 3 sin padding. La primera produce 32 mapas de 26 × 26; un pooling de 2 los resume a 13 × 13. La segunda produce 64 mapas de 11 × 11 y otro pooling los reduce a 5 × 5.

Flatten reorganiza 64 × 5 × 5 = 1600 valores por imagen. Una capa densa los combina en 128 activaciones, seguida de ReLU y dropout 0.5. La salida produce diez logits. Durante entrenamiento dropout desactiva aleatoriamente activaciones; durante evaluación se usa la representación completa, como viste en regularización.

Flujo espacial: entrada uno por 28 por 28, 32 mapas de 26 por 26, pooling a 13 por 13, 64 mapas de 11 por 11, pooling a cinco por cinco, 1600 valores aplanados, 128 activaciones y diez logits. El tamaño del lote se conserva.

Las dimensiones van cambiando mientras se conserva el número de imágenes del lote.

Construye la red y recórrela con dos imágenes de ceros. Cada fila de la tabla describe una transformación y su número de parámetros; esa comprobación detecta errores de tamaño antes de entrenar.

def crear_cnn():
    """Devuelve la arquitectura de MNIST sin pesos aprendidos."""
    return nn.Sequential(
        nn.Conv2d(1, 32, 3),
        nn.ReLU(),
        nn.MaxPool2d(2),
        nn.Conv2d(32, 64, 3),
        nn.ReLU(),
        nn.MaxPool2d(2),
        nn.Flatten(),
        nn.Linear(1600, 128),
        nn.ReLU(),
        nn.Dropout(0.5),
        nn.Linear(128, 10),
    )

modelo = crear_cnn()
modelo.eval()
entrada = torch.zeros(2, 1, 28, 28)
filas = []
with torch.inference_mode():
    for capa in modelo:
        entrada = capa(entrada)
        filas.append({"Capa": type(capa).__name__, "Salida": str(tuple(entrada.shape)),
                      "Parámetros": sum(p.numel() for p in capa.parameters())})
display(pd.DataFrame(filas))
print("Parámetros totales:", sum(p.numel() for p in modelo.parameters()))
Capa Salida Parámetros
0 Conv2d (2, 32, 26, 26) 320
1 ReLU (2, 32, 26, 26) 0
2 MaxPool2d (2, 32, 13, 13) 0
3 Conv2d (2, 64, 11, 11) 18496
4 ReLU (2, 64, 11, 11) 0
5 MaxPool2d (2, 64, 5, 5) 0
6 Flatten (2, 1600) 0
7 Linear (2, 128) 204928
8 ReLU (2, 128) 0
9 Dropout (2, 128) 0
10 Linear (2, 10) 1290
Parámetros totales: 225034

La tabla permite seguir el lote de dos imágenes hasta (2, 10). Después del segundo pooling quedan 64 mapas de 5 × 5; Flatten reúne sus 1600 valores. La red suma 225034 parámetros y la primera capa densa reúne 204928 de ellos. Dropout conserva la forma (2, 128) y añade cero parámetros entrenables.

13.6 Entrenar y conservar la mejor versión

La CNN empezará con pesos nuevos y aprenderá con las 12000 imágenes de ajuste. Usará Adam, tasa 0.001, lotes de 128, máximo inicial de 60 épocas y paciencia 5. La pérdida de validación decide qué estado guardar. Al final se restaura ese estado aunque se hayan ejecutado épocas posteriores.

La analogía es conservar el borrador que mejor funciona en una revisión, en lugar de entregar automáticamente el último que escribiste. La validación hace esa revisión; la prueba queda para el informe del trabajo terminado.

Ejemplo conceptual de tres versiones: una inicial, otra con menor pérdida de validación marcada para guardar y otra posterior con mayor pérdida. Una flecha vuelve a la versión guardada. La última época ejecutada y la mejor pueden ser distintas.

El estado elegido corresponde al mínimo de validación.

La siguiente celda ejecuta el ciclo de cinco acciones del Apéndice A. La función de apoyo recibe entrenamiento y validación, conserva el mejor state_dict y devuelve el historial. También comprueba que la mejor época quede antes del máximo; si llega al límite, amplía el presupuesto antes de continuar.

historial, resumen = apoyo.entrenar(
    modelo, ajuste, validacion, dispositivo, SEMILLA,
    maximo=60, paciencia=5, tasa=0.001,
)
display(pd.DataFrame([resumen]))
historial.to_csv(artefactos / "mnist-historial.csv", index=False)
Mejor época < máximo: 16 < 60 = True
mejor_epoca epocas_ejecutadas maximo_final perdida_validacion segundos parametros
0 16 21 60 0.060688 81.802437 225034

La mejor versión corresponde a la época 16, con CE de validación 0.060688. La ejecución llegó a la 21 y se detuvo tras cinco épocas sin un nuevo mínimo. La comprobación 16 < 60 dio True, de modo que la versión elegida quedó dentro del presupuesto. El ajuste medido tardó 81.80 segundos.

La pérdida y la exactitud de entrenamiento se miden sobre imágenes usadas para ajustar los pesos. Las dos curvas se calculan en modo evaluación al cerrar cada época, con el mismo estado de red, para compararlas con validación.

apoyo.curvas(historial, "MNIST · versión elegida por validación", resumen["mejor_epoca"])
Pérdida y exactitud por época con círculos continuos para ajuste, cuadrados discontinuos para validación y una línea vertical para la mejor versión.
Figura 13.2: Aprendizaje de la CNN de MNIST

En la época 21, la pérdida de entrenamiento es 0.007704 y la de validación 0.064182. La segunda supera el mínimo 0.060688 de la época 16, aunque entrenamiento siga ajustándose. Las exactitudes finales son 99.83 % y 98.13 %, respectivamente; esa separación permite observar la adaptación creciente a las imágenes conocidas.

13.7 Mirar una representación intermedia

Antes de abrir prueba, utiliza un dígito de validación y observa ocho de los 32 mapas de la primera convolución después de ReLU. Es como mirar las piezas que produjo el primer paso de un ensamblaje: la salida final combinará esas piezas con otras transformaciones.

La siguiente celda muestra la imagen y los mapas con escalas independientes. Observa en qué posiciones aparecen respuestas positivas y en cuáles quedan ceros.

modelo.eval()
with torch.inference_mode():
    muestra = validacion.tensors[0][:1].to(dispositivo)
    mapas = modelo[1](modelo[0](muestra))[0].cpu()
figura, ejes = plt.subplots(3, 3, figsize=(8, 7))
ejes.flat[0].imshow(muestra[0, 0].cpu(), cmap="gray")
ejes.flat[0].set_title("Dígito de validación")
for indice, eje in enumerate(list(ejes.flat)[1:]):
    eje.imshow(mapas[indice], cmap="viridis")
    eje.set_title(f"Canal {indice}")
for eje in ejes.flat:
    eje.axis("off")
figura.tight_layout()
plt.show()
print("Mapas de primera capa:", tuple(mapas.shape))
Un dígito de validación y ocho de sus 32 mapas después de ReLU, con escala independiente por canal.
Figura 13.3: Representaciones de una imagen de validación
Mapas de primera capa: (32, 26, 26)

La primera capa produce (32, 26, 26) para el dígito observado. La figura muestra ocho de esos 32 canales después de ReLU. Busca las posiciones de los trazos en la entrada y en cada mapa: algunos canales conservan respuestas alrededor de zonas distintas del mismo número.

13.8 Evaluar el clasificador terminado

Ya elegiste la versión con validación. La celda siguiente procesa una vez las 10000 imágenes oficiales de prueba y guarda sus probabilidades. La tabla resume CE y exactitud; las figuras e informes siguientes reutilizan esas predicciones.

resultado = apoyo.evaluar(modelo, prueba, dispositivo, SEMILLA)
fila_resultado = {"Modelo": "CNN MNIST", **resumen,
                  "CE prueba": resultado["perdida"], "Exactitud prueba": resultado["exactitud"]}
display(pd.DataFrame([fila_resultado]))
Modelo mejor_epoca epocas_ejecutadas maximo_final perdida_validacion segundos parametros CE prueba Exactitud prueba
0 CNN MNIST 16 21 60 0.060688 81.802437 225034 0.051716 0.9849

La versión de la época 16 alcanzó 98.49 % de exactitud en las 10000 imágenes de prueba, con CE 0.051716. La cifra de prueba evalúa la versión ya elegida, mientras que el 0.060688 de validación fue el criterio para escogerla. Mantener separados esos papeles permite explicar de dónde sale cada número.

La matriz cruza el dígito real de cada fila con el predicho de cada columna. La tabla por categoría añade precisión, sensibilidad y F1; el promedio macro trata los diez dígitos con el mismo peso. La galería muestra los primeros ocho errores en el orden de prueba.

matriz, por_categoria = apoyo.diagnostico(resultado, nombres, prueba.tensors[0])
display(por_categoria.round(4))
print("Errores totales:", int(matriz.sum() - matriz.trace()))
Errores totales: 151
Matriz de confusión con conteos por dígito real y predicho, y ocho errores con sus etiquetas, para observar qué escrituras se confunden.
(a) Errores del modelo terminado
Matriz de confusión con conteos por dígito real y predicho, y ocho errores con sus etiquetas, para observar qué escrituras se confunden.
(b)
precision recall f1-score support
0 0.9828 0.9939 0.9883 980.0
1 0.9904 0.9956 0.9930 1135.0
2 0.9912 0.9806 0.9859 1032.0
3 0.9842 0.9861 0.9852 1010.0
4 0.9790 0.9980 0.9884 982.0
5 0.9821 0.9843 0.9832 892.0
6 0.9937 0.9802 0.9869 958.0
7 0.9649 0.9903 0.9774 1028.0
8 0.9937 0.9713 0.9823 974.0
9 0.9879 0.9673 0.9775 1009.0
macro avg 0.9850 0.9847 0.9848 10000.0
(c)
Figura 13.4

La CNN cometió 151 errores. El dígito 9 tiene la menor sensibilidad, 0.9673, y la confusión individual más frecuente es 9 predicho como 4, con 13 casos. El dígito 7 tiene precisión 0.9649 y sensibilidad 0.9903: recupera casi todos los sietes reales, pero también recibe ejemplos de otros dígitos. Relaciona esos resultados con las escrituras que aparecen en la galería.

13.9 Guardar números y conservar su significado

NotaInferencia y metadatos

La inferencia utiliza un modelo ya ajustado para producir respuestas. Los metadatos describen la información que acompaña sus pesos: qué arquitectura reconstruir, cómo preparar una imagen y qué categoría corresponde a cada salida.

Guardar solo números es como conservar una receta sin indicar las unidades. El state_dict contiene los pesos aprendidos; el código de la arquitectura permite colocarlos en las capas correctas. Los metadatos completan el significado de la entrada y la salida.

Guarda ambos en mnist-cnn.pt, crea otra red con la misma estructura y carga su estado. Para comprobar la reconstrucción, compara sus logits con los del modelo original sobre 32 imágenes de validación. Usa eval() y inference_mode() porque esta etapa solo predice.

metadatos = {
    "arquitectura": "Conv32-valid-pool-Conv64-valid-pool-Linear128-Dropout0.5-Linear10",
    "forma": [1, 28, 28], "escala": "uint8 / 255", "etiquetas": nombres,
    "fondo": "oscuro", "trazo": "claro",
}
ruta_modelo = artefactos / "mnist-cnn.pt"
torch.save({"state_dict": modelo.state_dict(), "metadatos": metadatos}, ruta_modelo)
paquete = torch.load(ruta_modelo, map_location=dispositivo, weights_only=True)
recargado = crear_cnn().to(dispositivo)
recargado.load_state_dict(paquete["state_dict"])
recargado.eval()
control = validacion.tensors[0][:32].to(dispositivo)
with torch.inference_mode():
    diferencia = (modelo(control) - recargado(control)).abs().max().item()
    torch.testing.assert_close(modelo(control), recargado(control))
print("Diferencia máxima de logits al recargar:", diferencia)
print("Forma y etiquetas guardadas:", paquete["metadatos"]["forma"], nombres)
Diferencia máxima de logits al recargar: 0.0
Forma y etiquetas guardadas: [1, 28, 28] ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9']

La diferencia máxima de logits después de reconstruir y recargar fue 0.0 en las 32 imágenes de control. El paquete conserva la forma [1, 28, 28] y las etiquetas ordenadas de 0 a 9. Esa coincidencia comprueba que los pesos guardados se colocaron en la misma arquitectura y producen las mismas puntuaciones.

13.10 Leer un dígito nuevo desde un PNG

Prepararemos un 7 sintético: un trazo horizontal unido a uno diagonal sobre fondo claro. Se dibuja aquí, así que conoces su categoría y su procedencia. Es un ejemplo de entrada externa al entrenamiento, diferente de las imágenes reservadas de MNIST.

La función cargar_digito abre un archivo, lo convierte a gris y lo redimensiona a 28 × 28. Para fondo claro y trazo oscuro, invertir=True aplica 1 - intensidad, acercando su presentación al fondo oscuro con el que se entrenó la red.

Archivo de un siete dibujado, conversión a gris e inversión de fondo, tensor con canal y lote, red recargada y diez probabilidades. El dígito seleccionado es el de mayor probabilidad y se informa junto con la imagen preparada.

Del archivo nuevo a la respuesta de la red recargada.

La siguiente celda crea el PNG y lo pasa por la red recargada. Puedes sustituir la ruta por tu archivo y elegir la inversión según el fondo. La gráfica muestra todas las probabilidades, no solo la respuesta elegida.

def cargar_digito(ruta, invertir=False):
    """Recibe ruta e inversión opcional; devuelve un tensor float32 NCHW en [0, 1]."""
    with Image.open(ruta) as imagen:
        gris = imagen.convert("L").resize((28, 28), Image.Resampling.BILINEAR)
        valores = np.asarray(gris, dtype=np.float32) / 255
    if invertir:
        valores = 1 - valores
    return torch.from_numpy(valores.copy())[None, None]

imagen_nueva = Image.new("L", (112, 112), color=255)
dibujo = ImageDraw.Draw(imagen_nueva)
dibujo.line([(27, 25), (83, 25), (44, 91)], fill=0, width=9)
ruta_nueva = artefactos / "siete-nuevo.png"
imagen_nueva.save(ruta_nueva)
entrada_nueva = cargar_digito(ruta_nueva, invertir=True).to(dispositivo)
with torch.inference_mode():
    probabilidades = recargado(entrada_nueva).softmax(1)[0].cpu().numpy()
prediccion_nueva = int(probabilidades.argmax())
print("Dígito dibujado: 7. Predicción:", prediccion_nueva)
print("Probabilidades:", np.round(probabilidades, 6))
figura, ejes = plt.subplots(1, 2, figsize=(9, 3))
ejes[0].imshow(entrada_nueva[0, 0].cpu(), cmap="gray")
ejes[0].set_title("PNG nuevo preparado")
ejes[0].axis("off")
ejes[1].bar(nombres, probabilidades, color="#1786B3")
ejes[1].set(xlabel="Dígito", ylabel="Probabilidad predicha", ylim=(0, 1))
figura.tight_layout()
plt.show()
Dígito dibujado: 7. Predicción: 7
Probabilidades: [0.00000e+00 0.00000e+00 3.00000e-05 1.00000e-06 0.00000e+00 0.00000e+00
 0.00000e+00 9.99969e-01 0.00000e+00 0.00000e+00]
Siete sintético preparado sobre fondo oscuro y diez probabilidades del modelo recargado; las posiciones de las barras identifican los dígitos.
Figura 13.5: Inferencia desde un PNG propio

El archivo dibujado representa un 7 y la red recargada predijo 7. La probabilidad mostrada para esa categoría fue 0.999969; la siguiente respuesta visible, para 2, fue 0.000030. Observa el trazo claro sobre fondo oscuro de la figura: ya pasó por la preparación que exige la CNN. Este resultado recorre con éxito la ruta archivo–tensor–red–predicción.

13.11 Un error de etiquetas que conviene reconocer

Las imágenes son decimales, pero las etiquetas de esta CE deben ser enteras. Vamos a convertir deliberadamente las etiquetas de un lote a float32 para ver el mensaje real. El error se captura y el notebook continúa con la forma correcta de usarlas.

try:
    with torch.inference_mode():
        _ = F.cross_entropy(modelo(control), validacion.tensors[1][:32].float())
except RuntimeError as error:
    print(type(error).__name__ + ":", str(error))
print("Tipo correcto de etiquetas:", validacion.tensors[1].dtype)
RuntimeError: expected target dtype to be Long or Byte, but got Float
Tipo correcto de etiquetas: torch.int64

El mensaje real indica expected target dtype to be Long or Byte, but got Float. La salida correcta mantiene torch.int64: las etiquetas identifican posiciones enteras de las diez salidas. El error fue provocado a propósito y capturado, así que el notebook sigue su ejecución normal.

Lo que ves Qué pasó Cómo se arregla
El mensaje pide Long y encuentra Float Se cambiaron etiquetas enteras por decimales Conserva la etiqueta entera o usa .long()
La imagen preparada tiene el fondo invertido La convención visual difiere de MNIST Revisa la imagen después de cargar_digito y elige invertir
El tamaño de una capa al cargar no coincide Se reconstruyó otra arquitectura Usa las mismas capas, filtros y dimensiones de la red guardada

13.12 Alcance y siguientes comprobaciones

MNIST está centrado, tiene un solo dígito por imagen y una resolución pequeña. La exactitud de su prueba describe ese conjunto. Fotografías con varios números, fondos complejos o escrituras distintas necesitan datos representativos y otra evaluación.

El PNG sintético sirve para recorrer la lectura y la inferencia, no para estimar una exactitud externa. Su inversión corrige contraste, pero no resuelve automáticamente centrado, grosor ni encuadre. Los mapas internos tienen escala independiente y muestran respuestas espaciales, sin asignar un significado humano fijo a cada canal. El archivo guardado permite predecir; reanudar exactamente el entrenamiento exigiría también conservar el estado del optimizador.

13.13 Ejercicios

  1. Calcula por qué la segunda convolución recibe 32 canales y produce 64 mapas de 11 × 11.
  2. Explica qué versión se recupera si la época 9 tiene menor pérdida de validación que la 14.
  3. Busca en la matriz una confusión frecuente y relaciónala con los ejemplos de escritura mostrados.
  4. Dibuja un dígito propio y observa el archivo preparado antes de predecir. Documenta fondo y encuadre.
  5. Compara las probabilidades del archivo propio con el mismo archivo invertido. Mantén fijo el modelo.
  6. Enumera lo que debes conservar para usar la red en otro equipo.
  1. El primer pooling deja 32 mapas de 13 × 13; un kernel de 3 sin borde reduce cada eje a 11. Los 64 filtros deciden el número de mapas de salida.
  2. Se recupera la época 9 por la regla de menor CE, aunque la ejecución haya llegado a la 14.
  3. Observa filas y columnas concretas y plantea una hipótesis sobre los trazos de esos dígitos para contrastarla en ejemplos nuevos.
  4. Usa un solo dígito, evita recortarlo y comprueba que el tensor lo muestra con trazo claro sobre fondo oscuro.
  5. La inversión cambia las intensidades de entrada sin cambiar los pesos. La diferencia indica sensibilidad a esa preparación.
  6. Arquitectura, state_dict, preparación de entrada, orden de etiquetas y versiones de referencia.

13.14 Glosario de este capítulo

Término Significado
Clasificación multiclase Elegir una categoría entre varias
Etiqueta Respuesta conocida para un ejemplo
Preprocesamiento Preparación del dato para que tenga la forma, escala y presentación esperadas
Inferencia Producir predicciones con un modelo ajustado
Metadatos Información para reconstruir el modelo e interpretar entradas y salidas

Consulta los términos generales en el glosario del Apéndice A y los espaciales en fundamentos CNN.

13.15 Qué aprendiste

La CNN recuperó la época 16 por validación y alcanzó 98.49 % en prueba, con 151 errores que pudiste localizar por dígito. Después reconstruiste sus 225034 parámetros desde disco y verificaste una diferencia de logits de 0.0. La predicción del 7 nuevo cerró el recorrido: además de entrenar, conservaste cómo preparar una entrada y cómo interpretar la salida.

El siguiente capítulo, arquitecturas CNN, estudiará cómo cambia el recorrido de la información al conectar los bloques de otra manera. Conservarás la separación entre datos para ajustar, elegir y evaluar que acabas de utilizar.

13.16 Referencias

"""Apoyo para los experimentos CNN de la Biblioteca BIT.

Reúne carga oficial de datos, entrenamiento con validación y gráficos.
La semilla y el dispositivo los decide el notebook. Ninguna función usa prueba
para ajustar pesos ni elegir una época. Los valores devueltos permiten escribir
las interpretaciones en el texto del capítulo, después de ejecutar.
"""

from pathlib import Path
import contextlib
import copy
import io
import time

import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import torch
from sklearn.metrics import classification_report, confusion_matrix
from sklearn.model_selection import train_test_split
from torch.nn import functional as F
from torch.utils.data import DataLoader, TensorDataset
from torchvision.datasets import FashionMNIST, MNIST


def datos(nombre, cantidad, semilla):
    """Carga MNIST o Fashion-MNIST y devuelve tensores, etiquetas e índices.

    Recibe el nombre, el tamaño del subconjunto de desarrollo y la semilla.
    Descarga los archivos oficiales con torchvision la primera vez y después
    los reutiliza desde la caché. Devuelve un diccionario con entrenamiento,
    validación (20 % del subconjunto), prueba oficial e índices de desarrollo.
    """
    clase = FashionMNIST if nombre == "fashion" else MNIST
    raiz = Path.home() / ".cache" / "bit-datasets"
    with contextlib.redirect_stdout(io.StringIO()):
        with contextlib.redirect_stderr(io.StringIO()):
            desarrollo = clase(raiz, train=True, download=True)
            prueba = clase(raiz, train=False, download=True)
    etiquetas = desarrollo.targets.numpy()
    indices = np.arange(len(etiquetas))
    elegidos, _ = train_test_split(
        indices, train_size=cantidad, stratify=etiquetas, random_state=semilla
    )
    ajuste, validacion = train_test_split(
        elegidos, test_size=0.2, stratify=etiquetas[elegidos], random_state=semilla
    )
    assert not set(ajuste).intersection(validacion)
    imagenes = desarrollo.data.unsqueeze(1).float() / 255
    entrada_prueba = prueba.data.unsqueeze(1).float() / 255
    return {
        "entrenamiento": TensorDataset(imagenes[ajuste], desarrollo.targets[ajuste]),
        "validacion": TensorDataset(imagenes[validacion], desarrollo.targets[validacion]),
        "prueba": TensorDataset(entrada_prueba, prueba.targets),
        "indices_ajuste": ajuste,
        "indices_validacion": validacion,
    }


def cargador(conjunto, semilla, mezclar=False, lote=128):
    """Recibe datos, semilla y opciones; devuelve lotes con generador explícito."""
    generador = torch.Generator().manual_seed(semilla)
    return DataLoader(
        conjunto, batch_size=lote, shuffle=mezclar, generator=generador, num_workers=0
    )


@torch.inference_mode()
def evaluar(modelo, conjunto, dispositivo, semilla):
    """Recibe modelo y datos; devuelve CE media, exactitud, etiquetas y probabilidades."""
    modelo.eval()
    total = 0.0
    salidas = []
    etiquetas = []
    for entrada, respuesta in cargador(conjunto, semilla):
        logits = modelo(entrada.to(dispositivo))
        total += F.cross_entropy(
            logits, respuesta.to(dispositivo), reduction="sum"
        ).item()
        salidas.append(logits.cpu())
        etiquetas.append(respuesta)
    logits = torch.cat(salidas)
    reales = torch.cat(etiquetas)
    assert torch.isfinite(logits).all()
    return {
        "perdida": total / len(conjunto),
        "exactitud": (logits.argmax(1) == reales).float().mean().item(),
        "reales": reales.numpy(),
        "probabilidades": logits.softmax(1).numpy(),
    }


def entrenar(modelo, ajuste, validacion, dispositivo, semilla,
             maximo=60, paciencia=5, tasa=0.001):
    """Ajusta con Adam y restaura el mejor estado según CE de validación.

    Recibe modelo, dos conjuntos, dispositivo, semilla y presupuesto. Devuelve
    historial por época y resumen. Las curvas de ajuste también se miden en
    modo evaluación al cerrar la época, sobre datos usados para aprender.
    Si el mínimo queda en el borde se amplía el máximo, conservando el modelo
    y el optimizador. Si exige más de 1100 segundos o 240 épocas, se detiene
    con un error para revisar el diseño antes de abrir prueba.
    """
    modelo.to(dispositivo)
    optimizador = torch.optim.Adam(modelo.parameters(), lr=tasa)
    lotes = cargador(ajuste, semilla, mezclar=True)
    historial = []
    mejor = float("inf")
    estado = None
    espera = 0
    limite = maximo
    inicio = time.perf_counter()
    for epoca in range(1, 241):
        modelo.train()
        for entrada, respuesta in lotes:
            entrada = entrada.to(dispositivo)
            respuesta = respuesta.to(dispositivo)
            optimizador.zero_grad(set_to_none=True)
            perdida = F.cross_entropy(modelo(entrada), respuesta)
            assert torch.isfinite(perdida)
            perdida.backward()
            optimizador.step()
        medidas_ajuste = evaluar(modelo, ajuste, dispositivo, semilla)
        medidas_val = evaluar(modelo, validacion, dispositivo, semilla)
        historial.append({
            "epoca": epoca, "perdida_ajuste": medidas_ajuste["perdida"],
            "perdida_validacion": medidas_val["perdida"],
            "exactitud_ajuste": medidas_ajuste["exactitud"],
            "exactitud_validacion": medidas_val["exactitud"],
        })
        if medidas_val["perdida"] < mejor:
            mejor = medidas_val["perdida"]
            mejor_epoca = epoca
            estado = copy.deepcopy(modelo.state_dict())
            espera = 0
        else:
            espera += 1
        if time.perf_counter() - inicio > 1100:
            raise RuntimeError("Revisar presupuesto CPU antes de abrir prueba")
        if espera >= paciencia:
            break
        if epoca == limite:
            if mejor_epoca < limite:
                break
            limite *= 2
            print("Máximo ampliado a", limite)
    assert mejor_epoca < limite, "Ampliar presupuesto antes de evaluar prueba"
    modelo.load_state_dict(estado)
    modelo.eval()
    resumen = {
        "mejor_epoca": mejor_epoca, "epocas_ejecutadas": epoca,
        "maximo_final": limite, "perdida_validacion": mejor,
        "segundos": time.perf_counter() - inicio,
        "parametros": sum(p.numel() for p in modelo.parameters()),
    }
    print("Mejor época < máximo:", mejor_epoca, "<", limite, "=", mejor_epoca < limite)
    return pd.DataFrame(historial), resumen


def curvas(historial, titulo, mejor_epoca):
    """Recibe historial, título y época elegida; dibuja CE y exactitud comparables."""
    figura, ejes = plt.subplots(1, 2, figsize=(10, 3.6))
    for eje, medida, nombre in zip(
        ejes, ["perdida", "exactitud"], ["Entropía cruzada", "Exactitud"]
    ):
        eje.plot(historial.epoca, historial[medida + "_ajuste"], "-o",
                 color="#1786B3", label="Entrenamiento (evaluación)", markersize=3)
        eje.plot(historial.epoca, historial[medida + "_validacion"], "--s",
                 color="#EE5400", label="Validación", markersize=3)
        eje.axvline(mejor_epoca, color="#89878C", linestyle=":", label="Mejor época")
        eje.set(xlabel="Época", ylabel=nombre)
        eje.legend(fontsize=7)
    figura.suptitle(titulo)
    figura.tight_layout()
    plt.show()


def diagnostico(resultado, nombres, imagenes):
    """Recibe predicciones e imágenes; devuelve matriz y tabla de métricas por clase.

    Dibuja matriz con conteos y hasta ocho errores. Reutiliza las predicciones
    calculadas al abrir prueba, sin volver a evaluar el modelo.
    """
    reales = resultado["reales"]
    probabilidades = resultado["probabilidades"]
    predichos = probabilidades.argmax(1)
    matriz = confusion_matrix(reales, predichos, labels=np.arange(len(nombres)))
    reporte = classification_report(
        reales, predichos, target_names=nombres, output_dict=True, zero_division=0
    )
    figura, eje = plt.subplots(figsize=(7, 6))
    eje.imshow(matriz, cmap="Blues")
    eje.set_xticks(range(len(nombres)), nombres, rotation=65, ha="right", fontsize=8)
    eje.set_yticks(range(len(nombres)), nombres, fontsize=8)
    eje.set(xlabel="Categoría predicha", ylabel="Categoría real", title="Conteos en prueba")
    for i in range(len(nombres)):
        for j in range(len(nombres)):
            eje.text(j, i, str(matriz[i, j]), ha="center", va="center", fontsize=7,
                     color="white" if matriz[i, j] > matriz.max() / 2 else "black")
    eje.grid(False)
    figura.tight_layout()
    plt.show()
    errores = np.flatnonzero(reales != predichos)[:8]
    figura, ejes = plt.subplots(2, 4, figsize=(10, 5))
    for eje, indice in zip(ejes.flat, errores):
        eje.imshow(imagenes[indice, 0], cmap="gray")
        eje.set_title(
            f"Real: {nombres[reales[indice]]}\nPredicción: {nombres[predichos[indice]]}",
            fontsize=8,
        )
    for eje in ejes.flat:
        eje.axis("off")
    figura.tight_layout()
    plt.show()
    return matriz, pd.DataFrame(reporte).T.loc[nombres + ["macro avg"]]