28. Caso práctico completo con PyTorch

28.1 Introducción

En este tema cerraremos la primera parte del curso con un caso práctico completo.

Ya no veremos un concepto aislado, sino una aplicación real donde aparecen integradas muchas ideas estudiadas anteriormente: tensores, redes convolucionales, entrenamiento, función de pérdida, optimización, evaluación, uso de GPU, guardado del modelo e inferencia.

El objetivo es pasar de los ejemplos parciales a una solución completa de Deep Learning construida con PyTorch.

28.2 Enunciado del problema

Debemos desarrollar una aplicación capaz de reconocer dígitos escritos a mano.

Para ello entrenaremos una red neuronal convolucional con el dataset MNIST, que contiene imágenes de números del 0 al 9 en escala de grises.

Una vez entrenado el modelo, la aplicación permitirá que el usuario dibuje un número y obtenga una predicción junto con su nivel de confianza.

28.3 Objetivos del práctico

Este práctico tiene varios objetivos simultáneos:

  • Aplicar una CNN a un problema clásico de clasificación de imágenes.
  • Trabajar con un dataset real usando torchvision.datasets.MNIST.
  • Utilizar transformaciones para convertir imágenes en tensores y normalizarlas.
  • Entrenar el modelo por lotes con DataLoader.
  • Evaluar y usar el modelo entrenado para inferencia.
  • Guardar el modelo en disco para no entrenarlo cada vez.
  • Integrar el modelo de Deep Learning dentro de una aplicación completa.

28.4 Por qué este ejemplo es importante

MNIST es un problema clásico porque permite concentrarse en la lógica del Deep Learning sin quedar atrapado en la complejidad del dataset.

Aunque se trata de un problema sencillo en comparación con proyectos modernos más grandes, contiene muchos de los elementos reales que aparecen una y otra vez en aplicaciones prácticas.

Por eso este caso sirve como puente entre la teoría básica del curso y proyectos más completos.

Además, MNIST tiene un valor histórico muy importante dentro del aprendizaje automático y del Deep Learning. Su nombre proviene de Modified National Institute of Standards and Technology, porque fue construido a partir de bases de dígitos manuscritos recopiladas originalmente por el NIST en Estados Unidos.

Más adelante, Yann LeCun y otros investigadores reorganizaron ese material para crear un dataset más práctico y estandarizado para tareas de clasificación, y desde entonces se convirtió en uno de los conjuntos de datos más usados para enseñar y comparar modelos de reconocimiento de imágenes.

MNIST contiene imágenes en escala de grises de 28 por 28 píxeles correspondientes a los dígitos del 0 al 9. En total dispone de 60.000 imágenes de entrenamiento y 10.000 imágenes de prueba, lo que permite trabajar con una separación clara entre aprendizaje y evaluación.

En este práctico no necesitamos descargar manualmente los archivos desde una página web, porque PyTorch lo hace por nosotros mediante torchvision.datasets.MNIST usando download=True. Cuando ejecutamos esa instrucción, el dataset se descarga automáticamente desde los recursos públicos administrados para torchvision y queda almacenado localmente en la carpeta indicada por root="./data".

Esto vuelve al ejemplo todavía más apropiado para un curso introductorio: disponemos de un dataset histórico, bien documentado, ampliamente utilizado y muy fácil de integrar en un flujo real de entrenamiento con PyTorch.

28.5 Qué conceptos previos se integran aquí

Este práctico retoma de forma directa varios temas anteriores:

  • De los temas 4 a 11: neuronas, activaciones, forward propagation, función de pérdida, descenso del gradiente y backpropagation.
  • De los temas 13 a 19: tensores, construcción del modelo, entrenamiento, evaluación y clasificación en PyTorch.
  • Del tema 24: uso de redes convolucionales para imágenes.
  • Del tema 26: guardado y carga del modelo.
  • Del tema 27: selección automática de CPU o GPU mediante device.

28.6 Código completo del práctico 1

A continuación se presenta el código completo del práctico. Primero conviene ejecutarlo tal como está y luego leer con atención la explicación detallada de las partes de Deep Learning más importantes.

Aplicación de reconocimiento de dígitos manuscritos con PyTorch y Tkinter
import os
import threading
import queue
import tkinter as tk
from tkinter import ttk, messagebox

import torch
import torch.nn as nn
import torch.optim as optim
from torch.utils.data import DataLoader
from torchvision import datasets, transforms
from PIL import Image, ImageDraw


