Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Xarxes neuronals: ampliació

Material d’ampliació de Xarxes neuronals: no cal per seguir el recorregut principal d’aquest bloc.

Variants del descens del gradient

Hi ha tres maneres principals d’entrenar una xarxa neuronal, que difereixen en quantes mostres s’utilitzen per calcular el gradient abans d’actualitzar els pesos.

El codi continua l’exemple de Xarxes neuronals: la classe ThreeLayerNN i les dades dels dígits 0 i 1 (X_train, y_train, X_test, y_test). Cada variant crea un model nou.

Batch Gradient Descent (GD complet)

Utilitza tot el dataset per calcular el gradient i actualitzar els pesos un cop per època.

# X_train: forma (mostres, 64), y_train: forma (mostres, 1)
torch.manual_seed(42)
model = ThreeLayerNN()
loss_fn = nn.BCEWithLogitsLoss()
optimizer = torch.optim.SGD(model.parameters(), lr=0.1)

for epoch in range(1000):
    model.train()
    logits = model(X_train) # Prediccions: (num_samples, 1)
    loss = loss_fn(logits, y_train)
    optimizer.zero_grad()
    loss.backward() # utilitza els gradients calculats
    optimizer.step() # 1 actualització per època

    if epoch % 100 == 0:
        model.eval()
        with torch.no_grad(): # desactiva càlcul de gradients
            logits_test = model(X_test)
            test_loss = loss_fn(logits_test, y_test)
        print(f"Epoch {epoch}: Train Loss = {loss.item():.4f}, Test Loss = {test_loss.item():.4f}")

Avantatges: Gradient precís, convergència estable Inconvenients: Lent, requereix molta memòria, pot quedar atrapat en mínims locals

Mini-batch Gradient Descent (recomanat)

Divideix el dataset en grups petits (mini-batches) i actualitza els pesos després de cada grup. És l’enfocament més utilitzat en la pràctica.

En l’entrenament amb mini-batches:

  1. Una època (epoch) processa tot el conjunt d’entrenament
  2. Cada època es divideix en mini-batches (grups típicament de 16-512 mostres)
  3. Els pesos s’actualitzen un cop per mini-batch, després de calcular el gradient promig de les mostres del grup

Per exemple, amb 1000 mostres i batch_size=100:

  • Cada època té 10 mini-batches
  • Hi ha 10 actualitzacions de pesos per època
  • Amb 100 èpoques → 1000 actualitzacions totals
from torch.utils.data import DataLoader, TensorDataset

torch.manual_seed(42)
model = ThreeLayerNN()
loss_fn = nn.BCEWithLogitsLoss()
optimizer = torch.optim.SGD(model.parameters(), lr=0.1)

dataset = TensorDataset(X_train, y_train)
loader = DataLoader(dataset, batch_size=32, shuffle=True)

for epoch in range(1000):
    model.train()
    for X_batch, y_batch in loader:
        logits = model(X_batch)
        loss = loss_fn(logits, y_batch)
        optimizer.zero_grad()
        loss.backward()
        optimizer.step()  # múltiples actualitzacions per època

En aquest exemple, si el dataset tingués 10 mostres i batch_size=2:

  • Cada època processa 10 mostres en 5 mini-batches de 2 mostres
  • Hi ha 5 actualitzacions de pesos per època
  • Amb 1000 èpoques → 5000 actualitzacions totals

Això coincideix amb l’estructura del codi: el bucle extern (for epoch) es repeteix 1000 vegades, i el bucle intern (for X_batch, y_batch) fa 5 iteracions per època.

Avantatges: Equilibra velocitat (paral·lelització GPU), estabilitat (gradient més suau que mostra individual) i eficiència de memòria (millor que processar tot el dataset alhora) Inconvenients: Requereix ajustar un hiperparàmetre addicional (batch_size)

Stochastic Gradient Descent (SGD pur)

Actualitza els pesos després de cada mostra individual (batch_size=1).

from torch.utils.data import DataLoader, TensorDataset

torch.manual_seed(42)
model = ThreeLayerNN()
loss_fn = nn.BCEWithLogitsLoss()
optimizer = torch.optim.SGD(model.parameters(), lr=0.1)

