Entrenando un modelo de visión (I): primeros pasos con una CNN (Convolutional neural network)

Entrenando un modelo de visión (I): primeros pasos con una CNN (Convolutional neural network)

Esta es la primera entrega de una serie práctica sobre cómo entrenar un modelo de visión artificial desde cero. No partimos de teoría abstracta: partimos de un script real, funcionando, y lo vamos desmontando pieza a pieza para entender qué hace cada línea y por qué.

En este primer artículo montamos el esqueleto: la estructura de carpetas, el script de entrenamiento y la arquitectura de la red neuronal convolucional (CNN) que vamos a usar como base.


1. Punto de partida: estructura del proyecto

Antes de tocar código, así es como organizamos el directorio del proyecto:


testENTRENAR_IA/
├── data/
│   └── cifar-10/     # imágenes y etiquetas
├── models/
│   └── modelo_cifar10.pth     # pesos entrenados
└── train_cifar10.py            # script de entrenamiento

Nada exótico: una carpeta para los datos de entrada, otra donde se guardan los pesos ya entrenados, y el script principal. Esta estructura se repite en casi cualquier proyecto de entrenamiento, así que conviene acostumbrarse a ella desde el primer día.

Descarga: Puedes descargar el conjunto de datos de entrenamiento desde cave.cs.toronto.edu


2. El script de entrenamiento, sección por sección

Rutas y dispositivo


# os.path.abspath(__file__) da la ruta completa de este script.
# os.path.dirname() se queda solo con la carpeta que lo contiene.
# Así ROOT_DIR siempre apunta a "testENTRENAR_IA/", da igual desde dónde ejecutes el script.
ROOT_DIR = os.path.dirname(os.path.abspath(__file__))
# Construimos el resto de rutas a partir de ROOT_DIR, en vez de escribirlas
# a mano, para que el proyecto funcione igual en tu PC, en el servidor, etc.
DATA_DIR = os.path.join(ROOT_DIR, "data", "cifar10")   # carpeta con las imágenes
TRAIN_DIR = os.path.join(DATA_DIR, "train")             # subcarpeta de entrenamiento
TEST_DIR = os.path.join(DATA_DIR, "test")               # subcarpeta de evaluación
MODEL_DIR = os.path.join(ROOT_DIR, "models")            # aquí guardaremos el modelo ya entrenado
# Crea la carpeta "models/" si no existe todavía.
# exist_ok=True evita que el script falle si la carpeta ya está creada.
os.makedirs(MODEL_DIR, exist_ok=True)
# Elegimos dónde se va a entrenar: GPU si hay una disponible (mucho más rápido),
# y si no, caemos a CPU automáticamente sin que el script se rompa.
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")

Todo esto es preparación: dónde están los datos, dónde se va a guardar el modelo entrenado, y en qué dispositivo se va a entrenar. Si hay GPU disponible (`cuda`), se usa; si no, cae a CPU automáticamente.

Transformaciones y carga de datos (Revisión de dataset)


# transforms.Compose encadena varias transformaciones que se aplican
# a CADA imagen, en orden, antes de que entre a la red.
transform = transforms.Compose([
    transforms.Resize((32, 32)),   # todas las imágenes deben medir lo mismo; 32x32 = tamaño de CIFAR-10
    transforms.ToTensor(),          # convierte la imagen (píxeles 0-255) a un tensor de PyTorch (0.0-1.0)
    transforms.Normalize((0.5,), (0.5,))   # reescala los valores a un rango centrado en 0, ayuda a que el entrenamiento converja mejor
])
# ImageFolder asume que cada subcarpeta dentro de TRAIN_DIR es una clase distinta.
# Ejemplo: train/gallina/, train/pato/, train/ganso/ -> 3 clases detectadas automáticamente.
train_dataset = ImageFolder(root=TRAIN_DIR, transform=transform)
# DataLoader se encarga de servir las imágenes en "lotes" (batches) en vez de una a una.
# batch_size=32 -> procesamos 32 imágenes de golpe antes de actualizar el modelo.
# shuffle=True -> mezcla el orden en cada época, para que el modelo no memorice el orden de los datos.
train_loader = DataLoader(train_dataset, batch_size=32, shuffle=True)