# ---------------------------------------------
# Configuración general
# ---------------------------------------------
DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
BATCH_SIZE = 64
EPOCHS = 3
LEARNING_RATE = 0.001
MODEL_PATH = "modelo_mnist_tkinter.pth"

clases = ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"]

print("Dispositivo usado:", DEVICE)

transformacion = transforms.Compose([
    transforms.ToTensor(),
    transforms.Normalize((0.5,), (0.5,))
])


# ---------------------------------------------
# Definición de la CNN
# ---------------------------------------------
class RedCNN(nn.Module):
    def __init__(self):
        super().__init__()

        self.conv1 = nn.Conv2d(in_channels=1, out_channels=32, kernel_size=3, padding=1)
        self.conv2 = nn.Conv2d(in_channels=32, out_channels=64, kernel_size=3, padding=1)
        self.pool = nn.MaxPool2d(kernel_size=2, stride=2)
        self.relu = nn.ReLU()

        self.fc1 = nn.Linear(64 * 7 * 7, 128)
        self.fc2 = nn.Linear(128, 10)

    def forward(self, x):
        x = self.relu(self.conv1(x))
        x = self.pool(x)

        x = self.relu(self.conv2(x))
        x = self.pool(x)

        x = x.view(x.size(0), -1)
        x = self.relu(self.fc1(x))
        x = self.fc2(x)

        return x


