---
title: "Entrenant un model de visió (I): primers passos amb una CNN (Convolutional neural network)"
date: "2026-07-01"
category: "IA Local"
level: "advanced"
tags: ["IA-LOCAL", "VISIO-ARTIFICIAL", "CNN", "PYTORCH", "ENTRENAMENT"]
main_image: "images/entrenando-modelo-vision-cnn-parte1/fp-img.jpg"
slug: "entrenant-model-visio-cnn-part1"
lang: ca
status: process
group_id: "entrenando-modelo-vision-cnn-parte1"
series_id: "cnn-visio"
excerpt: "Aquesta és la primera entrega d'una sèrie pràctica sobre com entrenar un model de visió artificial des de zero. No partim de teoria abstracta..."
on_carousel: true
pinned: false
---

# Entrenant un model de visió (I): primers passos amb una CNN (Convolutional neural network)

Aquesta és la primera entrega d'una sèrie pràctica sobre com entrenar un model de visió artificial des de zero. No partim de teoria abstracta: partim d'un script real, funcionant, i el anem desmuntant peça a peça per entendre què fa cada línia i per què.

En aquest primer article muntem l'esquelet: l'estructura de carpetes, el script d'entrenament i l'arquitectura de la xarxa neuronal convolucional (CNN) que farem servir com a base.

---

## 1. Punt de partida: estructura del projecte

Abans de tocar codi, així és com organitzem el directori del projecte:

```
testENTRENAR_IA/
├── data/
│   └── cifar-10/     # imatges i etiquetes
├── models/
│   └── modelo_cifar10.pth     # pesos entrenats
└── train_cifar10.py            # script d'entrenament
```

Res d'exòtic: una carpeta per a les dades d'entrada, una altra on es guarden els pesos ja entrenats, i el script principal. Aquesta estructura es repeteix en gairebé qualsevol projecte d'entrenament, així que convé acostumar-s'hi des del primer dia.

