# 0. Conceptos básicos de LLM
## Preentrenamiento
El preentrenamiento es la fase fundamental en el desarrollo de un modelo de lenguaje grande (LLM) donde el modelo se expone a grandes y diversas cantidades de datos textuales. Durante esta etapa, **el LLM aprende las estructuras, patrones y matices fundamentales del lenguaje**, incluyendo gramática, vocabulario, sintaxis y relaciones contextuales. Al procesar estos datos extensos, el modelo adquiere una comprensión amplia del lenguaje y del conocimiento general del mundo. Esta base integral permite al LLM generar texto coherente y contextualmente relevante. Posteriormente, este modelo preentrenado puede someterse a un ajuste fino, donde se entrena aún más en conjuntos de datos especializados para adaptar sus capacidades a tareas o dominios específicos, mejorando su rendimiento y relevancia en aplicaciones dirigidas.
## Componentes principales de LLM
Usualmente, un LLM se caracteriza por la configuración utilizada para entrenarlo. Estos son los componentes comunes al entrenar un LLM:
* **Parámetros**: Los parámetros son los **pesos y sesgos aprendibles** en la red neuronal. Estos son los números que el proceso de entrenamiento ajusta para minimizar la función de pérdida y mejorar el rendimiento del modelo en la tarea. Los LLM suelen utilizar millones de parámetros.
* **Longitud del contexto**: Esta es la longitud máxima de cada oración utilizada para preentrenar el LLM.
* **Dimensión de incrustación**: El tamaño del vector utilizado para representar cada token o palabra. Los LLM suelen usar miles de millones de dimensiones.
* **Dimensión oculta**: El tamaño de las capas ocultas en la red neuronal.
* **Número de capas (profundidad)**: Cuántas capas tiene el modelo. Los LLM suelen usar decenas de capas.
* **Número de cabezales de atención**: En los modelos de transformadores, esta es la cantidad de mecanismos de atención separados que se utilizan en cada capa. Los LLM suelen usar decenas de cabezales.
* **Dropout**: El dropout es algo así como el porcentaje de datos que se eliminan (las probabilidades se convierten en 0) durante el entrenamiento utilizado para **prevenir el sobreajuste.** Los LLM suelen usar entre 0-20%.
Configuración del modelo GPT-2:
```json
GPT_CONFIG_124M = {
"vocab_size": 50257, // Vocabulary size of the BPE tokenizer
"context_length": 1024, // Context length
"emb_dim": 768, // Embedding dimension
"n_heads": 12, // Number of attention heads
"n_layers": 12, // Number of layers
"drop_rate": 0.1, // Dropout rate: 10%
"qkv_bias": False // Query-Key-Value bias
}
```
## Tensors en PyTorch
En PyTorch, un **tensor** es una estructura de datos fundamental que sirve como un arreglo multidimensional, generalizando conceptos como escalares, vectores y matrices a dimensiones potencialmente más altas. Los tensores son la forma principal en que los datos se representan y manipulan en PyTorch, especialmente en el contexto del aprendizaje profundo y las redes neuronales.
### Concepto Matemático de Tensores
* **Escalares**: Tensores de rango 0, que representan un solo número (cero-dimensional). Como: 5
* **Vectores**: Tensores de rango 1, que representan un arreglo unidimensional de números. Como: \[5,1]
* **Matrices**: Tensores de rango 2, que representan arreglos bidimensionales con filas y columnas. Como: \[\[1,3], \[5,2]]
* **Tensores de Rango Superior**: Tensores de rango 3 o más, que representan datos en dimensiones superiores (por ejemplo, tensores 3D para imágenes en color).
### Tensores como Contenedores de Datos
Desde una perspectiva computacional, los tensores actúan como contenedores para datos multidimensionales, donde cada dimensión puede representar diferentes características o aspectos de los datos. Esto hace que los tensores sean altamente adecuados para manejar conjuntos de datos complejos en tareas de aprendizaje automático.
### Tensores de PyTorch vs. Arreglos de NumPy
Mientras que los tensores de PyTorch son similares a los arreglos de NumPy en su capacidad para almacenar y manipular datos numéricos, ofrecen funcionalidades adicionales cruciales para el aprendizaje profundo:
* **Diferenciación Automática**: Los tensores de PyTorch soportan el cálculo automático de gradientes (autograd), lo que simplifica el proceso de calcular derivadas requeridas para entrenar redes neuronales.
* **Aceleración por GPU**: Los tensores en PyTorch pueden ser movidos y computados en GPUs, acelerando significativamente los cálculos a gran escala.
### Creando Tensores en PyTorch
Puedes crear tensores usando la función `torch.tensor`:
```python
pythonCopy codeimport torch
# Scalar (0D tensor)
tensor0d = torch.tensor(1)
# Vector (1D tensor)
tensor1d = torch.tensor([1, 2, 3])
# Matrix (2D tensor)
tensor2d = torch.tensor([[1, 2],
[3, 4]])
# 3D Tensor
tensor3d = torch.tensor([[[1, 2], [3, 4]],
[[5, 6], [7, 8]]])
```
### Tipos de Datos de Tensor
Los tensores de PyTorch pueden almacenar datos de varios tipos, como enteros y números de punto flotante.
Puedes verificar el tipo de dato de un tensor usando el atributo `.dtype`:
```python
tensor1d = torch.tensor([1, 2, 3])
print(tensor1d.dtype) # Output: torch.int64
```
* Los tensores creados a partir de enteros de Python son de tipo `torch.int64`.
* Los tensores creados a partir de flotantes de Python son de tipo `torch.float32`.
Para cambiar el tipo de datos de un tensor, utiliza el método `.to()`:
```python
float_tensor = tensor1d.to(torch.float32)
print(float_tensor.dtype) # Output: torch.float32
```
### Operaciones Comunes con Tensores
PyTorch proporciona una variedad de operaciones para manipular tensores:
* **Accediendo a la Forma**: Usa `.shape` para obtener las dimensiones de un tensor.
```python
print(tensor2d.shape) # Salida: torch.Size([2, 2])
```
* **Reformando Tensores**: Usa `.reshape()` o `.view()` para cambiar la forma.
```python
reshaped = tensor2d.reshape(4, 1)
```
* **Transponiendo Tensores**: Usa `.T` para transponer un tensor 2D.
```python
transposed = tensor2d.T
```
* **Multiplicación de Matrices**: Usa `.matmul()` o el operador `@`.
```python
result = tensor2d @ tensor2d.T
```
### Importancia en el Aprendizaje Profundo
Los tensores son esenciales en PyTorch para construir y entrenar redes neuronales:
* Almacenan datos de entrada, pesos y sesgos.
* Facilitan las operaciones requeridas para los pasos hacia adelante y hacia atrás en los algoritmos de entrenamiento.
* Con autograd, los tensores permiten el cálculo automático de gradientes, agilizando el proceso de optimización.
## Diferenciación Automática
La diferenciación automática (AD) es una técnica computacional utilizada para **evaluar las derivadas (gradientes)** de funciones de manera eficiente y precisa. En el contexto de redes neuronales, AD permite el cálculo de gradientes requeridos para **algoritmos de optimización como el descenso de gradiente**. PyTorch proporciona un motor de diferenciación automática llamado **autograd** que simplifica este proceso.
### Explicación Matemática de la Diferenciación Automática
**1. La Regla de la Cadena**
En el corazón de la diferenciación automática está la **regla de la cadena** del cálculo. La regla de la cadena establece que si tienes una composición de funciones, la derivada de la función compuesta es el producto de las derivadas de las funciones compuestas.
Matemáticamente, si `y=f(u)` y `u=g(x)`, entonces la derivada de `y` con respecto a `x` es:
**2. Grafo Computacional**
En AD, los cálculos se representan como nodos en un **grafo computacional**, donde cada nodo corresponde a una operación o una variable. Al recorrer este grafo, podemos calcular derivadas de manera eficiente.
3. Ejemplo
Consideremos una función simple:
Donde:
* `σ(z)` es la función sigmoide.
* `y=1.0` es la etiqueta objetivo.
* `L` es la pérdida.
Queremos calcular el gradiente de la pérdida `L` con respecto al peso `w` y al sesgo `b`.
**4. Cálculo de Gradientes Manualmente**
**5. Cálculo Numérico**
### Implementando la Diferenciación Automática en PyTorch
Ahora, veamos cómo PyTorch automatiza este proceso.
```python
pythonCopy codeimport torch
import torch.nn.functional as F
# Define input and target
x = torch.tensor([1.1])
y = torch.tensor([1.0])
# Initialize weights with requires_grad=True to track computations
w = torch.tensor([2.2], requires_grad=True)
b = torch.tensor([0.0], requires_grad=True)
# Forward pass
z = x * w + b
a = torch.sigmoid(z)
loss = F.binary_cross_entropy(a, y)
# Backward pass
loss.backward()
# Gradients
print("Gradient w.r.t w:", w.grad)
print("Gradient w.r.t b:", b.grad)
```
**Salida:**
```css
cssCopy codeGradient w.r.t w: tensor([-0.0898])
Gradient w.r.t b: tensor([-0.0817])
```
## Retropropagación en Redes Neuronales Más Grandes
### **1. Ampliación a Redes Multicapa**
En redes neuronales más grandes con múltiples capas, el proceso de cálculo de gradientes se vuelve más complejo debido al aumento en el número de parámetros y operaciones. Sin embargo, los principios fundamentales permanecen iguales:
* **Paso Adelante:** Calcular la salida de la red pasando las entradas a través de cada capa.
* **Calcular Pérdida:** Evaluar la función de pérdida utilizando la salida de la red y las etiquetas objetivo.
* **Paso Atrás (Retropropagación):** Calcular los gradientes de la pérdida con respecto a cada parámetro en la red aplicando la regla de la cadena de manera recursiva desde la capa de salida hasta la capa de entrada.
### **2. Algoritmo de Retropropagación**
* **Paso 1:** Inicializar los parámetros de la red (pesos y sesgos).
* **Paso 2:** Para cada ejemplo de entrenamiento, realizar un paso adelante para calcular las salidas.
* **Paso 3:** Calcular la pérdida.
* **Paso 4:** Calcular los gradientes de la pérdida con respecto a cada parámetro utilizando la regla de la cadena.
* **Paso 5:** Actualizar los parámetros utilizando un algoritmo de optimización (por ejemplo, descenso de gradiente).
### **3. Representación Matemática**
Considera una red neuronal simple con una capa oculta:
### **4. Implementación en PyTorch**
PyTorch simplifica este proceso con su motor de autograd.
```python
import torch
import torch.nn as nn
import torch.optim as optim
# Define a simple neural network
class SimpleNet(nn.Module):
def __init__(self):
super(SimpleNet, self).__init__()
self.fc1 = nn.Linear(10, 5) # Input layer to hidden layer
self.relu = nn.ReLU()
self.fc2 = nn.Linear(5, 1) # Hidden layer to output layer
self.sigmoid = nn.Sigmoid()
def forward(self, x):
h = self.relu(self.fc1(x))
y_hat = self.sigmoid(self.fc2(h))
return y_hat
# Instantiate the network
net = SimpleNet()
# Define loss function and optimizer
criterion = nn.BCELoss()
optimizer = optim.SGD(net.parameters(), lr=0.01)
# Sample data
inputs = torch.randn(1, 10)
labels = torch.tensor([1.0])
# Training loop
optimizer.zero_grad() # Clear gradients
outputs = net(inputs) # Forward pass
loss = criterion(outputs, labels) # Compute loss
loss.backward() # Backward pass (compute gradients)
optimizer.step() # Update parameters
# Accessing gradients
for name, param in net.named_parameters():
if param.requires_grad:
print(f"Gradient of {name}: {param.grad}")
```
En este código:
* **Forward Pass:** Calcula las salidas de la red.
* **Backward Pass:** `loss.backward()` calcula los gradientes de la pérdida con respecto a todos los parámetros.
* **Parameter Update:** `optimizer.step()` actualiza los parámetros en función de los gradientes calculados.
### **5. Understanding Backward Pass**
Durante el backward pass:
* PyTorch recorre el grafo computacional en orden inverso.
* Para cada operación, aplica la regla de la cadena para calcular los gradientes.
* Los gradientes se acumulan en el atributo `.grad` de cada tensor de parámetro.
### **6. Advantages of Automatic Differentiation**
* **Efficiency:** Evita cálculos redundantes al reutilizar resultados intermedios.
* **Accuracy:** Proporciona derivadas exactas hasta la precisión de la máquina.
* **Ease of Use:** Elimina el cálculo manual de derivadas.