Salta el contingut

Xarxes Neuronals i Deep Learning

Introducció

El deep learning és la tecnologia que ha impulsionat la majoria dels avanços espectaculars de la IA dels darrers deu anys: des de la traducció automàtica de Google fins a la generació d'imatge de Stable Diffusion, passant pels LLMs com GPT-4 i Claude. Entendre-la a fons, des del perceptró fins als Transformers moderns, és fonamental per a qualsevol professional que vulgui treballar en IA.

El 2025, PyTorch domina la recerca amb una quota superior al 80% dels papers d'arXiv. TensorFlow/Keras manté presència en producció empresarial i edge computing. Aprendre PyTorch és la inversió més sòlida.


La Neurona Artificial: el Perceptró

La neurona artificial, proposada per McCulloch i Pitts el 1943 i formalitzada per Rosenblatt com a perceptró el 1957, és la unitat bàsica de tota xarxa neuronal:

$$z = \sum_{i=1}^{n} w_i x_i + b = \mathbf{w}^T\mathbf{x} + b$$ $$\hat{y} = f(z)$$

On $f$ és la funció d'activació, que introdueix la no linealitat que fa possible aprendre funcions complexes.

Funcions d'Activació

ReLU (Rectified Linear Unit): $f(z) = \max(0, z)$

La funció d'activació més usada per la seva simplicitat i eficiència. Introdueix no linealitat i mitigua el problema del gradient que desapareix (vanishing gradient). Problema: "neurons mortes" (neurons que sempre retornen 0 i deixen de aprendre).

GELU (Gaussian Error Linear Unit): $f(z) = z \cdot \Phi(z)$

Usada en tots els Transformers moderns (BERT, GPT). Més suau que ReLU, millors resultats en models de llenguatge. Estàndard de facto en NLP el 2025.

SiLU / Swish: $f(z) = z \cdot \sigma(z)$

Usada en EfficientNet i models de visió moderns. Rendiment lleugerament superior a GELU en tasques de visió.

Sigmoide: $f(z) = \frac{1}{1+e^{-z}}$

Comprimeix a [0,1]. Usada a la capa de sortida per a classificació binària. Evitar a capes ocultes (vanishing gradient).

Softmax: $f(z_i) = \frac{e^{z_i}}{\sum_j e^{z_j}}$

Capa de sortida per a classificació multiclasse. Produeix probabilitats que sumen 1.


Arquitectures de Xarxes Neuronals

MLP — Multi-Layer Perceptron

El MLP és la xarxa neuronal més bàsica: capes lineals completament connectades alternades amb funcions d'activació. Funciona bé per a dades tabulars però no captura estructura espacial (imatge) ni temporal (sèries temporals).

import torch
import torch.nn as nn
import torch.optim as optim
from torch.utils.data import DataLoader, TensorDataset

class MLP(nn.Module):
    def __init__(self, input_dim: int, hidden_dims: list, output_dim: int,
                 dropout_rate: float = 0.3):
        super().__init__()
        capes = []
        dims = [input_dim] + hidden_dims

        for i in range(len(dims) - 1):
            capes.append(nn.Linear(dims[i], dims[i+1]))
            capes.append(nn.BatchNorm1d(dims[i+1]))  # Normalitza les activacions
            capes.append(nn.GELU())
            capes.append(nn.Dropout(p=dropout_rate))

        capes.append(nn.Linear(dims[-1], output_dim))
        self.xarxa = nn.Sequential(*capes)

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        return self.xarxa(x)

# Exemple d'ús
model = MLP(input_dim=20, hidden_dims=[128, 64, 32], output_dim=2)
print(model)
print(f"Parametres totals: {sum(p.numel() for p in model.parameters()):,}")

CNN — Convolutional Neural Networks

Les CNN aprenen filtres convolucionals que detecten patrons espacials en les dades, especialment imatges. Cada filtre s'aplica localment arreu de la imatge (pes compartit), cosa que redueix enormement el nombre de paràmetres i fa el model invariant a translació.

graph LR
    A["Imatge\n32x32x3"] --> B["Conv3x3\n32 filtres"]
    B --> C["BatchNorm\n+ ReLU"]
    C --> D["MaxPool\n2x2"]
    D --> E["Conv3x3\n64 filtres"]
    E --> F["BatchNorm\n+ ReLU"]
    F --> G["Conv3x3\n64 filtres"]
    G --> H["BatchNorm\n+ ReLU"]
    H --> I["AdaptiveAvgPool"]
    I --> J["Linear\n+ Softmax"]
    J --> K["10 classes"]