Las imágenes no entran "tal cual" a la red: se redimensionan a un tamaño fijo (32×32, el mismo que usa el dataset CIFAR-10) y se normalizan. `ImageFolder` es una utilidad de PyTorch que asume que cada subcarpeta dentro de `train/` es una clase distinta — así que si tienes `train/gallina/`, `train/pato/` y `train/ganso/`, PyTorch detecta automáticamente esas tres clases sin que tengas que etiquetarlas a mano.


3. La arquitectura: la clase `Net`

Aquí está el corazón del modelo, representado por la arquitectura de la red:

Evolución de las capas convolucionales de una CNN desde bordes simples hasta reconocimiento de objetos

# Toda red en PyTorch hereda de nn.Module. Eso le da "gratis" cosas como
# mover el modelo a la GPU, guardar/cargar pesos, etc.
class Net(nn.Module):
    # __init__ es donde DECLARAMOS las capas. Aquí todavía no pasa ningún dato,
    # solo estamos dejando las "piezas" preparadas encima de la mesa.
    def __init__(self, num_classes):
        super(Net, self).__init__()   # obligatorio: inicializa la parte interna de nn.Module
        # Primera capa convolucional:
        # 3  -> canales de entrada (imagen a color: Rojo, Verde, Azul)
        # 16 -> número de filtros que vamos a aprender (16 "detectores" distintos)
        # 3  -> tamaño del filtro, 3x3 píxeles
        # padding=1 -> añade un borde de 1 píxel para que la imagen no encoja al convolucionar
        self.conv1 = nn.Conv2d(3, 16, 3, padding=1)
        # Pooling: reduce el tamaño de la imagen a la mitad.
        # (2, 2) -> ventana de 2x2 píxeles, se queda con el valor más alto de cada ventana
        self.pool = nn.MaxPool2d(2, 2)
        # Segunda capa convolucional:
        # 16 -> entra con los 16 filtros que generó conv1
        # 32 -> saca 32 filtros nuevos, buscando patrones más complejos que la primera capa
        self.conv2 = nn.Conv2d(16, 32, 3, padding=1)
        # Capa densa (fully connected): conecta TODAS las neuronas de entrada con TODAS las de salida.
        # 32 * 8 * 8 -> tamaño del tensor aplanado que sale de las convoluciones (ver explicación abajo)
        # 128 -> tamaño del vector "resumen" que generamos para cada imagen
        self.fc1 = nn.Linear(32 * 8 * 8, 128)
        # Última capa: una neurona de salida por cada clase posible (gallina, pato, ganso...).
        self.fc2 = nn.Linear(128, num_classes)
    # forward define el CAMINO real que recorre el dato, en orden, desde que
    # entra la imagen hasta que sale la predicción.
    def forward(self, x):
        # Paso 1: convolución -> activación ReLU -> pooling
        # ReLU simplemente apaga (pone a 0) los valores negativos; ayuda a que la red aprenda mejor.
        x = self.pool(torch.relu(self.conv1(x)))
        # Paso 2: repetimos el mismo patrón con la segunda convolución
        x = self.pool(torch.relu(self.conv2(x)))
        # Paso 3: "aplanamos" el tensor. Pasa de tener forma [canales, alto, ancho]
        # a ser un único vector largo, que es lo que espera una capa densa (Linear).
        # El -1 le dice a PyTorch "calcula tú el tamaño de este eje automáticamente".
        x = x.view(-1, 32 * 8 * 8)
        # Paso 4: pasamos por la primera capa densa + ReLU
        x = torch.relu(self.fc1(x))
        # Paso 5: capa de salida. SIN activación aquí a propósito
        # (CrossEntropyLoss, más abajo, ya aplica softmax internamente).
        x = self.fc2(x)
        return x