dataset = TensorDataset(X_train, y_train)
loader = DataLoader(dataset, batch_size=1, shuffle=True)

for epoch in range(20):  # cada època ja fa una actualització per mostra
    model.train()
    for X_sample, y_sample in loader:
        logits = model(X_sample)
        loss = loss_fn(logits, y_sample)
        optimizer.zero_grad()
        loss.backward()
        optimizer.step()  # 1 actualització per mostra

Amb 10 mostres i batch_size=1:

  • Cada època fa 10 actualitzacions de pesos
  • Amb 1000 èpoques → 10000 actualitzacions totals

Avantatges: Actualitzacions freqüents, pot escapar de mínims locals, baixa memòria Inconvenients: Gradient molt sorollós, convergència inestable, difícil aprofitar paral·lelització GPU

Nota: Tot i que l’optimitzador de PyTorch es diu torch.optim.SGD, en realitat és el nom genèric per a gradient descent i es pot usar amb qualsevol dels tres mètodes segons el batch_size que triem.

La comparació de les tres variants és a Xarxes neuronals.

Classificació multietiqueta (multilabel)

En aquest tipus de problemes, una sola entrada pot estar associada a diverses etiquetes binàries. Per tant, la variable objectiu \(\mathbf{y}\) és un vector de \(N\) possibles etiquetes, cadascun dels quals pot ser 0 o 1.

Una estratègia possible per abordar aquest problema seria construir \(N\) xarxes neuronals independents, una per a cada etiqueta. No obstant això, hi ha una alternativa més eficient: entrenar una sola xarxa amb \(N\) sortides.

En aquest cas:

  • L’última capa tindrà \(N\) unitats de sortida.
  • Cada unitat aplica una funció d’activació sigmoide.
  • Això permet que cada sortida \(\hat{y}_j\) representi la probabilitat que l’etiqueta \(j\) estigui present (valgui 1), de manera independent de les altres.

Aquest enfocament aprofita el fet que, tot i que les etiquetes són múltiples, no són mútuament excloents, i per tant no cal una normalització com la que fa softmax.

import torch.nn.functional as F
from sklearn.datasets import make_multilabel_classification
from sklearn.model_selection import train_test_split

# Dades sintètiques: 1.000 exemples, 100 característiques, 5 etiquetes que poden coincidir
# (per exemple: acció, ciència-ficció, drama, comèdia, terror)
X, Y = make_multilabel_classification(n_samples=1000, n_features=100, n_classes=5, random_state=42)
X_train, X_test, Y_train, Y_test = train_test_split(X, Y, test_size=0.2, random_state=42)
X_train, X_test = torch.tensor(X_train, dtype=torch.float32), torch.tensor(X_test, dtype=torch.float32)
Y_train, Y_test = torch.tensor(Y_train, dtype=torch.float32), torch.tensor(Y_test, dtype=torch.float32)

class MultiLabelNN(nn.Module):
    def __init__(self):
        super().__init__()
        self.fc1 = nn.Linear(100, 64)
        self.fc2 = nn.Linear(64, 32)
        self.output = nn.Linear(32, 5)  # 5 sortides, 1 per etiqueta

    def forward(self, x):
        x = F.relu(self.fc1(x))
        x = F.relu(self.fc2(x))
        return self.output(x)  # logits (sense sigmoide)

torch.manual_seed(42)
model = MultiLabelNN()
loss_fn = nn.BCEWithLogitsLoss()  # una sigmoide i una BCE per a cada etiqueta
optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)

for epoch in range(100):
    model.train()
    optimizer.zero_grad()
    loss = loss_fn(model(X_train), Y_train)
    loss.backward()
    optimizer.step()

model.eval()
with torch.no_grad():
    Y_pred = (torch.sigmoid(model(X_test)) > 0.5).float()  # cada etiqueta, per separat
print(f"Predicció: {Y_pred[0].tolist()}")
print(f"Real:      {Y_test[0].tolist()}")
print(f"Etiquetes encertades: {(Y_pred == Y_test).float().mean():.2%}")

Grafs computacionals i diferenciació automàtica