class CNN_CIFAR10(nn.Module):
    """CNN per a classificació CIFAR-10 (32x32 RGB, 10 classes)."""

    def __init__(self, num_classes: int = 10):
        super().__init__()

        # Extractor de features
        self.features = nn.Sequential(
            # Bloc 1: 3 -> 32 canals
            nn.Conv2d(3, 32, kernel_size=3, padding=1),
            nn.BatchNorm2d(32),
            nn.ReLU(inplace=True),
            nn.Conv2d(32, 32, kernel_size=3, padding=1),
            nn.BatchNorm2d(32),
            nn.ReLU(inplace=True),
            nn.MaxPool2d(2, 2),   # 32x32 -> 16x16
            nn.Dropout2d(0.25),

            # Bloc 2: 32 -> 64 canals
            nn.Conv2d(32, 64, kernel_size=3, padding=1),
            nn.BatchNorm2d(64),
            nn.ReLU(inplace=True),
            nn.Conv2d(64, 64, kernel_size=3, padding=1),
            nn.BatchNorm2d(64),
            nn.ReLU(inplace=True),
            nn.MaxPool2d(2, 2),   # 16x16 -> 8x8
            nn.Dropout2d(0.25),

            # Bloc 3: 64 -> 128 canals
            nn.Conv2d(64, 128, kernel_size=3, padding=1),
            nn.BatchNorm2d(128),
            nn.ReLU(inplace=True),
            nn.AdaptiveAvgPool2d((4, 4)),  # 8x8 -> 4x4
        )

        # Classificador
        self.classifier = nn.Sequential(
            nn.Flatten(),
            nn.Linear(128 * 4 * 4, 256),
            nn.ReLU(inplace=True),
            nn.Dropout(0.5),
            nn.Linear(256, num_classes)
        )

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        x = self.features(x)
        return self.classifier(x)

model_cnn = CNN_CIFAR10()
print(f"Parametres CNN: {sum(p.numel() for p in model_cnn.parameters()):,}")

# Test de la forma de sortida
x_test = torch.randn(4, 3, 32, 32)  # Batch de 4 imatges 32x32 RGB
output = model_cnn(x_test)
print(f"Forma sortida: {output.shape}")  # [4, 10]

Arquitectures Modernes de CNN

EfficientNet (2019): Escala les dimensions (amplada, profunditat, resolució) de manera composta i equilibrada. EfficientNetV2 és l'estàndard per a classificació d'imatge eficient el 2025.

ConvNeXt (2022): "Modernitza" ResNet incorporant elements dels Transformers (patch embedding, activació GELU, fewer normalization layers) mantenint l'arquitectura convolucional. Competeix amb els ViT en visió.

Per a tasques de producció (Transfer Learning)

import torchvision.models as models
from torchvision import transforms

# Carregar EfficientNetV2-S preentrenat en ImageNet
model_effnet = models.efficientnet_v2_s(weights="IMAGENET1K_V1")

# Congelar tots els paràmetres (feature extractor)
for param in model_effnet.parameters():
    param.requires_grad = False

# Substituir el classificador final
n_features = model_effnet.classifier[-1].in_features
n_classes_propies = 5  # Adaptar al nombre de classes pròpies
model_effnet.classifier[-1] = nn.Linear(n_features, n_classes_propies)

# Ara només s'entrenaran els paràmetres del classificador
params_entrenables = sum(p.numel() for p in model_effnet.parameters() if p.requires_grad)
params_totals = sum(p.numel() for p in model_effnet.parameters())
print(f"Parametres entrenables: {params_entrenables:,} ({params_entrenables/params_totals:.1%})")

RNN, LSTM i GRU

Les xarxes recurrents processen seqüències mantenint un estat ocult que actua com a "memòria" de les posicions anteriors. LSTM i GRU afegeixen mecanismes de porta que controlen quin contingut s'emmagatzema o s'oblida, resolent el problema del gradient que desapareix en seqüències llargues.

