Aprendiendo Transformers Parte 2 – GPT de cerca y personalmente
Aprendiendo Transformers Parte 2 - GPT en detalle
Explorando los Transformers Generativos Pre-Entrenados a través de nanoGPT
Bienvenido a la segunda parte de mi proyecto, donde profundizo en las complejidades de los modelos basados en transformer y GPT utilizando el conjunto de datos TinyStories y nanoGPT, todos entrenados en una antigua laptop de juegos. En la primera parte, preparé el conjunto de datos para introducirlo en un modelo generativo a nivel de caracteres. Puedes encontrar un enlace a la primera parte a continuación.
Aprendiendo el Código de los Transformers – Primera Parte
Parte 1 de una nueva serie donde me empeño en aprender el código de los transformers utilizando nanoGPT
towardsdatascience.com
En este artículo, mi objetivo es descomponer el modelo GPT, sus componentes y su implementación en nanoGPT. Elegí nanoGPT debido a su implementación sencilla en Python de un modelo GPT, que consta de aproximadamente 300 líneas de código, y su guión de entrenamiento igualmente digerible. Con los conocimientos básicos necesarios, uno podría comprender rápidamente los modelos GPT simplemente leyendo el código fuente. Para ser sincero, me faltaba esta comprensión cuando examiné por primera vez el código. Algunos de los conceptos aún me escapan. Sin embargo, espero que con todo lo que he aprendido, esta explicación proporcione un punto de partida para aquellos que deseen obtener una comprensión intuitiva de cómo funcionan internamente los modelos de estilo GPT.
En preparación para este artículo, leí varios documentos. Inicialmente, asumí que simplemente leyendo el trabajo seminal “Attention is All You Need” sería suficiente para poner mi comprensión al día. Esta fue una suposición ingenua. Si bien es cierto que este documento introdujo el modelo transformer, fueron documentos posteriores los que lo adaptaron para tareas más avanzadas, como la generación de texto. “AIAYN” fue simplemente una introducción a un tema más amplio. Sin embargo, no me desanimé y recordé un artículo en HackerNews que proporcionaba una lista de lecturas para comprender completamente los LLMs. Después de una búsqueda rápida, encontré el artículo aquí. No leí todo en secuencia, pero tengo la intención de volver a esta lista de lecturas para continuar mi aprendizaje después de completar esta serie.
Dicho esto, vamos a sumergirnos. Para comprender los modelos GPT en detalle, debemos comenzar con el transformer. El transformer utiliza un mecanismo de atención de auto-atención conocido como atención de producto puntual escalado. La siguiente explicación se deriva de este artículo perspicaz sobre la atención de producto puntual escalado, que recomiendo para una comprensión más profunda. Básicamente, para cada elemento de una secuencia de entrada (el elemento i-ésimo), queremos multiplicar la secuencia de entrada por un promedio ponderado de todos los elementos de la secuencia con el elemento i-ésimo. Estos pesos se calculan multiplicando el producto escalar del vector en el elemento i-ésimo con el vector de entrada completo y luego aplicando una función softmax para que los pesos sean valores entre 0 y 1. En el documento original “Attention is All You Need”, a estas entradas se les llaman consulta (la secuencia completa), clave (el vector en el elemento i-ésimo) y el valor (también la secuencia completa). Los pesos que se pasan al mecanismo de atención se inicializan con valores aleatorios y se aprenden a medida que se realizan más pasadas dentro de una red neuronal.
- Servir grandes modelos de lenguaje desde tu ordenador con inferenci...
- Las decisiones que preparan a los equipos de datos para el éxito
- 2 Formas efectivas de transferir datos desde las instalaciones a la...
nanoGPT implementa la atención de producto puntual escalado y la extiende a la atención de múltiples cabezas, lo que significa que se realizan múltiples operaciones de atención a la vez. También lo implementa como un torch.nn.Module
, lo que le permite componerse con otras capas de la red
import torchimport torch.nn as nnfrom torch.nn import functional as Fclass CausalSelfAttention(nn.Module): def __init__(self, config): super().__init__() assert config.n_embd % config.n_head == 0 # proyecciones de clave, consulta, valor para todas las cabezas, pero en un lote self.c_attn = nn.Linear(config.n_embd, 3 * config.n_embd, bias=config.bias) # proyección de salida self.c_proj = nn.Linear(config.n_embd, config.n_embd, bias=config.bias) # regularización self.attn_dropout = nn.Dropout(config.dropout) self.resid_dropout = nn.Dropout(config.dropout) self.n_head = config.n_head self.n_embd = config.n_embd self.dropout = config.dropout # flash attention hace que la GPU funcione rápidamente, pero solo es compatible con PyTorch >= 2.0 self.flash = hasattr(torch.nn.functional, 'scaled_dot_product_attention') if not self.flash: print("ADVERTENCIA: utilizando una atención lenta. Flash Attention requiere PyTorch >= 2.0") # máscara causal para asegurar que la atención solo se aplique a la izquierda en la secuencia de entrada self.register_buffer("bias", torch.tril(torch.ones(config.block_size, config.block_size)) .view(1, 1, config.block_size, config.block_size)) def forward(self, x): B, T, C = x.size() # tamaño del lote, longitud de la secuencia, dimensionalidad de la incrustación (n_embd) # calcular clave, consulta, valores para todas las cabezas en el lote y mover la cabeza hacia adelante para que sea la dimensión del lote q, k, v = self.c_attn(x).split(self.n_embd, dim=2) k = k.view(B, T, self.n_head, C // self.n_head).transpose(1, 2) # (B, nh, T, hs) q = q.view(B, T, self.n_head, C // self.n_head).transpose(1, 2) # (B, nh, T, hs) v = v.view(B, T, self.n_head, C // self.n_head).transpose(1, 2) # (B, nh, T, hs) # auto-atención causal; Auto-atender: (B, nh, T, hs) x (B, nh, hs, T) -> (B, nh, T, T) if self.flash: # atención eficiente utilizando los kernels CUDA de Flash Attention y = torch.nn.functional.scaled_dot_product_attention(q, k, v, attn_mask=None, dropout_p=self.dropout if self.training else 0, is_causal=True) else: # implementación manual de la atención att = (q @ k.transpose(-2, -1)) * (1.0 / math.sqrt(k.size(-1))) att = att.masked_fill(self.bias[:,:,:T,:T] == 0, float('-inf')) att = F.softmax(att, dim=-1) att = self.attn_dropout(att) y = att @ v # (B, nh, T, T) x (B, nh, T, hs) -> (B, nh, T, hs) y = y.transpose(1, 2).contiguous().view(B, T, C) # re-ensamblar todas las salidas de las cabezas uno al lado de otro # proyección de salida y = self.resid_dropout(self.c_proj(y)) return y
Profundicemos en este código aún más, comenzando con el constructor. Primero, verificamos que el número de cabezas de atención (n_heads
) divida uniformemente la dimensionalidad de la incrustación (n_embed
). Esto es crucial porque cuando la incrustación se divide en secciones para cada cabeza, queremos cubrir todo el espacio de incrustación sin dejar espacios vacíos. A continuación, inicializamos dos capas lineales, c_att
y c_proj
: c_att
es la capa que contiene todo nuestro espacio de trabajo para las matrices que componen el cálculo de atención de producto punto escalado, mientras que c_proj
almacena el resultado final de los cálculos. La dimensión de la incrustación se triplica en c_att
porque necesitamos incluir espacio para los tres componentes principales de la atención: query, key y value.
También tenemos dos capas de dropout, attn_dropout
y resid_dropout
. Las capas de dropout anulan aleatoriamente elementos de la matriz de entrada según una probabilidad dada. Según la documentación de PyTorch, esto sirve para reducir el sobreajuste del modelo. El valor en config.dropout
es la probabilidad de que una muestra dada se descarte durante una capa de dropout.
Finalizamos el constructor verificando si el usuario tiene acceso a PyTorch 2.0, que cuenta con una versión optimizada de la atención de producto punto escalado. Si está disponible, la clase lo utiliza; de lo contrario, configuramos una máscara de sesgo. Esta máscara es un componente de la función de enmascaramiento opcional del mecanismo de atención. El método torch.tril produce una matriz con su sección triangular superior convertida en ceros. Cuando se combina con el método torch.ones, genera efectivamente una máscara de 1s y 0s que el mecanismo de atención utiliza para producir salidas anticipadas para una entrada de muestra dada.
A continuación, nos adentramos en el método forward
de la clase, donde se aplica el algoritmo de atención. Inicialmente, determinamos los tamaños de nuestra matriz de entrada y la dividimos en tres dimensiones: tamaño del lote (Batch), tiempo (o número de muestras) (T) y corpus (o tamaño de incrustación) (C). nanoGPT utiliza un proceso de aprendizaje por lotes, que exploraremos con más detalle al examinar el modelo transformador que utiliza esta capa de atención. Por ahora, es suficiente entender que estamos trabajando con los datos en lotes. Luego, alimentamos la entrada x
en la capa de transformación lineal c_attn
, que expande la dimensionalidad de n_embed
a tres veces n_embed
. La salida de esa transformación se divide en nuestras variables q
, k
y v
, que son nuestras entradas para el algoritmo de atención. A continuación, se utiliza el método view
para reorganizar los datos en cada una de estas variables en el formato esperado por la función scaled_dot_product_attention
de PyTorch.
Cuando la función optimizada no está disponible, el código se basa en una implementación manual de la atención de producto punto escalado. Comienza tomando el producto punto de las matrices q
y k
, con k
transpuesta para que se ajuste a la función de producto punto, y se escala el resultado por la raíz cuadrada del tamaño de k
. Luego enmascaramos la salida escalada usando el búfer de sesgo creado previamente, reemplazando los 0s con infinito negativo. A continuación, se aplica una función softmax a la matriz att
, convirtiendo los infinitos negativos en 0s y asegurando que todos los demás valores estén escalados entre 0 y 1. Luego aplicamos una capa de dropout para evitar el sobreajuste antes de obtener el producto punto de la matriz att
y v
.
Independientemente de la implementación de producto punto escalado utilizada, la salida de múltiples cabezas se reorganiza uno al lado del otro antes de pasarla por una capa final de dropout y luego devolver el resultado. Esta es la implementación completa de la capa de atención en menos de 50 líneas de código Python/PyTorch. Si no comprende completamente el código anterior, le recomiendo que dedique algo de tiempo a revisarlo antes de continuar con el resto del artículo.
Antes de adentrarnos en el módulo GPT, que integra todo, necesitamos dos bloques de construcción más. El primero es un perceptrón multi-capa (MLP) simple, denominado en el artículo “Attention is All You Need” como una red de alimentación directa, y el bloque de atención, que combina la capa de atención con un MLP para completar la arquitectura básica del transformador representada en el artículo. Ambos están implementados en el siguiente fragmento de código de nanoGPT.
class MLP(nn.Module): """ Multi Layer Perceptron """ def __init__(self, config): super().__init__() self.c_fc = nn.Linear(config.n_embd, 4 * config.n_embd, bias=config.bias) self.gelu = nn.GELU() self.c_proj = nn.Linear(4 * config.n_embd, config.n_embd, bias=config.bias) self.dropout = nn.Dropout(config.dropout) def forward(self, x): x = self.c_fc(x) x = self.gelu(x) x = self.c_proj(x) x = self.dropout(x) return xclass Block(nn.Module): def __init__(self, config): super().__init__() self.ln_1 = LayerNorm(config.n_embd, bias=config.bias) self.attn = CausalSelfAttention(config) self.ln_2 = LayerNorm(config.n_embd, bias=config.bias) self.mlp = MLP(config) def forward(self, x): x = x + self.attn(self.ln_1(x)) x = x + self.mlp(self.ln_2(x)) return x
La capa MLP, a pesar de su aparente simplicidad en términos de líneas de código, añade una capa adicional de complejidad al modelo. Esencialmente, las capas lineales vinculan cada capa de entrada con cada elemento de la capa de salida, utilizando una transformación lineal para transferir los valores entre ellas. En el código mencionado anteriormente, comenzamos con el tamaño de incrustación, n_embed
, como el número de parámetros antes de cuadruplicarlo en la salida. La cuadruplicación aquí es arbitraria; el propósito del módulo MLP es mejorar la computación de la red mediante la adición de más nodos. Siempre que el aumento de dimensionalidad al comienzo del MLP y la disminución al final del MLP sean equivalentes, produciendo la misma dimensión de entrada inicial/salida final, entonces el número de escala es simplemente otro hiperparámetro. Otro elemento crucial a considerar es la función de activación. Esta implementación de MLP consta de dos capas lineales conectadas con la función de activación GELU. El artículo original utiliza una función ReLU, pero nanoGPT utiliza GELU para garantizar la compatibilidad con los puntos de control del modelo GPT2.
A continuación, examinamos el módulo Block. Este módulo finaliza nuestro bloque transformador según se describe en el artículo “Attention”. Esencialmente, canaliza la entrada a través de una capa de normalización antes de pasarla a la capa de atención, luego agrega el resultado de nuevo a la entrada. La salida de esta adición se normaliza una vez más antes de transferirse al MLP, y luego se agrega a sí misma. Este proceso implementa el lado del decodificador del transformador como se describe en el artículo “Attention”. Para la generación de texto, es común utilizar solo un decodificador, ya que no es necesario condicionar la salida del decodificador en nada más que en la secuencia de entrada. El transformador fue diseñado inicialmente para la traducción automática, que necesita tener en cuenta tanto la codificación del token de entrada como la codificación del token de salida. Sin embargo, con la generación de texto, solo se utiliza una codificación de token única, eliminando la necesidad de atención cruzada a través de un codificador. Andrej Karpathy, el autor de nanoGPT, proporciona una explicación completa de esto en su vídeo vinculado en el primer artículo de esta serie.
Finalmente, llegamos al componente principal: el modelo GPT. La mayoría de las aproximadamente 300 líneas de código del archivo están dedicadas al módulo GPT. Gestiona características beneficiosas como el ajuste fino del modelo y utilidades diseñadas para el entrenamiento del modelo (el tema del próximo artículo de esta serie). Por lo tanto, presento una versión simplificada de lo que está disponible en el repositorio de nanoGPT a continuación.
class GPT(nn.Module): def __init__(self, config): super().__init__() assert config.vocab_size is not None assert config.block_size is not None self.config = config self.transformer = nn.ModuleDict(dict( wte = nn.Embedding(config.vocab_size, config.n_embd), wpe = nn.Embedding(config.block_size, config.n_embd), drop = nn.Dropout(config.dropout), h = nn.ModuleList([Block(config) for _ in range(config.n_layer)]), ln_f = LayerNorm(config.n_embd, bias=config.bias), )) self.lm_head = nn.Linear(config.n_embd, config.vocab_size, bias=False) # with weight tying when using torch.compile() some warnings get generated: # "UserWarning: functional_call was passed multiple values for tied weights. # This behavior is deprecated and will be an error in future versions" # not 100% sure what this is, so far seems to be harmless. TODO investigate self.transformer.wte.weight = self.lm_head.weight # https://paperswithcode.com/method/weight-tying # init all weights self.apply(self._init_weights) # apply special scaled init to the residual projections, per GPT-2 paper for pn, p in self.named_parameters(): if pn.endswith('c_proj.weight'): torch.nn.init.normal_(p, mean=0.0, std=0.02/math.sqrt(2 * config.n_layer)) def _init_weights(self, module): if isinstance(module, nn.Linear): torch.nn.init.normal_(module.weight, mean=0.0, std=0.02) if module.bias is not None: torch.nn.init.zeros_(module.bias) elif isinstance(module, nn.Embedding): torch.nn.init.normal_(module.weight, mean=0.0, std=0.02) def forward(self, idx, targets=None): device = idx.device b, t = idx.size() assert t <= self.config.block_size, f"No se puede avanzar en una secuencia de longitud {t}, el tamaño del bloque es solo {self.config.block_size}" pos = torch.arange(0, t, dtype=torch.long, device=device) # forma (t) # avanzar en el modelo GPT en sí mismo tok_emb = self.transformer.wte(idx) # incrustaciones de token de forma (b, t, n_embd) pos_emb = self.transformer.wpe(pos) # incrustaciones de posición de forma (t, n_embd) x = self.transformer.drop(tok_emb + pos_emb) for block in self.transformer.h: x = block(x) x = self.transformer.ln_f(x) if targets is not None: # si se proporcionan objetivos deseados, también calcular la pérdida logits = self.lm_head(x) loss = F.cross_entropy(logits.view(-1, logits.size(-1)), targets.view(-1), ignore_index=-1) else: # optimización en tiempo de inferencia: solo avanzar en lm_head en la última posición logits = self.lm_head(x[:, [-1], :]) # nota: usando lista [-1] para preservar la dimensión del tiempo loss = None return logits, loss @torch.no_grad() def generate(self, idx, max_new_tokens, temperature=1.0, top_k=None): """ Toma una secuencia de condicionamiento de índices idx (LongTensor de forma (b,t)) y completa la secuencia max_new_tokens veces, alimentando las predicciones de nuevo en el modelo cada vez. Lo más probable es que quieras asegurarte de estar en el modo de operación model.eval() para esto. """ for _ in range(max_new_tokens): # si el contexto de la secuencia se vuelve demasiado largo, debemos cortarlo en block_size idx_cond = idx if idx.size(1) <= self.config.block_size else idx[:, -self.config.block_size:] # avanzar en el modelo para obtener los logits para el índice en la secuencia logits, _ = self(idx_cond) # seleccionar los logits en el último paso y escalar por la temperatura deseada logits = logits[:, -1, :] / temperature # opcionalmente cortar los logits solo a las mejores k opciones if top_k is not None: v, _ = torch.topk(logits, min(top_k, logits.size(-1))) logits[logits < v[:, [-1]]] = -float('Inf') # aplicar softmax para convertir los logits en probabilidades (normalizadas) probs = F.softmax(logits, dim=-1) # muestrear de la distribución idx_next = torch.multinomial(probs, num_samples=1) # agregar el índice muestreado a la secuencia en ejecución y continuar idx = torch.cat((idx, idx_next), dim=1) return idx
Comencemos con el constructor de la clase. Las diferentes capas se ensamblan en un PyTorch ModuleDict, que proporciona cierta estructura. Comenzamos con dos capas de incrustación (embedding): una para la incrustación de tokens y otra para la incrustación posicional. El módulo nn.Embedding
está diseñado para estar escasamente poblado de valores, optimizando su capacidad de almacenamiento en comparación con otros módulos de capa. Después de esto, tenemos una capa de dropout, seguida de n_layer
módulos Block que forman nuestras capas de atención, y luego otra capa de dropout individual. La capa lineal lm_head
toma la salida de los bloques de atención y la reduce al tamaño del vocabulario, actuando como nuestra salida principal para el GPT, aparte del valor de pérdida.
Una vez que se definen las capas, se requiere una configuración adicional antes de poder comenzar a entrenar el módulo. Aquí, Andrej vincula los pesos de la codificación posicional con los de la capa de salida. Según el documento vinculado en los comentarios del código, esto se hace para reducir los parámetros finales del modelo y mejorar su rendimiento. El constructor también inicializa los pesos del modelo. Como estos pesos se aprenderán durante el entrenamiento, se inicializan con una distribución gaussiana de números aleatorios y los sesgos del módulo se establecen en 0. Finalmente, se utiliza una modificación del artículo GPT-2 donde los pesos de cualquier capa residual se escalan por la raíz cuadrada del número de capas.
Cuando se avanza a través de la red, el tamaño del lote (batch size) y el número de muestras (aquí t
) se extraen del tamaño de entrada. Luego creamos memoria en el dispositivo de entrenamiento para lo que se convertirá en la incrustación posicional. A continuación, incrustamos los tokens de entrada en una capa de incrustación de tokens wte
. Después de esto, se calcula la incrustación posicional en la capa wpe
. Estas incrustaciones se suman antes de pasar por una capa de dropout. El resultado luego se pasa a través de cada uno de los bloques n_layer
y se normaliza. El resultado final se pasa a la capa lineal lm_head
, que reduce los pesos incrustados en un puntaje de probabilidad para cada token en un vocabulario.
Cuando se calcula una pérdida (por ejemplo, durante el entrenamiento), calculamos la diferencia entre el token predicho y el token real usando entropía cruzada. Si no, la pérdida es None
. Tanto la pérdida como las probabilidades de los tokens se devuelven como parte de la función de avance.
A diferencia de los módulos anteriores, el módulo GPT tiene métodos adicionales. El más relevante para nosotros es la función de generación, que será familiar para aquellos que hayan utilizado un modelo generativo antes. Dado un conjunto de tokens de entrada idx
, un número de max_new_tokens
y una temperature
, genera max_new_tokens
tokens. Veamos cómo lo logra. Primero, recorta los tokens de entrada para que se ajusten al block_size
(otros lo llaman longitud de contexto), si es necesario, muestreando desde el final de la entrada primero. A continuación, los tokens se alimentan a la red y la salida se escala según la temperature
ingresada. Cuanto mayor sea la temperatura, más creativo y propenso a alucinar será el modelo. Las temperaturas más altas también resultan en una salida menos predecible. A continuación, se aplica un softmax para convertir los pesos de salida del modelo en probabilidades entre 0 y 1. Se utiliza una función de muestreo para seleccionar el siguiente token a partir de las probabilidades, y ese token se agrega al vector de entrada que se alimenta de nuevo al modelo GPT para el siguiente carácter.
Gracias por su paciencia al leer este artículo completo. Si bien examinar el código fuente anotado es un método valioso para comprender la función de un segmento de código, no hay sustituto para manipular personalmente varias partes y parámetros del código. En línea con esto, proporciono un enlace al código fuente completo model.py
del repositorio de nanoGPT
nanoGPT/model.py at master · karpathy/nanoGPT
El repositorio más simple y rápido para entrenar/ajustar GPTs de tamaño VoAGI. – nanoGPT/model.py en master ·…
github.com
En el próximo artículo, exploraremos el script train.py
de nanoGPT y entrenaremos un modelo a nivel de caracteres en el conjunto de datos TinyStories. ¡Sígueme en VoAGI para asegurarte de no perderte nada!
Utilicé una amplia variedad de recursos para crear este artículo, muchos de los cuales ya han sido enlazados en este y el artículo anterior. Sin embargo, estaría descuidando mi deber si no comparto estos recursos contigo para una exploración más profunda de cualquier tema o para explicaciones alternativas de los conceptos.
- Construyamos GPT: desde cero, en código, detallado — YouTube
- Lista de lectura de LLM — Blog
- “Attention is All You Need” — Paper
- “Language Models are Unsupervised Multitask Learners” — GPT-2 Paper
- Perceptrones de múltiples capas explicados e ilustrados — VoAGI
- Weight Tying — Papers With Code
- Guía ilustrada de la red neuronal Transformers: una explicación paso a paso — YouTube
Editado usando GPT-4 y un script personalizado de LangChain.