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:
- Escull un dataset d'imatges amb almenys 5 classes (exemples: Stanford Dogs, Oxford Pets, Food-101, Intel Image Classification).
- Descarrega i organitza el dataset en carpetes
train/,val/,test/per classe. - Aplica augmentació de dades apropiada al conjunt d'entrenament.
- Carrega EfficientNetV2-S preentrenat en ImageNet des de
torchvision.models. - Congela el feature extractor i entrena únicament el classificador durant 10 epochs.
- Descongela totes les capes i aplica fine-tuning complet amb un LR 10× menor durant 20 epochs més.
- Guarda el millor model (per val accuracy) i avalua en el test set.
- Mostra 20 imatges del test set amb les prediccions i la ground truth. Identifica els errors més freqüents.
- Reporta accuracy i F1-score per classe.
Entrega: Notebook Jupyter executat + informe de 400 paraules.
Codi de l'activitat: AC5072/05