class LSTM_Classificador(nn.Module):
    def __init__(self, vocab_size: int, embed_dim: int, hidden_dim: int,
                 n_layers: int, n_classes: int):
        super().__init__()
        self.embedding = nn.Embedding(vocab_size, embed_dim, padding_idx=0)
        self.lstm = nn.LSTM(embed_dim, hidden_dim, n_layers,
                            batch_first=True, dropout=0.3, bidirectional=True)
        self.dropout = nn.Dropout(0.5)
        self.fc = nn.Linear(hidden_dim * 2, n_classes)  # *2 per bidireccional

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        embedded = self.dropout(self.embedding(x))
        output, (hidden, _) = self.lstm(embedded)
        # Usar l'últim hidden state de les dues direccions
        hidden_cat = torch.cat([hidden[-2], hidden[-1]], dim=1)
        return self.fc(self.dropout(hidden_cat))

Per quin motiu els Transformers han superat les RNN

Les RNN processen les seqüències pas a pas (seqüencialment), cosa que impedeix la paral·lelització durant l'entrenament i limita la gestió de dependències de llarg abast. El Transformer, en canvi, processa tots els tokens en paral·lel gràcies al mecanisme d'atenció, captura dependències arbitràriament llargues en un sol pas i s'escala molt millor amb dades i computació. Des de 2018, els Transformers han superat les RNN en pràcticament tots els benchmarks de NLP.

El Transformer: Atenció i Self-Attention

L'arquitectura Transformer, proposada per Vaswani et al. el 2017, es basa en el mecanisme d'atenció: per calcular la representació d'un token, el model "mira" tots els altres tokens de la seqüència i pondera la seva importància.

class MultiHeadAttention(nn.Module):
    """Implementació simplificada de Multi-Head Attention."""

    def __init__(self, d_model: int, n_heads: int):
        super().__init__()
        assert d_model % n_heads == 0
        self.d_model = d_model
        self.n_heads = n_heads
        self.d_k = d_model // n_heads

        self.W_q = nn.Linear(d_model, d_model)
        self.W_k = nn.Linear(d_model, d_model)
        self.W_v = nn.Linear(d_model, d_model)
        self.W_o = nn.Linear(d_model, d_model)

    def attention(self, Q, K, V, mask=None):
        scores = torch.matmul(Q, K.transpose(-2, -1)) / (self.d_k ** 0.5)
        if mask is not None:
            scores = scores.masked_fill(mask == 0, -1e9)
        attn = torch.softmax(scores, dim=-1)
        return torch.matmul(attn, V), attn

    def forward(self, x, mask=None):
        B, T, _ = x.shape

        Q = self.W_q(x).view(B, T, self.n_heads, self.d_k).transpose(1, 2)
        K = self.W_k(x).view(B, T, self.n_heads, self.d_k).transpose(1, 2)
        V = self.W_v(x).view(B, T, self.n_heads, self.d_k).transpose(1, 2)

        x, attn_weights = self.attention(Q, K, V, mask)
        x = x.transpose(1, 2).contiguous().view(B, T, self.d_model)
        return self.W_o(x)

Vision Transformer (ViT)

El ViT aplica el Transformer directament a imatges dividint-les en petits "pegats" (patches) que es tracten com tokens de text. Funciona millor que les CNN quan hi ha moltes dades d'entrenament.

# Usar ViT preentrenat de Hugging Face Transformers
from transformers import ViTForImageClassification, ViTFeatureExtractor
import torch

# Carregar model preentrenat ViT-Base (patch 16, entrenament en ImageNet-21k)
model_vit = ViTForImageClassification.from_pretrained("google/vit-base-patch16-224")
print(f"ViT-Base parametres: {sum(p.numel() for p in model_vit.parameters())/1e6:.1f}M")

Models de Difusió (Stable Diffusion, DALL-E 3)

Els models de difusió aprenen a generar imatges desfent progressivament el soroll gaussian. Superaren les GANs com a estàndard de generació d'imatge el 2022-2023.

graph LR
    subgraph Difusio_Forward ["Proces de difusio endavant (entrenament)"]
        A["Imatge original\nx_0"] --> B["+ soroll\nx_1"]
        B --> C["+ soroll\nx_2"]
        C --> D["... T passos ..."]
        D --> E["Soroll pur\nx_T"]
    end
    subgraph Difusio_Reverse ["Proces invers (generacio)"]
        F["Soroll aleatori\nx_T"] --> G["- soroll\npas T-1"]
        G --> H["- soroll\npas T-2"]
        H --> I["... T passos ..."]
        I --> J["Imatge generada\nx_0"]
    end
    K["Xarxa neuronal\n(U-Net) aprèn\na predir el soroll"] -.-> G
    K -.-> H

