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.
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.
Este práctico tiene varios objetivos simultáneos:
torchvision.datasets.MNIST.DataLoader.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.
Este práctico retoma de forma directa varios temas anteriores:
device.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.
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()
Si dejamos de lado la interfaz visual, el flujo de Deep Learning de esta aplicación puede resumirse así:
Este es exactamente el tipo de estructura que luego aparece en problemas más grandes y reales.
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:
Esta línea conecta con el tema 27 y permite que el mismo programa funcione tanto en CPU como en GPU.
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.
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.
La clase RedCNN hereda de nn.Module, como ya vimos al construir modelos en PyTorch.
La arquitectura tiene dos partes:
Esto conecta de forma directa con el tema 24 sobre CNN y con el tema 7 sobre arquitectura general de una red neuronal.
La red define:
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.
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.
La capa:
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.
Tras las convoluciones y el pooling, el tensor se reorganiza con:
Esto aplana la salida para que pueda entrar a las capas densas:
La última capa tiene 10 neuronas porque el problema tiene 10 clases posibles. Esto conecta con el tema 19 sobre clasificación multiclase.
El método forward expresa de manera concreta la propagación hacia adelante estudiada en el tema 8.
Los datos avanzan capa a capa:
El resultado final son logits, es decir, valores sin normalizar para cada clase.
El práctico utiliza:
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.
La función elegida es:
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.
El práctico usa:
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.
Dentro del método entrenar_modelo aparece el patrón central del entrenamiento en Deep Learning:
DEVICE.optimizador.zero_grad().self.modelo(imagenes).perdida.backward().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.
La línea:
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.
Después de calcular gradientes, la línea:
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.
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:
Esta distinción conecta con el tema 18 sobre evaluación del modelo.
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.
En distintos puntos del código aparecen:
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.
El práctico evita entrenar siempre desde cero gracias a:
y luego:
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.
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:
Esto permite que el mismo código funcione correctamente tanto en CPU como en GPU.
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.
Durante la predicción aparece:
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.
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:
La interfaz solo cumple el papel de acercar el modelo a un escenario de uso real.
Si observamos el práctico completo, podemos ver una continuidad muy clara del curso:
backward() y step().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.
Aunque este práctico ya es completamente funcional, a partir de aquí se podrían explorar varias mejoras:
Estas extensiones conectan naturalmente con temas más avanzados y con una práctica más profesional del entrenamiento de modelos.
DataLoader permite entrenar por lotes de manera eficiente.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.