# Creamos una instancia de la red y la enviamos al dispositivo elegido antes (GPU o CPU).
net = Net(num_classes).to(device)

Toda red en PyTorch se define en dos sitios distintos, y es importante no confundirlos:

  • `_init_` — aquí solo declaras las capas que vas a usar, como quien prepara las herramientas encima de la mesa antes de empezar a trabajar. Todavía no hay flujo de datos.
  • `forward` — aquí defines el orden real en que esas capas procesan la imagen. Es el camino que recorre el dato desde que entra hasta que sale convertido en una predicción.

Qué hace cada capa

CapaLínea de códigoFunción
Conv1`nn.Conv2d(3, 16, 3, padding=1)`Primera convolución: entra con 3 canales (RGB) y genera 16 filtros de 3×3. El `padding=1` evita perder tamaño en los bordes.
Pool`nn.MaxPool2d(2, 2)`Reduce a la mitad el tamaño del mapa de activación (de 32×32 a 16×16 tras la primera pasada).
Conv2`nn.Conv2d(16, 32, 3, padding=1)`Segunda convolución: pasa de 16 a 32 filtros, buscando patrones más complejos que la primera capa.
FC1`nn.Linear(32 * 8 * 8, 128)`Capa densa: aplana el resultado de las convoluciones (32 canales de 8×8 tras dos poolings) y lo convierte en un vector de 128 dimensiones.
FC2`nn.Linear(128, num_classes)`Capa de salida: una neurona por cada clase posible del dataset.

Un par de analogías para que estas piezas queden claras de verdad:

  • `padding=1` es como poner un marco de un píxel alrededor de la foto. Si no lo pusiéramos, el filtro de 3×3 no podría "centrarse" sobre los píxeles del borde exterior (le faltaría vecino por un lado), y la imagen resultante saldría un poco más pequeña cada vez que pasa por una convolución. El marco añadido evita ese recorte.
  • `MaxPool2d(2, 2)` es como reducir una foto a la mitad quedándote solo con el píxel más "intenso" de cada bloque de 2×2. Imagina que divides la imagen en cuadraditos de 4 píxeles y, de cada cuadradito, solo conservas el que tiene el valor más alto. Pierdes detalle fino, pero conservas lo que más "destacaba" en esa zona — por eso el tamaño se reduce a la mitad en cada pasada.
  • `x.view(-1, 32 * 8 * 8)` es como desenrollar una manta doblada en un solo hilo largo. Antes de esta línea, el dato tiene forma de "caja" (32 capas de 8×8 píxeles cada una). Una capa densa (`Linear`) no entiende cajas, solo listas de números en fila. `view()` no cambia ningún valor, solo lo "desenrolla" todo en una única lista larga de 2048 números para que la capa densa pueda procesarlo.

Por qué `32 * 8 * 8`: la imagen entra a 32×32. Cada `MaxPool2d(2,2)` reduce el tamaño a la mitad, y aplicamos pooling dos veces (una después de cada convolución), así que 32 → 16 → 8. Con 32 filtros en la última convolución, el tensor aplanado que llega a la capa densa tiene 32 × 8 × 8 = 2048 valores. Este cálculo hay que rehacerlo cada vez que cambias el tamaño de entrada o el número de capas convolucionales — es el error más habitual al modificar una arquitectura ya existente.

El pooling reduce el tamaño de los mapas de activación conservando la información más importante. Esto hace que la red sea más rápida, consuma menos memoria y sea más robusta frente a pequeños desplazamientos.

4. El bucle de entrenamiento y evaluación