# ---------------------------------------------
# Aplicación Tkinter
# ---------------------------------------------
class AppMNIST:
    def __init__(self, root):
        self.root = root
        self.root.title("Reconocimiento de dígitos con PyTorch y Tkinter")
        self.root.geometry("760x520")
        self.root.resizable(False, False)

        self.modelo = RedCNN().to(DEVICE)
        self.cola = queue.Queue()
        self.entrenado = False

        self.ultimo_x = None
        self.ultimo_y = None

        self.crear_interfaz()

        hilo = threading.Thread(target=self.inicializar_modelo, daemon=True)
        hilo.start()

        self.root.after(100, self.procesar_cola)

    def crear_interfaz(self):
        marco_superior = tk.Frame(self.root, padx=10, pady=10)
        marco_superior.pack(fill="x")

        self.lbl_estado = tk.Label(
            marco_superior,
            text="Inicializando aplicación...",
            font=("Arial", 12, "bold")
        )
        self.lbl_estado.pack(anchor="w")

        self.barra = ttk.Progressbar(
            marco_superior,
            orient="horizontal",
            length=720,
            mode="determinate",
            maximum=100
        )
        self.barra.pack(pady=10)

        self.lbl_progreso = tk.Label(
            marco_superior,
            text="Preparando...",
            font=("Arial", 10)
        )
        self.lbl_progreso.pack(anchor="w")

        separador = ttk.Separator(self.root, orient="horizontal")
        separador.pack(fill="x", pady=5)

        marco_principal = tk.Frame(self.root, padx=10, pady=10)
        marco_principal.pack(fill="both", expand=True)

        panel_izquierdo = tk.Frame(marco_principal)
        panel_izquierdo.pack(side="left", padx=10)

        tk.Label(
            panel_izquierdo,
            text="Dibuje un número aquí",
            font=("Arial", 12, "bold")
        ).pack(pady=5)

        self.canvas = tk.Canvas(
            panel_izquierdo,
            width=280,
            height=280,
            bg="black",
            cursor="cross"
        )
        self.canvas.pack()

        self.canvas.bind("<Button-1>", self.iniciar_trazo)
        self.canvas.bind("<B1-Motion>", self.dibujar)
        self.canvas.bind("<ButtonRelease-1>", self.terminar_trazo)

        botones = tk.Frame(panel_izquierdo)
        botones.pack(pady=10)

        self.btn_predecir = tk.Button(
            botones,
            text="Predecir",
            width=12,
            state="disabled",
            command=self.predecir_numero
        )
        self.btn_predecir.grid(row=0, column=0, padx=5)

        self.btn_limpiar = tk.Button(
            botones,
            text="Limpiar",
            width=12,
            state="disabled",
            command=self.limpiar_canvas
        )
        self.btn_limpiar.grid(row=0, column=1, padx=5)

        panel_derecho = tk.Frame(marco_principal, padx=20)
        panel_derecho.pack(side="left", fill="both", expand=True)

        tk.Label(
            panel_derecho,
            text="Resultado",
            font=("Arial", 14, "bold")
        ).pack(pady=10)

        self.lbl_resultado = tk.Label(
            panel_derecho,
            text="-",
            font=("Arial", 60, "bold"),
            fg="blue"
        )
        self.lbl_resultado.pack(pady=20)

        self.lbl_confianza = tk.Label(
            panel_derecho,
            text="Confianza: -",
            font=("Arial", 12)
        )
        self.lbl_confianza.pack(pady=10)

        self.txt_info = tk.Label(
            panel_derecho,
            text="Espere mientras se carga o entrena el modelo.",
            font=("Arial", 11),
            justify="left"
        )
        self.txt_info.pack(pady=20)

        self.imagen_pil = Image.new("L", (280, 280), color=0)
        self.draw_pil = ImageDraw.Draw(self.imagen_pil)

    def iniciar_trazo(self, event):
        self.ultimo_x = event.x
        self.ultimo_y = event.y

    def dibujar(self, event):
        if self.ultimo_x is not None and self.ultimo_y is not None:
            self.canvas.create_line(
                self.ultimo_x, self.ultimo_y,
                event.x, event.y,
                fill="white",
                width=18,
                capstyle=tk.ROUND,
                smooth=True
            )

            self.draw_pil.line(
                [(self.ultimo_x, self.ultimo_y), (event.x, event.y)],
                fill=255,
                width=18
            )

        self.ultimo_x = event.x
        self.ultimo_y = event.y

    def terminar_trazo(self, event):
        self.ultimo_x = None
        self.ultimo_y = None

    def limpiar_canvas(self):
        self.canvas.delete("all")
        self.imagen_pil = Image.new("L", (280, 280), color=0)
        self.draw_pil = ImageDraw.Draw(self.imagen_pil)
        self.lbl_resultado.config(text="-")
        self.lbl_confianza.config(text="Confianza: -")

    def preprocesar_imagen(self):
        imagen = self.imagen_pil.resize((28, 28), Image.Resampling.LANCZOS)

        tensor = transforms.ToTensor()(imagen)
        tensor = transforms.Normalize((0.5,), (0.5,))(tensor)
        tensor = tensor.unsqueeze(0).to(DEVICE)

        return tensor

    def predecir_numero(self):
        if not self.entrenado:
            messagebox.showinfo("Información", "El modelo todavía no está listo.")
            return

        tensor = self.preprocesar_imagen()

        self.modelo.eval()
        with torch.no_grad():
            salida = self.modelo(tensor)
            probabilidades = torch.softmax(salida, dim=1)
            confianza, prediccion = torch.max(probabilidades, 1)

        numero = prediccion.item()
        porcentaje = confianza.item() * 100

        self.lbl_resultado.config(text=str(numero))
        self.lbl_confianza.config(text=f"Confianza: {porcentaje:.2f}%")

    def inicializar_modelo(self):
        try:
            if os.path.exists(MODEL_PATH):
                self.cola.put(("estado", "Modelo encontrado. Cargando desde disco..."))
                self.cola.put(("progreso", 20, "Abriendo archivo del modelo..."))

                state_dict = torch.load(MODEL_PATH, map_location=DEVICE)
                self.modelo.load_state_dict(state_dict)
                self.modelo.to(DEVICE)
                self.modelo.eval()

                self.cola.put(("progreso", 100, "Modelo cargado correctamente."))
                self.cola.put(("fin", "Modelo cargado desde archivo. Ya puede dibujar y predecir."))
            else:
                self.entrenar_modelo()

        except Exception as e:
            self.cola.put(("error", str(e)))

    def entrenar_modelo(self):
        self.cola.put(("estado", "No se encontró modelo guardado. Entrenando..."))
        self.cola.put(("progreso", 0, "Descargando/cargando MNIST..."))

        train_dataset = datasets.MNIST(
            root="./data",
            train=True,
            download=True,
            transform=transformacion
        )

        test_dataset = datasets.MNIST(
            root="./data",
            train=False,
            download=True,
            transform=transformacion
        )

        train_loader = DataLoader(train_dataset, batch_size=BATCH_SIZE, shuffle=True)
        test_loader = DataLoader(test_dataset, batch_size=BATCH_SIZE, shuffle=False)

        criterio = nn.CrossEntropyLoss()
        optimizador = optim.Adam(self.modelo.parameters(), lr=LEARNING_RATE)

        total_pasos = EPOCHS * len(train_loader)
        paso_actual = 0

        for epoca in range(EPOCHS):
            self.modelo.train()
            perdida_total = 0.0
            correctos = 0
            total = 0

            for lote, (imagenes, etiquetas) in enumerate(train_loader, start=1):
                imagenes = imagenes.to(DEVICE)
                etiquetas = etiquetas.to(DEVICE)

                optimizador.zero_grad()

                salidas = self.modelo(imagenes)
                perdida = criterio(salidas, etiquetas)

                perdida.backward()
                optimizador.step()

                perdida_total += perdida.item() * imagenes.size(0)

                _, predichas = torch.max(salidas, 1)
                total += etiquetas.size(0)
                correctos += (predichas == etiquetas).sum().item()

                paso_actual += 1
                progreso = (paso_actual / total_pasos) * 100

                self.cola.put((
                    "progreso",
                    progreso,
                    f"Época {epoca + 1}/{EPOCHS} - lote {lote}/{len(train_loader)}"
                ))

            perdida_promedio = perdida_total / total
            exactitud = 100 * correctos / total

            self.cola.put((
                "estado",
                f"Época {epoca + 1}/{EPOCHS} finalizada - "
                f"Pérdida: {perdida_promedio:.4f} - Exactitud: {exactitud:.2f}%"
            ))

        self.modelo.eval()
        correctos = 0
        total = 0

        with torch.no_grad():
            for imagenes, etiquetas in test_loader:
                imagenes = imagenes.to(DEVICE)
                etiquetas = etiquetas.to(DEVICE)

                salidas = self.modelo(imagenes)
                _, predichas = torch.max(salidas, 1)

                total += etiquetas.size(0)
                correctos += (predichas == etiquetas).sum().item()

        exactitud_prueba = 100 * correctos / total

        torch.save(self.modelo.state_dict(), MODEL_PATH)

        self.cola.put(("progreso", 100, "Modelo entrenado y guardado correctamente."))
        self.cola.put(("fin", f"Entrenamiento finalizado. Exactitud en prueba: {exactitud_prueba:.2f}%"))

    def procesar_cola(self):
        try:
            while True:
                mensaje = self.cola.get_nowait()

                if mensaje[0] == "estado":
                    self.lbl_progreso.config(text=mensaje[1])

                elif mensaje[0] == "progreso":
                    _, valor, texto = mensaje
                    self.barra["value"] = valor
                    self.lbl_progreso.config(text=texto)

                elif mensaje[0] == "fin":
                    self.barra["value"] = 100
                    self.lbl_estado.config(text="Modelo listo para usar")
                    self.lbl_progreso.config(text=mensaje[1])
                    self.txt_info.config(
                        text="Ahora dibuje un número con el mouse\n"
                             "y presione el botón Predecir."
                    )
                    self.btn_predecir.config(state="normal")
                    self.btn_limpiar.config(state="normal")
                    self.entrenado = True

                elif mensaje[0] == "error":
                    self.lbl_estado.config(text="Ocurrió un error")
                    self.lbl_progreso.config(text=mensaje[1])
                    messagebox.showerror("Error", mensaje[1])

        except queue.Empty:
            pass

        self.root.after(100, self.procesar_cola)