Entrenament de Xarxes Neuronals

Backpropagation

L'algorisme de backpropagation calcula el gradient de la funció de pèrdua respecte a cadascun dels pesos de la xarxa aplicant la regla de la cadena del càlcul. PyTorch ho fa automàticament amb autograd.

Optimitzadors

SGD (Stochastic Gradient Descent): L'optimitzador clàssic. Amb momentum i weight decay, competitiu per a entrenament de CNN.

Adam (Adaptive Moment Estimation): Combina momentum i adaptació de la taxa d'aprenentatge per a cada paràmetre. Molt robust, funciona bé per defecte.

AdamW: Adam amb weight decay desacoblat de la taxa d'aprenentatge. L'estàndard de facto per a entrenament de Transformers i la majoria de models moderns.

# Optimitzadors disponibles a PyTorch
optimizer_sgd = optim.SGD(model.parameters(), lr=0.01, momentum=0.9, weight_decay=1e-4)
optimizer_adam = optim.Adam(model.parameters(), lr=1e-3, weight_decay=1e-5)
optimizer_adamw = optim.AdamW(model.parameters(), lr=1e-3, weight_decay=0.01)

# Learning Rate Schedulers
from torch.optim.lr_scheduler import CosineAnnealingLR, ReduceLROnPlateau, OneCycleLR

# Cosine Annealing: decreix el LR seguint una cosinus → entrenament més estable
scheduler_cos = CosineAnnealingLR(optimizer_adamw, T_max=100)

# OneCycleLR: puja ràpidament el LR fins al màxim i baixa → convergència ràpida
scheduler_onecycle = OneCycleLR(optimizer_adamw, max_lr=0.01,
                                epochs=100, steps_per_epoch=len(train_loader))

Exemple Complet: CIFAR-10 amb PyTorch

"""
Classificació CIFAR-10 amb CNN en PyTorch 2.2
Dataset: 60.000 imatges 32x32 RGB, 10 classes
"""
import torch
import torch.nn as nn
import torch.optim as optim
from torch.utils.data import DataLoader
import torchvision
import torchvision.transforms as transforms
import numpy as np
import matplotlib.pyplot as plt
from sklearn.metrics import classification_report

CLASSES = ["avio", "automovil", "ocell", "gat", "cervo",
           "gos", "granota", "cavall", "vaixell", "camio"]
BATCH_SIZE = 128
N_EPOCHS = 30
LR = 3e-4
DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
print(f"Dispositiu: {DEVICE}")

# ============================================================
# 1. TRANSFORMACIONS I DADES
# ============================================================
transform_train = transforms.Compose([
    transforms.RandomCrop(32, padding=4),       # Data augmentation
    transforms.RandomHorizontalFlip(),
    transforms.ColorJitter(brightness=0.2, contrast=0.2),
    transforms.ToTensor(),
    transforms.Normalize(mean=[0.4914, 0.4822, 0.4465],
                         std=[0.2023, 0.1994, 0.2010])
])

transform_test = transforms.Compose([
    transforms.ToTensor(),
    transforms.Normalize(mean=[0.4914, 0.4822, 0.4465],
                         std=[0.2023, 0.1994, 0.2010])
])

trainset = torchvision.datasets.CIFAR10(root="./data", train=True,
                                         download=True, transform=transform_train)
testset  = torchvision.datasets.CIFAR10(root="./data", train=False,
                                         download=True, transform=transform_test)

train_loader = DataLoader(trainset, batch_size=BATCH_SIZE, shuffle=True, num_workers=4, pin_memory=True)
test_loader  = DataLoader(testset,  batch_size=BATCH_SIZE, shuffle=False, num_workers=4, pin_memory=True)

# ============================================================
# 2. MODEL
# ============================================================
model = CNN_CIFAR10(num_classes=10).to(DEVICE)

# Compilar el model per a rendiment òptim (PyTorch 2.0+)
if hasattr(torch, "compile"):
    model = torch.compile(model)