# La "función de pérdida" mide cuánto se equivoca el modelo en cada predicción.
# CrossEntropyLoss es la estándar para clasificación multi-clase.
criterion = nn.CrossEntropyLoss()
# El optimizador es quien ajusta los pesos del modelo para reducir esa pérdida.
# Adam es la elección por defecto más habitual; lr=0.001 es el "tamaño de paso" de cada ajuste.
optimizer = optim.Adam(net.parameters(), lr=0.001)
# Una "época" = una pasada completa por TODO el dataset de entrenamiento.
# Entrenamos durante 10 épocas.
for epoch in range(10):
    running_loss = 0.0   # acumulador para ver cómo evoluciona el error dentro de la época
    # train_loader nos va dando lotes (batches) de 32 imágenes con sus etiquetas.
    for i, (inputs, labels) in enumerate(train_loader):
        # Movemos el lote actual al mismo dispositivo donde está el modelo (GPU o CPU).
        inputs, labels = inputs.to(device), labels.to(device)
        # PyTorch acumula gradientes por defecto; hay que resetearlos en cada lote
        # o se sumarían con los del lote anterior y el entrenamiento se rompería.
        optimizer.zero_grad()
        # Paso hacia delante: le pasamos las imágenes al modelo y obtenemos sus predicciones.
        outputs = net(inputs)
        # Comparamos la predicción con la etiqueta real para calcular el error.
        loss = criterion(outputs, labels)
        # Backpropagation: calcula cuánto ha contribuido cada peso del modelo al error.
        loss.backward()
        # Aplica esos cálculos: ajusta ligeramente los pesos del modelo para reducir el error.
        optimizer.step()
        running_loss += loss.item()   # guardamos el valor del error para poder monitorizarlo

La última capa (`fc2`) no lleva activación propia porque `CrossEntropyLoss` ya aplica softmax internamente — añadirla dos veces sería un error común que distorsiona el entrenamiento.

Al final, el modelo se evalúa contra el set de test (imágenes que nunca vio durante el entrenamiento) y se guarda:


# Construimos la ruta final donde se guardará el modelo entrenado.
model_path = os.path.join(MODEL_DIR, "modelo_local.pth")
# state_dict() contiene únicamente los PESOS aprendidos (no la arquitectura).
# Por eso, para volver a usar este modelo más adelante, necesitarás también
# la clase Net definida igual que aquí, y luego cargar estos pesos dentro de ella.
torch.save(net.state_dict(), model_path)
Softmax es la función de activación de la última capa de una red de clasificación. Convierte las puntuaciones del modelo en probabilidades cuya suma es siempre 100%, permitiendo elegir la clase más probable.

5. Qué hemos montado hasta aquí

  • Un proyecto con estructura estándar (datos / modelo / script).
  • Una CNN mínima de dos capas convolucionales y dos capas densas.
  • Un bucle de entrenamiento completo, con su evaluación y guardado de pesos.

Lo que todavía no hemos explicado es qué está pasando realmente dentro de `conv1` y `conv2` cuando decimos que "detectan patrones" — ni qué hace exactamente el `pool` más allá de "reducir tamaño". Eso es lo que desarrollamos en la segunda entrega de esta serie, donde entramos en el concepto de convolución con ejemplos visuales sobre imágenes reales.


6. Código completo