[Descàrrega: Podeu descarregar el dataset d'entrament de cave.cs.toronto.edu](https://cave.cs.toronto.edu/kriz/cifar.html)
---

## 2. El script d'entrenament, secció per secció

### Rutes i dispositiu

```python
# os.path.abspath(__file__) dona la ruta completa d'aquest script.
# os.path.dirname() es queda només amb la carpeta que el conté.
# Així ROOT_DIR sempre apunta a "testENTRENAR_IA/", és igual des d'on executis el script.
ROOT_DIR = os.path.dirname(os.path.abspath(__file__))

# Construïm la resta de rutes a partir de ROOT_DIR, en comptes d'escriure-les
# a mà, perquè el projecte funcioni igual al teu PC, al servidor, etc.
DATA_DIR = os.path.join(ROOT_DIR, "data", "cifar10")   # carpeta amb les imatges
TRAIN_DIR = os.path.join(DATA_DIR, "train")             # subcarpeta d'entrenament
TEST_DIR = os.path.join(DATA_DIR, "test")               # subcarpeta d'avaluació
MODEL_DIR = os.path.join(ROOT_DIR, "models")            # aquí desarem el model ja entrenat

# Crea la carpeta "models/" si encara no existeix.
# exist_ok=True evita que el script falli si la carpeta ja està creada.
os.makedirs(MODEL_DIR, exist_ok=True)

# Triem on s'entrenarà: GPU si n'hi ha una de disponible (molt més ràpid),
# i si no, caiem a CPU automàticament sense que el script es trenqui.
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
```

Tot això és preparació: on són les dades, on es desarà el model entrenat, i en quin dispositiu s'entrenarà. Si hi ha GPU disponible (`cuda`), es fa servir; si no, cau a CPU automàticament.

### Transformacions i càrrega de dades (Revisió del dataset)

```python
# transforms.Compose encadena diverses transformacions que s'apliquen
# a CADA imatge, en ordre, abans d'entrar a la xarxa.
transform = transforms.Compose([
    transforms.Resize((32, 32)),   # totes les imatges han de mesurar el mateix; 32x32 = mida de CIFAR-10
    transforms.ToTensor(),          # converteix la imatge (píxeles 0-255) a un tensor de PyTorch (0.0-1.0)
    transforms.Normalize((0.5,), (0.5,))   # reescala els valors a un rang centrat en 0, ajuda a fer que l'entrenament convergeixi millor
])

# ImageFolder assumeix que cada subcarpeta dins de TRAIN_DIR és una classe diferent.
# Exemple: train/gallina/, train/anec/, train/oca/ -> 3 classes detectades automàticament.
train_dataset = ImageFolder(root=TRAIN_DIR, transform=transform)

# DataLoader s'encarrega de servir les imatges en "lots" (batches) en lloc d'una a una.
# batch_size=32 -> processem 32 imatges de cop abans d'actualitzar el model.
# shuffle=True -> barreja l'ordre a cada època, perquè el model no memoritzi l'ordre de les dades.
train_loader = DataLoader(train_dataset, batch_size=32, shuffle=True)
```

Les imatges no entren "tal qual" a la xarxa: es redimensionan a una mida fixa (32×32, la mateixa que fa servir el dataset CIFAR-10) i es normalitzen. `ImageFolder` és una utilitat de PyTorch que assumeix que cada subcarpeta dins de `train/` és una classe diferent — així que si tens `train/gallina/`, `train/anec/` i `train/oca/`, PyTorch detecta automàticament aquestes tres classes sense que les hagis d'etiquetar a mà.

---

## 3. L'arquitectura: la classe `Net`

Aquí hi ha el cor del model, representat per l'arquitectura de la xarxa:

![Evolució de las capes convolucionals d'una CNN des de vores simples fins al reconeixement d'objectes](images/entrenando-modelo-vision-cnn-parte1/cnn-extraccion-caracteristicas-capas-convolucionales.jpg) ::

```python
# Tota xarxa a PyTorch hereda de nn.Module. Això li dona "de franc" coses com
# moure el model a la GPU, desar/carregar pesos, etc.
class Net(nn.Module):

    # __init__ és on DECLAREM les capes. Aquí encara no passa cap dada,
    # només estem deixant les "peces" preparades sobre la taula.
    def __init__(self, num_classes):
        super(Net, self).__init__()   # obligatori: inicialitza la part interna de nn.Module

        # Primera capa convolucional:
        # 3  -> canals d'entrada (imatge a color: Vermell, Verd, Blau)
        # 16 -> nombre de filtres que aprendrem (16 "detectors" diferents)
        # 3  -> mida del filtre, 3x3 píxels
        # padding=1 -> afegeix una vora d'1 píxel perquè la imatge no s'encongeixi en convolucionar
        self.conv1 = nn.Conv2d(3, 16, 3, padding=1)

        # Pooling: redueix la mida de la imatge a la meitat.
        # (2, 2) -> finestra de 2x2 píxels, es queda amb el valor més alt de cada finestra
        self.pool = nn.MaxPool2d(2, 2)

        # Segona capa convolucional:
        # 16 -> entra amb els 16 filtres que ha generat conv1
        # 32 -> treu 32 filtres nous, buscant patrons més complexos que la primera capa
        self.conv2 = nn.Conv2d(16, 32, 3, padding=1)

        # Capa densa (fully connected): connecta TOTES les neurones d'entrada amb TOTES les de sortida.
        # 32 * 8 * 8 -> mida del tensor aplanat que surt de les convolucions (vegeu l'explicació a sota)
        # 128 -> mida del vector "resum" que generem per a cada imatge
        self.fc1 = nn.Linear(32 * 8 * 8, 128)

        # Última capa: una neurona de sortida per a cada classe possible (gallina, ànec, oca...).
        self.fc2 = nn.Linear(128, num_classes)

    # forward defineix el CAMÍ real que recorre la dada, en ordre, des de que
    # entra la imatge fins que surt la predicció.
    def forward(self, x):
        # Pas 1: convolució -> activació ReLU -> pooling
        # ReLU simplement apaga (posa a 0) els valors negatius; ajuda a fer que la xarxa aprengui millor.
        x = self.pool(torch.relu(self.conv1(x)))

        # Pas 2: repetim el mateix patró amb la segona convolució
        x = self.pool(torch.relu(self.conv2(x)))

        # Pas 3: "aplanem" el tensor. Passa de tenir forma [canals, alt, ample]
        # a ser un únic vector llarg, que és el que espera una capa densa (Linear).
        # El -1 li diu a PyTorch "calcula tu la mida d'aquest eix automàticament".
        x = x.view(-1, 32 * 8 * 8)

        # Pas 4: passem per la primera capa densa + ReLU
        x = torch.relu(self.fc1(x))

        # Pas 5: capa de sortida. SENSE activació aquí a propòsit
        # (CrossEntropyLoss, més avall, ja aplica softmax internament).
        x = self.fc2(x)

        return x

# Creem una instància a la xarxa i l'enviem al dispositiu triat abans (GPU o CPU).
net = Net(num_classes).to(device)
```

Tota xarxa a PyTorch es defineix en dos llocs diferents, i és important no confondre'ls:

- **`__init__`** — aquí només **declares** les capes que faràs servir, com qui prepara les eines sobre la taula abans de començar a treballar. Encara no hi ha flux de dades.
- **`forward`** — aquí defineixes **l'ordre real** en què aquestes capes processen la imatge. És el camí que recorre la dada des que entra fins que surt convertida en una predicció.

### Què fa cada capa

| Capa | Línia de codi | Funció |
|---|---|---|
| Conv1 | `nn.Conv2d(3, 16, 3, padding=1)` | Primera convolució: entra amb 3 canals (RGB) i genera 16 filtres de 3×3. El `padding=1` evita perdre mida a les vores. |
| Pool | `nn.MaxPool2d(2, 2)` | Redueix a la meitat la mida del mapa d'activació (de 32×32 a 16×16 després de la primera passada). |
| Conv2 | `nn.Conv2d(16, 32, 3, padding=1)` | Segona convolució: passa de 16 a 32 filtres, buscant patrons més complexos que la primera capa. |
| FC1 | `nn.Linear(32 * 8 * 8, 128)` | Capa densa: aplana el resultat de les convolucions (32 canals de 8×8 després de dos poolings) i el converteix en un vector de 128 dimensions. |
| FC2 | `nn.Linear(128, num_classes)` | Capa de sortida: una neurona per cada classe possible del dataset. |

Un parell d'analogies perquè aquestes peces quedin clares de veritat:

- **`padding=1` és com posar un marc d'un píxel al voltant de la foto.** Si no el poséssim, el filtre de 3×3 no podria "centrar-se" sobre els píxels de la vora exterior (li faltaria veí per un costat), i la imatge resultant sortiria una mica més petita cada vegada que passa per una convolució. El marc afegit evita aquesta retallada.
- **`MaxPool2d(2, 2)` és com reduir una foto a la meitat quedant-te només amb el píxel més "intens" de cada bloc de 2×2.** Imagina que divideixes l'imatge en quadradets de 4 píxels i, de cada quadradet, només conserves el que té el valor más alt. Perds detall fi, pero conserves el que més "destacava" en aquella zona — per això la mida es redueix a la meitat a cada passada.
- **`x.view(-1, 32 * 8 * 8)` és com desfilar una manta plegada en un sol fil llarg.** Abans d'aquesta línia, la dada té forma de "caixa" (32 capes de 8×8 píxeles cadascuna). Una capa densa (`Linear`) no entén de caixes, només de llistes de números en fila. `view()` no canvia cap valor, només ho "desfila" tot en una única llista llarga de 2048 números perquè la capa densa ho pugui processar.

**Per què `32 * 8 * 8`:** la imatge entra a 32×32. Cada `MaxPool2d(2,2)` redueix la mida a la meitat, y apliquem pooling dues vegades (una després de cada convolució), així que 32 → 16 → 8. Amb 32 filtres a la darrera convolució, el tensor aplanat que arriba a la capa densa té 32 × 8 × 8 = 2048 valors. Aquest càlcul s'ha de tornar a fer cada vegada que canvies la mida d'entrada o el nombre de capes convolucionals — és l'error més habitual en modificar una arquitectura ja existent.

> [!INFO] El pooling redueix la mida dels mapes d'activació conservant la informació més rellevant. Això fa que la xarxa sigui més ràpida, consumeixi menys memòria i sigui més robusta davant de petits desplaçaments de la imatge.
---

## 4. El bucle d'entrenament i avaluació

```python
# La "funció de pèrdua" mesura quant s'equivoca el model en cada predicció.
# CrossEntropyLoss és l'estàndard per a classificació multiclasse.
criterion = nn.CrossEntropyLoss()

# L'optimizador és qui ajusta els pesos del model per reduir aquesta pèrdua.
# Adam és l'elecció per defecte més habitual; lr=0.001 és la "mida del pas" de cada ajustament.
optimizer = optim.Adam(net.parameters(), lr=0.001)

# Una "època" = una passada completa per TOT el dataset d'entrenament.
# Entrenem durant 10 èpoques.
for epoch in range(10):
    running_loss = 0.0   # acumulador per veure com evoluciona l'error dins de l'època

    # train_loader ens va donant lots (batches) de 32 imatges amb les seves etiquetes.
    for i, (inputs, labels) in enumerate(train_loader):

        # Movem el lot actual al dispositiu on és el model (GPU o CPU).
        inputs, labels = inputs.to(device), labels.to(device)

        # PyTorch acumula gradients per defecte; cal resetejar-los a cada lot
        # o se sumarien amb els del lot anterior i l'entrenament es trencaria.
        optimizer.zero_grad()

        # Pas cap endavant: li passem les imatges al model i obtenim les seves prediccions.
        outputs = net(inputs)

        # Comparem la predicció amb l'etiqueta real per calcular l'error.
        loss = criterion(outputs, labels)

        # Backpropagation: calcula quant ha contribuït cada pes del model a l'error.
        loss.backward()

        # Aplica aquests càlculs: ajusta lleugerament els pesos del model per reduir l'error.
        optimizer.step()

        running_loss += loss.item()   # desem el valor de l'error per poder monitoritzar-lo
```

La darrera capa (`fc2`) no porta activació pròpia perquè `CrossEntropyLoss` ya aplica softmax internament — afegir-la dues vegades seria un error comú que distorsiona l'entrenament.

Al final, el model s'avalua contra el set de test (70% entrenament vs 30% test - imatges que mai va veure durant l'entrenament) i es desa:

```python
# Construïm la ruta final on es desarà el model entrenat.
model_path = os.path.join(MODEL_DIR, "modelo_local.pth")

# state_dict() conté únicament els PESOS apresos (no l'arquitectura).
# Per això, per tornar a fer servir aquest model més endavant, necessitaràs també
# la classe Net definida igual que aquí, i després carregar aquests pesos dins seu.
torch.save(net.state_dict(), model_path)
```

> [!INFO] Softmax és la funció d'activació de l'última capa d'una xarxa de classificació. Converteix les puntuacions del model en probabilitats que sumen el 100%, permetent escollir la classe més probable.
---

## 5. Què hem muntat fins aquí

- Un projecte amb estructura estàndard (dades/model/script).
- Una CNN mínima de dues capes convolucionals i dues capes denses.
- Un bucle d'entrenament complet, amb la seva avaluació i desat de pesos.

El que encara no hem explicat és **qué està passant realment** dins de `conv1` y `conv2` quan diem que "detecten patrons" — ni què fa exactament el `pool` més enllà de "reduïr mida". Això és el que desenvolupem en la segona entrega d'aquesta sèrie, on entrem en el concepte de convolució amb exemples visuals sobre imatges reals.

---

## 6. Codi complet

```python

'''
CODI D'EXEMPLE PER A ENTRENAR UN MODEL DE VISIÓ:

testENTRENAR_IA/
├── data/
│   └── cifar-10/     # imatges i etiquetes
├── models/
│   └── modelo_cifar10.pth       # pesos entrenats
└── train_cifar10.py             # script d'entrenament
'''


# 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


# === RUTES LOCALS ===
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}")


# === TRANSFORMACIONS ===
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))  #Normalització correcta per RGB
])


# === CARREGAR DADES ===
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)


# === NOMBRE DE CLASSES ===
num_classes = len(train_dataset.classes)
print(f"📚 Clases detectadas: {train_dataset.classes}")


# === MODEL 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)


# === ENTRENAMENT ===
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")


# === AVALUACIÓ ===
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}%")

# === DESAR MODEL ===
model_path = os.path.join(MODEL_DIR, "modelo_local.pth")
torch.save(net.state_dict(), model_path)
print(f"💾 Modelo guardado en: {model_path}")
```

::: article-note
Article elaborat a partir de proves reals sobre infraestructura pròpia de ReparamiPC/Girtual (Girona).
:::

::: author-box
**Sobre l'autor:** David Otero Verdaguer, Graduat en Multimèdia per la UOC i amb formació en Administració de Sistemes Informàtics en Xarxa (ASIX). Si t'interessa aquest tipus de contingut, al blog de ReparamiPC ([www.reparamipc.com](https://www.reparamipc.com)) escric amb més freqüència sobre xarxes, sistemes i els fonaments tècnics que sostenen projectes com aquest.
:::