# ============================================================
# 3. FUNCIÓ DE PÈRDUA I OPTIMITZADOR
# ============================================================
criterion = nn.CrossEntropyLoss(label_smoothing=0.1)  # Label smoothing millora generalització
optimizer = optim.AdamW(model.parameters(), lr=LR, weight_decay=0.05)
scheduler = optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=N_EPOCHS)

# ============================================================
# 4. ENTRENAMENT
# ============================================================
def train_epoch(model, loader, optimizer, criterion, device):
    model.train()
    total_loss, correct, total = 0.0, 0, 0
    for X, y in loader:
        X, y = X.to(device), y.to(device)
        optimizer.zero_grad()
        output = model(X)
        loss = criterion(output, y)
        loss.backward()
        nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)  # Gradient clipping
        optimizer.step()
        total_loss += loss.item()
        pred = output.argmax(dim=1)
        correct += (pred == y).sum().item()
        total += y.size(0)
    return total_loss / len(loader), correct / total

def eval_epoch(model, loader, criterion, device):
    model.eval()
    total_loss, correct, total = 0.0, 0, 0
    with torch.no_grad():
        for X, y in loader:
            X, y = X.to(device), y.to(device)
            output = model(X)
            loss = criterion(output, y)
            total_loss += loss.item()
            pred = output.argmax(dim=1)
            correct += (pred == y).sum().item()
            total += y.size(0)
    return total_loss / len(loader), correct / total

historial = {"train_loss": [], "train_acc": [], "test_loss": [], "test_acc": []}
millor_acc = 0.0

for epoch in range(1, N_EPOCHS + 1):
    tr_loss, tr_acc = train_epoch(model, train_loader, optimizer, criterion, DEVICE)
    te_loss, te_acc = eval_epoch(model, test_loader, criterion, DEVICE)
    scheduler.step()

    historial["train_loss"].append(tr_loss)
    historial["train_acc"].append(tr_acc)
    historial["test_loss"].append(te_loss)
    historial["test_acc"].append(te_acc)

    if te_acc > millor_acc:
        millor_acc = te_acc
        torch.save(model.state_dict(), "millor_model_cifar10.pth")

    if epoch % 5 == 0:
        print(f"Epoch {epoch:3d}/{N_EPOCHS} | "
              f"Train Loss: {tr_loss:.4f} Acc: {tr_acc:.3f} | "
              f"Test Loss: {te_loss:.4f} Acc: {te_acc:.3f}")

print(f"\nMillor accuracy en test: {millor_acc:.4f}")

# ============================================================
# 5. CORBES D'APRENENTATGE
# ============================================================
fig, axes = plt.subplots(1, 2, figsize=(14, 5))
epochs_range = range(1, N_EPOCHS + 1)

axes[0].plot(epochs_range, historial["train_loss"], label="Train Loss", color="blue")
axes[0].plot(epochs_range, historial["test_loss"], label="Test Loss", color="red")
axes[0].set_title("Funcio de perdua")
axes[0].set_xlabel("Epoch")
axes[0].legend()
axes[0].grid(True, alpha=0.3)

axes[1].plot(epochs_range, historial["train_acc"], label="Train Acc", color="blue")
axes[1].plot(epochs_range, historial["test_acc"], label="Test Acc", color="red")
axes[1].set_title("Accuracy")
axes[1].set_xlabel("Epoch")
axes[1].legend()
axes[1].grid(True, alpha=0.3)

plt.suptitle("Corbes d'Aprenentatge - CNN CIFAR-10")
plt.tight_layout()
plt.savefig("corbes_aprenentatge.png", dpi=150)

Transfer Learning i Fine-Tuning Avançat

LoRA (Low-Rank Adaptation)

LoRA és la tècnica de fine-tuning més popular el 2025 per a LLMs. En lloc d'actualitzar tots els paràmetres del model base (bilions de paràmetres), afegeix matrius de baix rang entrenables que representen les adaptacions:

$$W' = W_0 + \Delta W = W_0 + \frac{\alpha}{r} \cdot BA$$

On $W_0$ és la matriu de pes congelada, $B \in \mathbb{R}^{d \times r}$ i $A \in \mathbb{R}^{r \times k}$ són matrius entrenables de rang $r \ll \min(d,k)$.

from peft import LoraConfig, get_peft_model, TaskType
from transformers import AutoModelForSequenceClassification