# ---------------------------------------------
# Programa principal
# ---------------------------------------------
if __name__ == "__main__":
    root = tk.Tk()
    app = AppMNIST(root)
    root.mainloop()

28.7 Visión general del flujo de Deep Learning

Si dejamos de lado la interfaz visual, el flujo de Deep Learning de esta aplicación puede resumirse así:

  1. Definir hiperparámetros y el dispositivo de trabajo.
  2. Preparar el dataset MNIST y sus transformaciones.
  3. Definir una CNN adecuada para imágenes de 28x28.
  4. Entrenar la red con propagación hacia adelante, pérdida, backpropagation y optimización.
  5. Evaluar el modelo sobre datos no usados para entrenamiento.
  6. Guardar el modelo aprendido.
  7. Cargar el modelo y usarlo para inferencia sobre nuevos datos.

Este es exactamente el tipo de estructura que luego aparece en problemas más grandes y reales.

28.8 Configuración general e hiperparámetros

El bloque inicial define valores como BATCH_SIZE, EPOCHS y LEARNING_RATE.

Aquí estamos retomando el tema 22 sobre ajuste de hiperparámetros. Estos valores no son aprendidos por la red: los decide el programador antes de entrenar.

También aparece:

DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")

Esta línea conecta con el tema 27 y permite que el mismo programa funcione tanto en CPU como en GPU.