'''
CÓDIGO DE EJEMPLO PARA ENTRENAR UN MODELO DE VISIÓN:
testENTRENAR_IA/
├── data/
│   └── cifar-10/     # imágenes y etiquetas
├── models/
│   └── modelo_cifar10.pth       # pesos entrenados
└── train_cifar10.py             # script de entrenamiento
'''
# scripts/train_local_images.py
import os
import torch
import torchvision
import torchvision.transforms as transforms
from torchvision.datasets import ImageFolder
from torch.utils.data import DataLoader
import torch.nn as nn
import torch.optim as optim
# === RUTAS LOCALES ===
ROOT_DIR = os.path.dirname(os.path.abspath(__file__))                  # testENTRENAR_IA/
DATA_DIR = os.path.join(ROOT_DIR, "data", "cifar10")                   # ./data/
TRAIN_DIR = os.path.join(DATA_DIR, "train")
TEST_DIR = os.path.join(DATA_DIR, "test")
MODEL_DIR = os.path.join(ROOT_DIR, "models")
os.makedirs(MODEL_DIR, exist_ok=True)
# === DEVICE ===
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print(f"✅ Dispositivo: {device}")
# === TRANSFORMACIONES ===
transform = transforms.Compose([
transforms.Resize((32, 32)),           # igual que CIFAR-10
transforms.ToTensor(),
transforms.Normalize((0.5,), (0.5,))
#transforms.Normalize((0.5, 0.5, 0.5), (0.5, 0.5, 0.5))  #Normalización correcta para RGB
])
# === CARGAR DATOS ===
train_dataset = ImageFolder(root=TRAIN_DIR, transform=transform)
train_loader = DataLoader(train_dataset, batch_size=32, shuffle=True)
test_dataset = ImageFolder(root=TEST_DIR, transform=transform)
test_loader = DataLoader(test_dataset, batch_size=32, shuffle=False)
# === NÚMERO DE CLASES ===
num_classes = len(train_dataset.classes)
print(f"📚 Clases detectadas: {train_dataset.classes}")
# === MODELO SIMPLE ===
class Net(nn.Module):
def __init__(self, num_classes):
super(Net, self).__init__()
self.conv1 = nn.Conv2d(3, 16, 3, padding=1)
self.pool = nn.MaxPool2d(2, 2)
self.conv2 = nn.Conv2d(16, 32, 3, padding=1)
self.fc1 = nn.Linear(32 * 8 * 8, 128)
self.fc2 = nn.Linear(128, num_classes)
def forward(self, x):
x = self.pool(torch.relu(self.conv1(x)))
x = self.pool(torch.relu(self.conv2(x)))
x = x.view(-1, 32 * 8 * 8)
x = torch.relu(self.fc1(x))
x = self.fc2(x)
return x
net = Net(num_classes).to(device)
# === ENTRENAMIENTO ===
criterion = nn.CrossEntropyLoss()
optimizer = optim.Adam(net.parameters(), lr=0.001)
for epoch in range(10):
running_loss = 0.0
for i, (inputs, labels) in enumerate(train_loader):
inputs, labels = inputs.to(device), labels.to(device)
optimizer.zero_grad()
outputs = net(inputs)
loss = criterion(outputs, labels)
loss.backward()
optimizer.step()
running_loss += loss.item()
if i % 10 == 9:
print(f"[{epoch + 1}, {i + 1}] pérdida: {running_loss / 10:.3f}")
running_loss = 0.0
print("✅ Entrenamiento finalizado")
# === EVALUACIÓN ===
correct = 0
total = 0
with torch.no_grad():
for inputs, labels in test_loader:
inputs, labels = inputs.to(device), labels.to(device)
outputs = net(inputs)
_, predicted = torch.max(outputs, 1)
total += labels.size(0)
correct += (predicted == labels).sum().item()
print(f"📊 Precisión en test: {100 * correct / total:.2f}%")
# === GUARDAR MODELO ===
model_path = os.path.join(MODEL_DIR, "modelo_local.pth")
torch.save(net.state_dict(), model_path)
print(f"💾 Modelo guardado en: {model_path}")

Artículo elaborado a partir de pruebas reales sobre infraestructura propia de ReparamiPC/Girtual (Girona).

Sobre el autor: David Otero Verdaguer, Graduado en Multimedia por la UOC y con formación en Administración de Sistemas Informáticos en Red (ASIR). Si te interesa este tipo de contenido, en el blog de ReparamiPC (www.reparamipc.com) escribo con más frecuencia sobre redes, sistemas y los fundamentos técnicos que sostienen proyectos como este.

Comentarios