Les llibreries modernes de deep learning (com PyTorch o TensorFlow) utilitzen un mecanisme anomenat diferenciació automàtica (automatic differentiation o autograd). Aquest mecanisme es basa en la construcció d’un graf computacional.

Què és un graf computacional?

Un graf computacional és una representació interna que la llibreria crea automàticament mentre es fan càlculs. Està format per:

  • Nodes: representen les operacions matemàtiques (multiplicacions, sumes, funcions d’activació)
  • Arestes: connecten les operacions seguint l’ordre en què s’executen

Aquest graf registra totes les operacions que es fan sobre els tensors durant la passada cap endavant, com ara:

  • Transformacions lineals (multiplicacions de matrius i sumes)
  • Funcions d’activació (ReLU, sigmoid, tanh, etc.)
  • Funcions de pèrdua (cross-entropy, MSE, etc.)

El més important és que també emmagatzema com calcular les derivades de cada operació.

Per què serveix el graf computacional?

El graf s’utilitza en dos moments diferents de l’entrenament:

  1. Passada endavant (forward pass): mentre calculem la predicció, el graf es va construint automàticament, registrant cada operació.

  2. Passada enrere (backward pass): quan volem aprendre dels errors, el graf es recorre en sentit invers per calcular les derivades de forma automàtica, sense que nosaltres les hàgim de programar manualment.

Aquest procés automàtic de calcular derivades s’anomena diferenciació automàtica, i és el que fa possible entrenar xarxes neuronals amb milions de paràmetres sense haver de calcular derivades a mà.

Com funciona la diferenciació automàtica?

Durant l’entrenament, els tensors (les estructures de dades que contenen els pesos i les dades) tenen la capacitat de “recordar” totes les operacions que s’han fet sobre ells. Quan arribem al final i tenim la pèrdua calculada, podem demanar a la llibreria que calculi automàticament les derivades de la pèrdua respecte a cada pes de la xarxa.

Això ho fa aplicant la regla de la cadena del càlcul diferencial de forma automàtica: va descomponent les derivades complexes en derivades simples d’operacions bàsiques (suma, multiplicació, funcions d’activació), i les combina per obtenir el gradient de cada paràmetre.

En resum: els tensors diferencien totes les funcions que s’han executat durant la passada cap endavant (forward pass), però ho fan durant la passada cap enrere (backward pass), recorrent el graf computacional en sentit invers.

El resultat: sabem exactament quant hem d’ajustar cada pes per reduir l’error, sense haver de derivar ni una sola equació manualment.

La clau de l’entrenament modern de xarxes neuronals és aquesta diferenciació automàtica: el càlcul automàtic de derivades mitjançant el graf computacional. Això permet entrenar models amb milions de paràmetres sense haver d’escriure ni una sola derivada a mà.

Capes convolucionals

Fins ara hem treballat amb capes totalment connectades (dense layers), on cada neurona està connectada a totes les sortides de la capa anterior. Aquest enfocament funciona bé per a moltes tasques, però quan treballem amb dades estructurades com imatges o senyals temporals, hi ha un tipus de capa més eficient: les capes convolucionals.

Una capa convolucional és una capa especial on cada neurona només “veu” una regió local de l’entrada procedent de la capa anterior. Això significa que, en lloc d’estar connectada a totes les sortides anteriors, cada neurona aplica un petit filtre (o kernel) a una finestra reduïda de valors.

Avantatges:

  1. Càlcul més ràpid
    Com que cada neurona processa només una part de l’entrada, es redueix el nombre de connexions i, per tant, la quantitat de càlcul necessari.

  2. Menys dades d’entrenament i menor sobreajustament
    Els filtres locals comparteixen pesos, de manera que el model té menys paràmetres i resulta menys procliu a memoritzar dades específiques.

Quan encadenem diverses capes convolucionals en una xarxa, obtenim una xarxa neuronal convolucional (CNN), que és especialment eficaç per al processament d’imatges i sèries temporals.