28.9 El dataset MNIST

MNIST es un conjunto de imágenes de dígitos manuscritos en escala de grises.

Cada imagen tiene tamaño 28x28 y pertenece a una de 10 clases: los dígitos del 0 al 9.

Desde el punto de vista del curso, este dataset es ideal porque convierte el problema en una clasificación multiclase, exactamente como vimos en el tema 19.

28.10 Transformaciones: de imagen a tensor normalizado

La variable transformacion usa transforms.Compose para encadenar dos pasos:

  • transforms.ToTensor()
  • transforms.Normalize((0.5,), (0.5,))

El primer paso convierte la imagen en un tensor de PyTorch, retomando directamente los temas 13 y 14.

El segundo paso normaliza los valores. En vez de trabajar con intensidades crudas, la red recibe datos escalados de forma más conveniente para el entrenamiento.

La normalización ayuda a que el aprendizaje sea más estable y a que el descenso del gradiente avance mejor.

28.11 La arquitectura de la red convolucional

La clase RedCNN hereda de nn.Module, como ya vimos al construir modelos en PyTorch.

La arquitectura tiene dos partes:

  • Una parte convolucional para extraer características espaciales de la imagen.
  • Una parte totalmente conectada para convertir esas características en una predicción final.

Esto conecta de forma directa con el tema 24 sobre CNN y con el tema 7 sobre arquitectura general de una red neuronal.

28.12 Las capas convolucionales

La red define:

self.conv1 = nn.Conv2d(in_channels=1, out_channels=32, kernel_size=3, padding=1)
self.conv2 = nn.Conv2d(in_channels=32, out_channels=64, kernel_size=3, padding=1)

La primera capa recibe una sola entrada porque la imagen es en escala de grises. Luego produce 32 mapas de características.

La segunda toma esos 32 mapas y genera 64 mapas más abstractos.

Aquí aparece la idea fundamental de las CNN: en lugar de aplanar de entrada la imagen, dejamos que la red descubra patrones locales como bordes, curvas y combinaciones más complejas.

28.13 Función de activación ReLU

La red usa nn.ReLU(), retomando el tema 6 sobre funciones de activación.

Si no colocáramos activaciones no lineales entre capas, toda la red se comportaría como una transformación lineal global, perdiendo gran parte de su poder expresivo.

ReLU introduce no linealidad y además suele ser una opción práctica y muy usada en redes profundas.

28.14 Max Pooling

La capa:

self.pool = nn.MaxPool2d(kernel_size=2, stride=2)

reduce el tamaño espacial de los mapas de características.

Después de cada bloque convolucional, la imagen interna se hace más pequeña, lo que disminuye el costo computacional y conserva información relevante.

Esta reducción progresiva ayuda a que la red capture patrones cada vez más globales.

28.15 De mapas de características a capas densas

Tras las convoluciones y el pooling, el tensor se reorganiza con:

x = x.view(x.size(0), -1)

Esto aplana la salida para que pueda entrar a las capas densas:

self.fc1 = nn.Linear(64 * 7 * 7, 128)
self.fc2 = nn.Linear(128, 10)

La última capa tiene 10 neuronas porque el problema tiene 10 clases posibles. Esto conecta con el tema 19 sobre clasificación multiclase.

28.16 Forward propagation en la práctica

El método forward expresa de manera concreta la propagación hacia adelante estudiada en el tema 8.

Los datos avanzan capa a capa:

  • Convolución.
  • Activación.
  • Pooling.
  • Nueva convolución.
  • Nueva activación.
  • Nuevo pooling.
  • Aplanado.
  • Capas densas.

El resultado final son logits, es decir, valores sin normalizar para cada clase.

28.17 DataLoader y entrenamiento por lotes

El práctico utiliza:

train_loader = DataLoader(train_dataset, batch_size=BATCH_SIZE, shuffle=True)

Esto significa que no entrenamos imagen por imagen, sino en mini-batches.

Esta idea está ligada al tema 17: el entrenamiento en PyTorch suele hacerse por lotes porque es más eficiente computacionalmente y produce actualizaciones de gradiente más estables que el entrenamiento completamente individual.