# Carregar model base (exemple: LLaMA o BERT)
model_base = AutoModelForSequenceClassification.from_pretrained(
    "bert-base-uncased", num_labels=2
)

# Configurar LoRA
lora_config = LoraConfig(
    task_type=TaskType.SEQ_CLS,
    r=16,               # Rang de les matrius LoRA
    lora_alpha=32,      # Escala (normalment 2*r)
    lora_dropout=0.1,
    target_modules=["query", "key", "value"],  # Capes on aplicar LoRA
    bias="none"
)

# Aplicar LoRA al model
model_lora = get_peft_model(model_base, lora_config)
model_lora.print_trainable_parameters()
# Típicament: <1% dels paràmetres totals!

Hardware: GPU, TPU, NPU

Hardware Us principal Rendiment relatiu Cost
CPU (multi-core) Inferencia, preprocessing Referencia (1x) Baix
NVIDIA T4 (Cloud) Entrenament mig, inferencia 20-30x CPU Mitjà
NVIDIA A100 80GB Entrenament de models grans 80-100x CPU Alt
NVIDIA H100 Entrenament LLMs, clústers 200-300x CPU Molt alt
Google TPU v4 Entrenament a escala (Google Cloud) Similar H100 Alt
Apple M3 Pro (NPU) Inferencia local MacOS 15-25x CPU Integrat
NVIDIA RTX 4090 Entrenament local, LoRA 50-70x CPU Mig-alt

Requisits pràctics per a tasques comunes

  • Fine-tuning CNN (ImageNet, 50 epochs): RTX 3080 (10 GB VRAM), ~2-4 hores
  • Fine-tuning BERT-base amb LoRA: RTX 3060 (12 GB VRAM), ~30 min
  • Fine-tuning LLaMA-7B amb QLoRA: RTX 4090 (24 GB VRAM), ~4-8 hores
  • Entrenament GPT-2 des de zero: A100 (40 GB VRAM) × 4, ~24 hores
  • Inferencia LLaMA-7B (quantitzat 4-bit): CPU moderna o M2 MacBook, ~1-5 tokens/s

Regularització

Tècnica Aplicació Efecte
Dropout Capes lineals, recurrents Entrena subxarxes aleatòries → generalització
Dropout2D Capes convolucionals Descarta canals sencers
Batch Normalization Capes internes Normalitza activacions → entrenament estable
Layer Normalization Transformers Normalitza sobre features, no batch
Weight Decay (L2) Tots els paràmetres Penalitza pesos grans
Data Augmentation Entrades d'imatge Incrementa diversitat del training set
Label Smoothing Funció de pèrdua Evita confiança excessiva
Early Stopping Procés d'entrenament Atura quan el val loss no millora

Miniactivitat — Transfer Learning

Descarrega el dataset Flowers102 de PyTorch (torchvision.datasets.Flowers102). Aplica transfer learning amb EfficientNetV2-S preentrenat en ImageNet. Compara els resultats d'entrenar: (a) només el classificador final congelant el feature extractor, (b) fine-tuning complet de totes les capes amb un learning rate molt petit. Quin assoleix millor accuracy? Quant temps tarda cadascun?


Exercici Pràctic — AC5072/05

Objectiu: Fine-tuning d'una CNN preentrenada per a un dataset personalitzat.

Tasca:

  1. Escull un dataset d'imatges amb almenys 5 classes (exemples: Stanford Dogs, Oxford Pets, Food-101, Intel Image Classification).
  2. Descarrega i organitza el dataset en carpetes train/, val/, test/ per classe.
  3. Aplica augmentació de dades apropiada al conjunt d'entrenament.
  4. Carrega EfficientNetV2-S preentrenat en ImageNet des de torchvision.models.
  5. Congela el feature extractor i entrena únicament el classificador durant 10 epochs.
  6. Descongela totes les capes i aplica fine-tuning complet amb un LR 10× menor durant 20 epochs més.
  7. Guarda el millor model (per val accuracy) i avalua en el test set.
  8. Mostra 20 imatges del test set amb les prediccions i la ground truth. Identifica els errors més freqüents.
  9. Reporta accuracy i F1-score per classe.

Entrega: Notebook Jupyter executat + informe de 400 paraules.

Codi de l'activitat: AC5072/05