Exemple pràctic (1D): suposem que tenim un senyal d’ECG d’1 dimensió amb 100 punts d’entrada: \(x_1, x_2, \dots, x_{100}\).

  1. Primera capa oculta
    Utilitza un filtre de grandària 20 i pas (stride) de 10 per generar 9 activacions. Cada activació processa finestres des de \(x_1\dots x_{20}\), \(x_{11}\dots x_{30}\), \(x_{21}\dots x_{40}\), etc.

  2. Segona capa oculta
    Aplica un filtre de grandària 5 i pas de 2 sobre les 9 activacions anteriors per obtenir 3 noves activacions, que corresponen a les finestres \(a_1\dots a_5\), \(a_3\dots a_7\) i \(a_5\dots a_9\).

  3. Capa de sortida
    Acaba amb una funció sigmoide que retorna la probabilitat de presència o absència de malaltia cardíaca.

Aquest procés permet que la xarxa detecti patrons locals (per exemple, formes d’ones característiques de l’ECG) i els combini progressivament per fer una classificació robusta.

Càlcul eficient per a xarxes neuronals

Quan treballem amb xarxes neuronals, el càlcul de la inferència pot ser molt costós si es fa mostra per mostra i neurona per neurona. Per això utilitzem biblioteques optimitzades que aprofiten el maquinari modern.

A continuació t’expliquem les principals tecnologies implicades.

BLAS (Basic Linear Algebra Subprograms)

És una especificació estàndard de rutines bàsiques d’àlgebra lineal (com multiplicacions de matrius i vectors).

  • Funciona en CPU
  • Les seves implementacions (com OpenBLAS, MKL, ATLAS…) són molt optimitzades
  • És la base de moltes biblioteques científiques (NumPy, SciPy, etc.)

Exemple: multiplicar dues matrius grans amb numpy.dot() utilitza BLAS per fer-ho ràpidament.

GPU (Graphics Processing Unit)

Una GPU és un processador paral·lel que pot executar milers de càlculs al mateix temps. Tot i que es va dissenyar per a gràfics, avui s’utilitza àmpliament per a càlcul científic i d’IA.

  • Té milers de nuclis simples (en comparació amb pocs nuclis potents d’una CPU)
  • Ideal per a càlculs repetitius i paral·lels com els de l’àlgebra lineal

CUDA (Compute Unified Device Architecture)

És la plataforma de programació creada per NVIDIA per desenvolupar aplicacions que s’executin a les seves GPU.

  • Permet escriure codi que s’executa directament dins la GPU
  • Exposa una API per controlar memòria, fils d’execució, etc.
  • Moltes biblioteques populars (com PyTorch o TensorFlow) fan servir CUDA per sota si hi ha GPU disponible

cuBLAS (CUDA BLAS)

És la versió de BLAS implementada per NVIDIA per executar-se sobre GPU amb CUDA.

  • Té les mateixes operacions que BLAS, però molt més ràpides a la GPU
  • L’ús de torch.matmul o tf.matmul en dispositius CUDA utilitza cuBLAS per sota
  • Aprofita tot el paral·lelisme de la GPU per multiplicar matrius de forma massiva

Resum:

TecnologiaFunciona a…Fa què?Exemples d’ús
BLASCPUOperacions bàsiques d’àlgebra linealnumpy.dot, scipy.linalg
GPUTargeta gràficaExecució massiva de càlculs paral·lelstorch.Tensor.to("cuda")
CUDAGPU NVIDIAPlataforma per programar GPUsPyTorch, TensorFlow, numba
cuBLASGPU NVIDIAVersió GPU de BLAStorch.matmul amb CUDA activat

Aquestes tecnologies són claus per fer que les xarxes neuronals siguin ràpides i escalables. Quan entrenes o fas inferència amb models grans, estar familiaritzat amb aquestes eines t’ajuda a entendre què passa “sota el capó”.

Organització de matrius i dimensions

Per aprofitar aquestes tecnologies d’acceleració, cal entendre com s’organitzen les dades en format matricial.

Dimensions típiques en processament per lots

Suposem que tenim:

  • \(m = 3\) mostres d’entrada al lot (batch)
  • Cada mostra és un vector d’entrada amb \(n_{\text{in}} = 4\) característiques
  • La capa té \(n_{\text{out}} = 2\) neurones
Matriu d’entrada \(X\)

Conté les mostres del lot:

\[ X = \begin{bmatrix} x_{1}^{(1)} & x_{1}^{(2)} & x_{1}^{(3)} \\ x_{2}^{(1)} & x_{2}^{(2)} & x_{2}^{(3)} \\ x_{3}^{(1)} & x_{3}^{(2)} & x_{3}^{(3)} \\ x_{4}^{(1)} & x_{4}^{(2)} & x_{4}^{(3)} \\ \end{bmatrix} \in \mathbb{R}^{4 \times 3} \]

Cada columna és una mostra amb les seves característiques.

Matriu de pesos \(W\)

Cada fila de \(W\) conté els pesos d’una neurona:

\[ W = \begin{bmatrix} w_{1,1} & w_{1,2} & w_{1,3} & w_{1,4} \\ w_{2,1} & w_{2,2} & w_{2,3} & w_{2,4} \\ \end{bmatrix} \in \mathbb{R}^{2 \times 4} \]

Vector de biaixos \(b\)

\[ b = \begin{bmatrix} b_1 \ b_2 \end{bmatrix} \in \mathbb{R}^{2} \]

Càlcul de l’activació lineal \(Z\)

Fem la multiplicació i sumem el biaix:

\[ Z = W \cdot X + b = \begin{bmatrix} z_1^{(1)} & z_1^{(2)} & z_1^{(3)} \\ z_2^{(1)} & z_2^{(2)} & z_2^{(3)} \\ \end{bmatrix} \in \mathbb{R}^{2 \times 3} \]

Cada columna de \(Z\) és la sortida (abans de l’activació) per una mostra.

Resum de dimensions

MatriuDimensionsSignificat
\(X\)\(n_{\text{in}} \times m\)Entrades: cada columna és una mostra
\(W\)\(n_{\text{out}} \times n_{\text{in}}\)Pesos: cada fila correspon a una neurona
\(b\)\(n_{\text{out}}\)Biaixos: un valor per neurona
\(Z\)\(n_{\text{out}} \times m\)Activacions lineals: cada columna és l’output per mostra

El producte \(W \cdot X\) és possible perquè les dimensions coincideixen: \((n_{\text{out}} \times n_{\text{in}}) \cdot (n_{\text{in}} \times m) = (n_{\text{out}} \times m)\).

Transposada d’una matriu

La transposada d’una matriu \(A\), notada \(A^T\), s’obté intercanviant files per columnes:

\[ A = \begin{bmatrix} a_{11} & a_{12} & a_{13} \\ a_{21} & a_{22} & a_{23} \end{bmatrix} \quad\Rightarrow\quad A^T = \begin{bmatrix} a_{11} & a_{21} \\ a_{12} & a_{22} \\ a_{13} & a_{23} \end{bmatrix} \]

Si \(A\) és \(m \times n\), llavors \(A^T\) és \(n \times m\).

Ús pràctic: diferents convencions

Algunes biblioteques (com NumPy) representen \(X\) amb forma \(m \times n_{\text{in}}\), és a dir, cada fila és una mostra. En aquest cas, cal fer servir la transposada dels pesos:

\[ Z = X \cdot W^T + b \]

On:

  • \(X\) té forma \(m \times n_{\text{in}}\)
  • \(W^T\) té forma \(n_{\text{in}} \times n_{\text{out}}\)
  • \(Z\) té forma \(m \times n_{\text{out}}\)

Exemple en PyTorch:

import torch

m = 10       # nombre de mostres
n_in = 64    # característiques d'entrada
n_out = 25   # neurones a la capa

# Dades d'entrada (cada fila és una mostra)
X = torch.randn(m, n_in)       # (10, 64)

# Pesos i biaixos
W = torch.randn(n_out, n_in)   # (25, 64)
b = torch.randn(n_out)         # (25,)

# Càlcul vectoritzat per tot el lot
Z = torch.matmul(X, W.T) + b   # (10, 25)
# Equivalent: Z = X @ W.T + b

print("Forma de Z:", Z.shape)  # torch.Size([10, 25])

Aquest enfocament vectoritzat permet processar moltes mostres simultàniament, aprofitant al màxim les capacitats de BLAS (a CPU) o cuBLAS (a GPU).

Last change: , commit: cc6333d