Además, shuffle=True mezcla los ejemplos en cada época, algo muy importante para evitar sesgos debidos al orden de los datos.

28.18 Función de pérdida

La función elegida es:

criterio = nn.CrossEntropyLoss()

Esto conecta con el tema 9 sobre función de pérdida.

Como estamos ante un problema de clasificación multiclase, CrossEntropyLoss es la opción natural en PyTorch. Esta pérdida compara los logits producidos por la red con la clase correcta esperada.

Cuanto más se aleja la predicción de la etiqueta real, mayor será la pérdida.

28.19 El optimizador Adam

El práctico usa:

optimizador = optim.Adam(self.modelo.parameters(), lr=LEARNING_RATE)

Aquí retomamos el tema 10 sobre descenso del gradiente.

Adam es un optimizador muy popular porque ajusta dinámicamente el paso de actualización de cada parámetro y suele converger bien en muchos problemas prácticos.

La tasa de aprendizaje LEARNING_RATE sigue siendo un hiperparámetro crítico: si es demasiado alta, el entrenamiento puede volverse inestable; si es demasiado baja, puede avanzar demasiado lento.

28.20 El ciclo de entrenamiento completo

Dentro del método entrenar_modelo aparece el patrón central del entrenamiento en Deep Learning:

  1. Mover imágenes y etiquetas al DEVICE.
  2. Llamar a optimizador.zero_grad().
  3. Ejecutar el forward con self.modelo(imagenes).
  4. Calcular la pérdida.
  5. Ejecutar perdida.backward().
  6. Llamar a optimizador.step().

Aquí están unidas, en código real, las ideas de forward propagation, función de pérdida, backpropagation y descenso del gradiente que se estudiaron de forma conceptual en los primeros temas.

28.21 Backpropagation

La línea:

perdida.backward()

es la aplicación directa del tema 11.

PyTorch calcula automáticamente los gradientes de todos los parámetros del modelo con respecto a la pérdida final. Esto evita derivar a mano cada expresión, pero conceptualmente está ocurriendo exactamente el mismo proceso explicado cuando vimos backpropagation.

28.22 Actualización de parámetros

Después de calcular gradientes, la línea:

optimizador.step()

actualiza pesos y bias del modelo.

Es decir, la red modifica sus parámetros para reducir la pérdida en futuras iteraciones. Ese aprendizaje progresivo es el corazón del entrenamiento supervisado.

28.23 Métricas durante el entrenamiento

Además de la pérdida, el código calcula la cantidad de aciertos dentro de cada época.

Esto permite obtener una medida de exactitud o accuracy, que resulta mucho más intuitiva para un problema de clasificación.

Es importante entender que pérdida y exactitud no son lo mismo:

  • La pérdida es la magnitud optimizada directamente por el algoritmo.
  • La exactitud es una métrica de desempeño más fácil de interpretar.

Esta distinción conecta con el tema 18 sobre evaluación del modelo.

28.24 Evaluación sobre el conjunto de prueba

Cuando termina el entrenamiento, el modelo se evalúa sobre test_loader.

Esto es fundamental porque no alcanza con medir el desempeño en los datos usados para entrenar. Necesitamos verificar si la red generaliza.

Aquí reaparece una idea central del curso: un modelo no debe memorizar solamente el conjunto de entrenamiento, sino aprender patrones que funcionen también en datos nuevos.

28.25 Modo evaluación y desactivación de gradientes

En distintos puntos del código aparecen:

self.modelo.eval()
with torch.no_grad():

Esto conecta con el tema 18 y también con el tema 26.

eval() pone al modelo en modo evaluación y torch.no_grad() evita calcular gradientes innecesarios durante inferencia o prueba, reduciendo uso de memoria y costo computacional.

28.26 Guardado y carga del modelo

El práctico evita entrenar siempre desde cero gracias a:

torch.save(self.modelo.state_dict(), MODEL_PATH)

y luego:

state_dict = torch.load(MODEL_PATH, map_location=DEVICE)
self.modelo.load_state_dict(state_dict)

Esto enlaza con el tema 26. Se guarda el state_dict, que contiene los parámetros aprendidos, y luego se vuelve a cargar cuando el archivo ya existe.

De ese modo, la aplicación se comporta como un proyecto real: si el modelo ya fue entrenado, simplemente se reutiliza.

28.27 El uso del device en un proyecto real

Todo el práctico respeta una regla técnica muy importante del tema 27: el modelo y los tensores deben vivir en el mismo dispositivo.

Por eso aparecen llamadas como:

self.modelo = RedCNN().to(DEVICE)
imagenes = imagenes.to(DEVICE)
etiquetas = etiquetas.to(DEVICE)
tensor = tensor.unsqueeze(0).to(DEVICE)

Esto permite que el mismo código funcione correctamente tanto en CPU como en GPU.

28.28 Inferencia sobre nuevas entradas

Cuando el usuario dibuja un dígito, la imagen generada debe pasar por un preprocesamiento equivalente al del entrenamiento.

Por eso se redimensiona a 28x28, se convierte en tensor y se normaliza con los mismos parámetros.

Esta consistencia es clave: si entrenamos con una representación de datos y luego inferimos con otra muy distinta, el modelo puede fallar aunque la red esté bien entrenada.

28.29 Softmax y probabilidad de cada clase

Durante la predicción aparece:

probabilidades = torch.softmax(salida, dim=1)

La salida cruda del modelo son logits. Al aplicar softmax, esos valores se transforman en probabilidades que suman 1.

Esto permite responder no solo cuál es la clase más probable, sino también con qué confianza se produjo la decisión.

La clase final se obtiene con torch.max, es decir, eligiendo la probabilidad mayor.

28.30 Qué no debe confundirse con Deep Learning

La aplicación tiene una parte visual hecha con Tkinter, pero esa parte no es el foco conceptual de este curso.

Desde el punto de vista del Deep Learning, lo realmente importante aquí es:

  • Cómo se representan los datos.
  • Cómo se define la arquitectura.
  • Cómo se entrena el modelo.
  • Cómo se evalúa.
  • Cómo se reutiliza para inferencia.

La interfaz solo cumple el papel de acercar el modelo a un escenario de uso real.

28.31 Relación con los temas anteriores

Si observamos el práctico completo, podemos ver una continuidad muy clara del curso:

  • El perceptrón y las capas vistas al comienzo se amplían ahora en una red más profunda.
  • La propagación hacia adelante ya no es un ejemplo abstracto, sino una secuencia real de convoluciones, activaciones y capas densas.
  • La función de pérdida deja de ser una idea teórica y pasa a guiar el aprendizaje efectivo del modelo.
  • Backpropagation y descenso del gradiente aparecen implementados con backward() y step().
  • Las CNN del tema 24 muestran aquí su utilidad concreta en imágenes.
  • Guardar el modelo y usar GPU dejan de ser extras y se vuelven partes naturales del flujo de trabajo.

28.32 Qué se aprende realmente con este práctico

El mayor valor de este ejercicio no es solo reconocer dígitos, sino comprender cómo se construye una solución completa basada en Deep Learning.

Después de este caso práctico ya debería quedar claro que un proyecto real no consiste solamente en definir una red, sino en integrar datos, preprocesamiento, entrenamiento, evaluación, persistencia e inferencia en un único flujo coherente.

28.33 Posibles mejoras futuras

Aunque este práctico ya es completamente funcional, a partir de aquí se podrían explorar varias mejoras:

  • Aumentar la cantidad de épocas.
  • Comparar distintos optimizadores.
  • Agregar validación separada del conjunto de prueba.
  • Experimentar con más capas o con regularización.
  • Medir tiempos de entrenamiento en CPU y GPU.

Estas extensiones conectan naturalmente con temas más avanzados y con una práctica más profesional del entrenamiento de modelos.

28.34 Qué debes recordar de este tema

  • Un caso práctico completo integra muchos conceptos vistos por separado a lo largo del curso.
  • MNIST es un excelente problema inicial para clasificación de imágenes con CNN.
  • La red aprende mediante forward propagation, pérdida, backpropagation y optimización.
  • DataLoader permite entrenar por lotes de manera eficiente.
  • La evaluación debe hacerse sobre datos no usados en entrenamiento.
  • Guardar y cargar el modelo vuelve la aplicación reutilizable.
  • El preprocesamiento de inferencia debe ser consistente con el preprocesamiento de entrenamiento.

28.35 Cierre conceptual

Este práctico marca un punto importante del curso porque muestra el recorrido completo de una solución de Deep Learning en PyTorch.

A partir de aquí, los siguientes proyectos podrán ser más ambiciosos, pero la lógica de fondo seguirá siendo esencialmente la misma: datos, modelo, entrenamiento, evaluación e inferencia.