Inicio / Artículos

Tokenizador BPE desde cero: secuencias más cortas para una ventana de contexto pequeña en GPT

Escriba un tokenizador de codificación de pares de bytes en Python, entrénelo con WikiText-2, guarde las fusiones en formato JSON y observe cómo la tokenización subpalabra amplía la ventana de contexto de un GPT.

Editor: REACTAPP.TOP26 min de lectura6015 palabras

Fuentes: Tutorial 16: Beyond Character Tokens, Building a BPE Tokenizer

Un tokenizador a nivel de carácter es la forma más sencilla de hacer funcionar un GPT pequeño: el vocabulario es muy reducido, no hay nada desconocido y cada paso es transparente. El costo está en la longitud de la secuencia, ya que cada letra ocupa un espacio en la ventana de contexto. Esta guía lo reemplaza por el Codificación de Pares de Bytes (BPE), desarrollado desde cero en Python puro. Lo entrenarás con WikiText-2, guardarás el vocabulario y las fusiones aprendidas en formato JSON, generarás su huella digital, volverás a codificar el conjunto de datos y verás exactamente qué cambia al volver a entrenar el modelo con tokens subpalabras.

El código presupone que ya existe un GPT pequeño de PyTorch y archivos de WikiText-2 limpios. Si el entrenamiento sigue teniendo problemas, primero estabilízalo; consulta pruebas que aíslan cada fallo en un pequeño GPT de PyTorch.

Por qué los tokens de carácter ya no son suficientes

Un tokenizador de caracteres asigna a cada carácter, incluidos los espacios, su propio token. Toma esta breve frase:

artificial intelligence

Se convierte en un token por carácter:

['a', 'r', 't', 'i', 'f', 'i', 'c', 'i', 'a', 'l',
 ' ', 'i', 'n', 't', 'e', 'l', 'l', 'i', 'g', 'e',
 'n', 'c', 'e']

Eso son 23 tokens para dos palabras. Un tokenizador de subpalabras podría representar la misma frase con muchas menos partes, por ejemplo:

['art', 'ificial', ' intelligence']

Tres tokens en lugar de 23. Las secuencias más cortas permiten incluir más texto por ventana de contexto, menos posiciones para entrenar y menos pasos de decodificación durante la generación. El modelo también recibe fragmentos significativos en lugar de letras aisladas; los fragmentos frecuentes se convierten en unidades reutilizables, y las palabras raras aún pueden ser formadas a partir de piezas más pequeñas. La parte difícil es descubrir automáticamente buenas unidades subpalabras, y ese es precisamente el trabajo que realiza BPE.

Comparación de caracteres, palabras y subpalabras

Existen tres formas principales de dividir el texto, y cada una implica un compromiso diferente.

Tokenización por caracteres

Cada carácter es un token:

"learning"
→ ["l", "e", "a", "r", "n", "i", "n", "g"]

Ofrece un vocabulario reducido, sin tokens desconocidos y una implementación sencilla. A cambio, se obtienen secuencias largas, una generación lenta y un modelo que debe formar palabras a partir de letras individuales.

Tokenización de palabras

Cada palabra es un token:

"machine learning works"
→ ["machine", "learning", "works"]

Las secuencias son cortas y fáciles de leer, pero el vocabulario se vuelve enorme; las palabras desconocidas requieren un token especial, y formas relacionadas como "learn", "learning" y "learned" pueden convertirse en tres identificadores sin relación entre sí. Los nombres, las errores de escritura y las palabras poco comunes se manejan mal.

Tokenización subpalabra

Las palabras se dividen en fragmentos frecuentes:

"unbelievable"
→ ["un", "believ", "able"]

Esto genera secuencias más cortas que los caracteres y un vocabulario más reducido que las palabras. Las palabras frecuentes aún pueden convertirse en tokens individuales, las palabras raras se descomponen en partes conocidas, y fragmentos como prefijos y sufijos se reutilizan en muchas palabras. El único costo real es que el entrenamiento e implementación del tokenizador es más complejo.

Puede considerar los tres enfoques como puntos en un espectro:

Characters
← flexibility

Subwords
← balance

Words
← compactness

Cómo aprende BPE sus fusiones

BPE comienza con las unidades más pequeñas, en este caso los caracteres individuales, y fusiona repetidamente la pareja de vecinos más común para crear un nuevo token. Considere un corpus muy pequeño de tres palabras:

low
lower
lowest

Inicialmente, cada palabra es simplemente sus caracteres:

l o w
l o w e r
l o w e s t

A continuación, enumere las parejas de símbolos adyacentes que aparecen:

(l, o)
(o, w)
(w, e)
(e, r)
(e, s)
(s, t)

Un par destaca:

(l, o)

Aparece en las tres palabras, por lo que se combina en un único símbolo:

(l, o) → lo

Después de reemplazarlo en todas partes, el corpus se ve así:

lo w
lo w e r
lo w e s t

Al contar de nuevo, la pareja más frecuente ahora es:

(lo, w)

También aparece tres veces, por lo que también se fusiona:

(lo, w) → low

Y el corpus se convierte en:

low
low e r
low e s t

Después de suficientes rondas, las secuencias más frecuentes se convierten en tokens por sí mismas.

Qué produce el bucle de entrenamiento

El procedimiento que está a punto de implementar sigue estos pasos:

Training text
→ initialize character tokens
→ count adjacent token pairs
→ select most frequent pair
→ merge the selected pair
→ add merged token to vocabulary
→ repeat until target vocabulary size

Produce dos artefactos, y se necesitan ambos para utilizar el tokenizador más tarde.

El vocabulario

El vocabulario asocia cada cadena de token a un ID entero, por ejemplo:

"a"       → 0
"b"       → 1
"th"      → 98
"ing"     → 127
" the"    → 204

Las reglas de fusión

Las reglas de fusión son la lista ordenada de pares que se fusionaron durante el entrenamiento:

("t", "h") → "th"
("i", "n") → "in"
("in", "g") → "ing"
(" ", "the") → " the"

El orden es esencial. Una regla posterior como ("in", "g") solo puede activarse si la regla anterior que creó "in" ya ha sido aplicada. Aplicar las mismas reglas en un orden diferente produce una segmentación distinta.

Primera implementación en un corpus de ejemplo

Antes de trabajar con WikiText-2, pruebe el algoritmo en una cadena que pueda inspeccionar visualmente:

text = "low lower lowest"

Al dividirlo en caracteres se obtiene la lista de tokens iniciales:

tokens = list(text)
print(tokens)

Al imprimirlo se muestran todos los caracteres, incluidos los espacios:

['l', 'o', 'w', ' ', 'l', 'o', 'w', 'e', 'r',
 ' ', 'l', 'o', 'w', 'e', 's', 't']

Para encontrar candidatos para la fusión, cuenta cada par de vecinos. Al combinar la lista consigo misma desplazada una posición se obtiene cada par adyacente, y Counter los cuenta:

from collections import Counter


def count_pairs(tokens):
    return Counter(
        zip(tokens[:-1], tokens[1:])
    )

Inspeccione las parejas más comunes:

pair_counts = count_pairs(tokens)
print(pair_counts.most_common(10))

El resultado tendrá un aspecto más o menos así:

[(('l', 'o'), 3),
 (('o', 'w'), 3),
 (('w', 'e'), 2),
 ...]

Tenga en cuenta que (' ', 'l') también aparece en los conteos. Dado que los espacios son caracteres comunes en este esquema, BPE puede aprender tokens que incluyen un espacio al principio, y así surgen posteriormente tokens como " the".

Fusión de una sola pareja

La operación principal recorre la lista de tokens de izquierda a derecha. Cada vez que el token actual y el siguiente coinciden con la pareja elegida, emite el token fusionado y salta dos posiciones hacia adelante; de lo contrario, copia el token actual y avanza una posición. Las superposiciones se resuelven de izquierda a derecha: en a a a con la pareja (a, a), solo se fusionan los dos primeros.

def merge_pair(
    tokens,
    pair,
    merged_token,
):
    output = []
    index = 0

    while index < len(tokens):
        if (
            index < len(tokens) - 1
            and tokens[index] == pair[0]
            and tokens[index + 1] == pair[1]
        ):
            output.append(merged_token)
            index += 2
        else:
            output.append(tokens[index])
            index += 1

    return output

Úsalo para fusionar l y o:

tokens = merge_pair(
    tokens=tokens,
    pair=("l", "o"),
    merged_token="lo",
)

print(tokens)

El resultado es más corto que la entrada:

['lo', 'w', ' ', 'lo', 'w', 'e', 'r',
 ' ', 'lo', 'w', 'e', 's', 't']

Cada fusión elimina un token por cada aparición de la pareja, y de ahí proviene exactamente la compresión.

Repetir hasta que el vocabulario esté completo

Un entrenador mínimo simplemente repite un ciclo: cuenta las parejas, elige la más frecuente, la fusiona en todas partes, registra el nuevo token y la regla, y se detiene una vez que el vocabulario alcanza el tamaño deseado. También se detiene antes si no quedan parejas, o si la pareja más frecuente aparece menos de dos veces, ya que en ese punto la fusión ya no genera nada reutilizable.

def train_bpe_on_tokens(
    tokens,
    target_vocab_size,
):
    vocabulary = set(tokens)
    merges = []

    while len(vocabulary) < target_vocab_size:
        pair_counts = count_pairs(tokens)

        if not pair_counts:
            break

        best_pair, frequency = (
            pair_counts.most_common(1)[0]
        )

        if frequency < 2:
            break

        merged_token = "".join(best_pair)

        tokens = merge_pair(
            tokens=tokens,
            pair=best_pair,
            merged_token=merged_token,
        )

        vocabulary.add(merged_token)
        merges.append(best_pair)

        print(
            f"Merged {best_pair} "
            f"into {merged_token!r}, "
            f"frequency={frequency}"
        )

    return tokens, vocabulary, merges

Ejecutarlo con la cadena de ejemplo y un objetivo pequeño es suficiente para observar cómo se producen las fusiones una tras otra:

final_tokens, vocabulary, merges = (
    train_bpe_on_tokens(
        tokens=list(text),
        target_vocab_size=20,
    )
)

Esta versión es útil para desarrollar intuición, pero cada iteración vuelve a escanear toda la lista de Python. En el corpus completo de WikiText, esto se vuelve muy lento, por lo que la versión práctica que se presenta a continuación se entrena con una porción configurable de WikiText-2.

Diseño de un tokenizador reutilizable

La clase del tokenizador debe entrenarse a partir de texto sin procesar, comenzando por los caracteres, codificar y decodificar, rechazar caracteres fuera de su vocabulario, guardar y cargar datos desde JSON, y calcular una huella digital. Alrededor de ella, un script vuelve a codificar cada fragmento de WikiText-2 y muestra cuánto más cortas son las secuencias BPE.

Hay una decisión de diseño que merece ser mencionada claramente. Este tokenizador funciona con caracteres Unicode, no con bytes en bruto. Los tokenizadores de producción utilizados por los modelos grandes suelen operar a nivel de byte, lo que significa que su vocabulario base está formado por los 256 valores de byte posibles y ningún dato de entrada puede quedar sin representación. La versión basada en caracteres aquí sigue el mismo algoritmo y es más fácil de leer, pero no es un equivalente directo de esos tokenizadores.

El esqueleto de la clase

El constructor recibe los caracteres base y la lista ordenada de fusiones. Construye la lista completa de tokens comenzando con los caracteres base y añadiendo el resultado de cada fusión, omitiendo las duplicadas. A partir de esa lista, deriva las funciones de búsqueda stoi (de cadena a ID) e itos (de ID a cadena) y registra el tamaño del vocabulario. Dado que los IDs provienen de las posiciones en la lista, los mismos tokens y fusiones siempre generan los mismos IDs.

class BPETokenizer:
    def __init__(
        self,
        base_tokens,
        merges,
    ):
        self.base_tokens = list(
            base_tokens
        )

        self.merges = [
            tuple(pair)
            for pair in merges
        ]

        self.tokens = list(
            self.base_tokens
        )

        for left, right in self.merges:
            merged = left + right

            if merged not in self.tokens:
                self.tokens.append(merged)

        self.stoi = {
            token: index
            for index, token in enumerate(
                self.tokens
            )
        }

        self.itos = {
            index: token
            for token, index
            in self.stoi.items()
        }

        self.vocab_size = len(
            self.tokens
        )

Por lo tanto, el vocabulario resultante está compuesto por dos partes:

Base character tokens
+
learned merged tokens

Entrenamiento con texto real

El entrenamiento se convierte en un método de clase que devuelve un tokenizador listo para usar. Ordena los caracteres únicos del texto para formar el vocabulario base, rechaza un tamaño objetivo menor que ese conjunto base y luego ejecuta el mismo bucle de conteo, selección y fusión que antes. El registro de información se controla: imprime las primeras 20 fusiones y luego una cada cien, junto con la frecuencia de la pareja y la longitud actual de la secuencia, de modo que pueda observar el progreso de la compresión sin quedar abrumado por la cantidad de datos generados.

@classmethod
def train(
    cls,
    text,
    target_vocab_size,
    minimum_pair_frequency=2,
    verbose=True,
):
    base_tokens = sorted(
        set(text)
    )

    if target_vocab_size < len(base_tokens):
        raise ValueError(
            "target_vocab_size cannot be "
            "smaller than the number of "
            "unique characters"
        )

    working_tokens = list(text)
    vocabulary = set(base_tokens)
    merges = []

    while len(vocabulary) < target_vocab_size:
        pair_counts = Counter(
            zip(
                working_tokens[:-1],
                working_tokens[1:],
            )
        )

        if not pair_counts:
            break

        best_pair, frequency = (
            pair_counts.most_common(1)[0]
        )

        if frequency < minimum_pair_frequency:
            break

        merged_token = "".join(
            best_pair
        )

        working_tokens = merge_pair(
            tokens=working_tokens,
            pair=best_pair,
            merged_token=merged_token,
        )

        merges.append(best_pair)
        vocabulary.add(merged_token)

        if (
            verbose
            and (
                len(merges) <= 20
                or len(merges) % 100 == 0
            )
        ):
            print(
                f"Merge {len(merges):4d}: "
                f"{best_pair!r} "
                f"→ {merged_token!r}, "
                f"frequency={frequency:,}, "
                f"tokens={len(working_tokens):,}"
            )

    return cls(
        base_tokens=base_tokens,
        merges=merges,
    )

Por qué existe una frecuencia mínima de pares

Si el par más frecuente restante aparece solo una vez, su fusión genera un token que nunca será reutilizado, lo que simplemente desperdicia un espacio en el vocabulario. Por lo tanto, el bucle se detiene en esta condición:

frequency < minimum_pair_frequency

Para experimentos como este, un umbral de dos es suficiente:

minimum_pair_frequency = 2

En un corpus más grande, es posible que desee un umbral más alto, de modo que el vocabulario se utilice únicamente en patrones que ocurren con suficiente frecuencia como para que el modelo aprenda buenos embeddings para ellos.

Codificación y decodificación con el tokenizador

La codificación sigue tres pasos:

  1. Dividir el texto en caracteres básicos.
  2. Reproducir cada regla de fusión en el orden en que fue aprendida.
  3. Buscar el ID de cada token resultante.

La primera mitad de este proceso se encuentra en encode_tokens. Comienza recopilando todos los caracteres que faltan en el vocabulario y genera un error claro si hay alguno, lo cual es mucho más fácil de depurar que un KeyError en medio de un conjunto de datos. Luego reproduce las fusiones.

def encode_tokens(self, text):
    unknown_characters = sorted({
        char
        for char in text
        if char not in self.stoi
    })

    if unknown_characters:
        raise ValueError(
            "Text contains characters missing "
            "from the base vocabulary: "
            f"{unknown_characters}"
        )

    tokens = list(text)

    for pair in self.merges:
        merged_token = (
            pair[0] + pair[1]
        )

        tokens = merge_pair(
            tokens=tokens,
            pair=pair,
            merged_token=merged_token,
        )

    return tokens

Convertir las cadenas de tokens resultantes en IDs es una búsqueda sencilla:

def encode(self, text):
    tokens = self.encode_tokens(text)

    return [
        self.stoi[token]
        for token in tokens
    ]

La decodificación es aún más sencilla. Cada token, ya sea fusionado o no, almacena el texto exacto que abarca, por lo que la decodificación consiste simplemente en concatenación:

def decode(self, token_ids):
    return "".join(
        self.itos[int(token_id)]
        for token_id in token_ids
    )

Eso le confiere a BPE una propiedad que merece ser probada explícitamente: la posibilidad de realizar viajes de ida y vuelta sin pérdida de calidad:

decode(encode(text)) = text

Una verificación rápida con una oración de ejemplo:

sample = "The history of science."
encoded = tokenizer.encode(sample)
decoded = tokenizer.decode(encoded)
assert decoded == sample

Permanencia y recarga del tokenizador

No es necesario almacenar el vocabulario en sí, ya que se puede reconstruir de manera determinista a partir de los tokens básicos y la lista de fusión. El método save escribe esos dos elementos en JSON, junto con una etiqueta de tipo y el tamaño del vocabulario para mayor comodidad. Al establecer ensure_ascii=False se mantienen legibles los caracteres no ASCII en el archivo, y se crea el directorio padre si aún no existe.

def save(self, path):
    data = {
        "type": "character_bpe",
        "base_tokens": self.base_tokens,
        "merges": [
            [left, right]
            for left, right in self.merges
        ],
        "vocab_size": self.vocab_size,
    }

    path = Path(path)

    path.parent.mkdir(
        parents=True,
        exist_ok=True,
    )

    with open(
        path,
        "w",
        encoding="utf-8",
    ) as file:
        json.dump(
            data,
            file,
            ensure_ascii=False,
            indent=2,
        )

El resultado es un único archivo:

tokenizer/
└── bpe_tokenizer.json

Al cargarse, el proceso se invierte al leer el JSON y pasar ambas listas al constructor:

@classmethod
def from_file(cls, path):
    with open(
        path,
        "r",
        encoding="utf-8",
    ) as file:
        data = json.load(file)

    return cls(
        base_tokens=data[
            "base_tokens"
        ],
        merges=data["merges"],
    )

Luego confirme que el tokenizador recargado sigue procesando el texto de ejemplo de ida y vuelta:

loaded_tokenizer = (
    BPETokenizer.from_file(
        "tokenizer/bpe_tokenizer.json"
    )
)

assert (
    loaded_tokenizer.decode(
        loaded_tokenizer.encode(sample)
    )
    == sample
)

Entrenamiento con una porción de WikiText-2

Dado que el entrenador simple vuelve a escanear toda la secuencia de trabajo en cada fusión, entrenar todo WikiText-2 con un vocabulario amplio puede llevar mucho tiempo. Comience con un prefijo configurable del texto de entrenamiento:

training_characters = 500_000

training_text = train_text[
    :training_characters
]

Un tamaño razonable de vocabulario inicial es:

target_vocab_size = 500

En otras palabras:

Initial character vocabulary
+
learned merged tokens
=
approximately 500 tokens

Cuando todo funcione, pruebe con objetivos más grandes:

target_vocab_size = 1_000
target_vocab_size = 2_000

Se espera que el tiempo de entrenamiento aumente tanto con el tamaño de la muestra como con la cantidad de fusiones.

Tomar caracteres base de cada división

Si el vocabulario base se construye únicamente a partir de la muestra de entrenamiento, el texto de validación o prueba podría contener caracteres que nunca aparecieron en ella, y la codificación de esas divisiones fallará. La solución es recopilar el conjunto de caracteres base de las tres divisiones, al tiempo que se siguen aprendiendo las estadísticas de fusión solo a partir de los datos de entrenamiento:

all_text = (
    train_text
    + val_text
    + test_text
)

base_tokens = sorted(
    set(all_text)
)

Esto no filtra ningún patrón de lenguaje de validación en las fusiones. Solo garantiza que cada carácter en el conjunto de datos tenga un ID. Para respaldarlo, el entrenador dispone de un argumento opcional base_tokens que se utiliza en lugar de los caracteres del texto de entrenamiento cuando se proporciona:

@classmethod
def train(
    cls,
    text,
    target_vocab_size,
    minimum_pair_frequency=2,
    base_tokens=None,
    verbose=True,
):
    if base_tokens is None:
        base_tokens = sorted(
            set(text)
        )
    else:
        base_tokens = sorted(
            set(base_tokens)
        )

Midiendo la compresión obtenida

Para saber qué ahorro le brinda BPE, compare el conteo de tokens en el mismo pasaje. Tome una muestra:

sample_text = train_text[:10_000]

El recuento de tokens de su carácter es simplemente su longitud:

character_count = len(sample_text)

El número de tokens BPE es la longitud de la lista de IDs codificados:

bpe_ids = tokenizer.encode(
    sample_text
)

bpe_count = len(bpe_ids)

La relación entre ellos es el factor de compresión:

compression_factor = (
    character_count / bpe_count
)

Un resultado podría verse así:

Character tokens: 10,000
BPE tokens: 4,250
Compression: 2.35×

Con un factor de compresión de 2,35, un bloque de 128 tokens BPE abarca aproximadamente la misma cantidad de texto que 301 tokens de caracteres. Sin embargo, la compresión no es uniforme: la prosa llena de palabras comunes se comprime bien, mientras que los números, los nombres poco comunes y la puntuación inusual se comprimen mal.

Analizando lo que aprendió el tokenizador

Los tokens aprendidos son todo lo que está en el vocabulario después de los caracteres básicos. Al ordenarlos por longitud e imprimir los más largos, junto con sus IDs, se obtiene una idea rápida de qué considera frecuente el tokenizador:

learned_tokens = (
    tokenizer.tokens[
        len(tokenizer.base_tokens):
    ]
)

longest_tokens = sorted(
    learned_tokens,
    key=len,
    reverse=True,
)

for token in longest_tokens[:50]:
    print(
        repr(token),
        tokenizer.stoi[token],
    )

Por lo general, verá tokens de esta forma:

" the"
"ing"
"tion"
" of"
"ed"
" and"

La lista exacta depende de la muestra de entrenamiento, el preprocesamiento, el tamaño del vocabulario objetivo, la frecuencia mínima y las reglas para resolver empates. En cuanto a las reglas para resolver empates: Counter.most_common devuelve pares con frecuencia igual en el orden en que se contaron por primera vez, por lo que los resultados son deterministas para una entrada dada, pero cambiar la muestra puede reordenar cuál de los varios pares empatados gana.

Re-codificación del conjunto de datos

Con un tokenizador entrenado, codifique las tres divisiones:

train_bpe_ids = tokenizer.encode(
    train_text
)

val_bpe_ids = tokenizer.encode(
    val_text
)

test_bpe_ids = tokenizer.encode(
    test_text
)

Convierta las listas de ID en tensores long, que es el tipo que esperan las capas de embedding para los índices:

train_tensor = torch.tensor(
    train_bpe_ids,
    dtype=torch.long,
)

val_tensor = torch.tensor(
    val_bpe_ids,
    dtype=torch.long,
)

test_tensor = torch.tensor(
    test_bpe_ids,
    dtype=torch.long,
)

Guárdalos en su propio directorio:

data/
└── encoded_bpe/
    ├── train_ids.pt
    ├── val_ids.pt
    └── test_ids.pt

Deje los archivos codificados en caracteres sin tocarlos; mantener ambos facilita las comparaciones posteriores y evita que un modelo reciba los IDs del otro.

Validación de las divisiones codificadas

Primero, cada ID debe estar dentro del rango que va desde cero hasta, pero excluyendo, el tamaño del vocabulario:

for split_name, token_ids in [
    ("train", train_tensor),
    ("validation", val_tensor),
    ("test", test_tensor),
]:
    assert token_ids.min().item() >= 0

    assert (
        token_ids.max().item()
        < tokenizer.vocab_size
    )

    print(
        split_name,
        token_ids.shape,
        token_ids.min().item(),
        token_ids.max().item(),
    )

En segundo lugar, una muestra de cada división debe regresar al texto original después de un viaje de ida y vuelta:

for text in [
    train_text[:1_000],
    val_text[:1_000],
    test_text[:1_000],
]:
    encoded = tokenizer.encode(text)
    decoded = tokenizer.decode(encoded)

    assert decoded == text

Integración de BPE en el modelo

La arquitectura del modelo no requiere cambios estructurales. Solo cambia el tamaño del vocabulario en la configuración:

config = MiniGPTConfig(
    vocab_size=bpe_tokenizer.vocab_size,
    block_size=128,
    embedding_dim=128,
    num_heads=4,
    num_layers=4,
    expansion_factor=4,
    dropout=0.1,
)

Dirija la carga de datos a los nuevos archivos:

train_ids = torch.load(
    "data/encoded_bpe/train_ids.pt"
)

A partir de ahora, el modelo predice tokens BPE en lugar de caracteres.

Los puntos de control antiguos no se cargarán

Un modelo de caracteres suele tener un vocabulario en este rango:

100 to 300 character tokens

Un modelo BPE suele tener algo como esto:

500 to 2,000 subword tokens

Tanto la incrustación de tokens como la cabeza de salida dependen del tamaño del vocabulario, por lo que sus formas cambian. Un punto de control por carácter tiene:

token embedding: [character_vocab_size, C]

Mientras que el modelo BPE espera:

token embedding: [bpe_vocab_size, C]

Aquí C representa la dimensión de incrustación. Las formas no coinciden, por lo que los pesos antiguos no pueden cargarse directamente. Incluso si fuera posible, los IDs significan cosas diferentes en cada tokenizador. Es necesario entrenar un modelo nuevo, y resulta útil mantener separados los puntos de control:

checkpoints_char/
checkpoints_bpe/

O, de forma equivalente:

checkpoints/char/
checkpoints/bpe/

Identificación del tokenizador

Una discrepancia en la forma es evidente; emparejar un punto de control con un tokenizador BPE diferente del mismo tamaño es silencioso, ya que cada identificador simplemente se asigna al texto incorrecto. La huella digital hasha una forma JSON compacta de los tokens básicos y se combina con SHA-256, de modo que cualquier cambio genera un nuevo valor.

import hashlib


def tokenizer_fingerprint(tokenizer):
    payload = json.dumps(
        {
            "base_tokens": (
                tokenizer.base_tokens
            ),
            "merges": [
                list(pair)
                for pair in tokenizer.merges
            ],
        },
        ensure_ascii=False,
        separators=(",", ":"),
    ).encode("utf-8")

    return hashlib.sha256(
        payload
    ).hexdigest()

Almacene la huella digital dentro de cada punto de control:

checkpoint[
    "tokenizer_fingerprint"
] = tokenizer_fingerprint(
    tokenizer
)

Al cargar, calcule la huella digital del tokenizador activo y niegue continuar si no coincide con la que se guardó con el punto de control.

La limitación de rendimiento que debe conocer

El codificador vuelve a aplicar cada regla de fusión en toda la secuencia de tokens:

for pair in self.merges:
    tokens = merge_pair(...)

Eso significa un proceso completo sobre el texto por cada fusión, por lo que los textos largos con miles de fusiones se codifican lentamente. La implementación prioriza deliberadamente la claridad, la corrección y la posibilidad de inspección sobre la velocidad. Los tokenizadores de producción dividen el texto en palabras de antemano, almacenan en caché las segmentaciones repetidas y aplican las fusiones según su importancia; para fines de aprendizaje, gana la versión transparente.

El programa completo

Reúna todo en un único script:

build_bpe_tokenizer.py

El programa completo que se muestra a continuación combina los auxiliares para pares, la clase tokenizador, los auxiliares de validación y un punto de entrada en la línea de comandos. Hay dos detalles que difieren de los fragmentos anteriores: ahora al guardar se almacena la huella digital y se escribe a través de un archivo temporal que se renombra sobre el destino, de modo que una ejecución interrumpida nunca deje un archivo incompleto; además, el entrenamiento verifica que el vocabulario base cubra el texto de entrenamiento. La función principal entrena con el prefijo configurado, guarda y vuelve a cargar el tokenizador, comprueba las huellas digitales, realiza pruebas de ida y vuelta y guarda cada división, valida los rangos de ID e imprime estadísticas.

Una línea en el bucle de “tokens aprendidos más largos” parece haberse dañado: f"{loaded_tokenizer.stoi5d} " generará un AttributeError, ya que stoi5d no existe. La expresión pretendida es casi con certeza f"{loaded_tokenizer.stoi[token]:5d} ", la cual imprime el ID de cada token relleno hasta cinco caracteres. Corrija esa línea antes de ejecutar el script.

import argparse
import hashlib
import json
from collections import Counter
from pathlib import Path

import torch


# ==================================================
# Pair Operations
# ==================================================

def count_pairs(tokens):
    return Counter(
        zip(tokens[:-1], tokens[1:])
    )


def merge_pair(
    tokens,
    pair,
    merged_token,
):
    output = []
    index = 0

    while index < len(tokens):
        if (
            index < len(tokens) - 1
            and tokens[index] == pair[0]
            and tokens[index + 1] == pair[1]
        ):
            output.append(merged_token)
            index += 2
        else:
            output.append(tokens[index])
            index += 1

    return output


# ==================================================
# BPE Tokenizer
# ==================================================

class BPETokenizer:
    def __init__(
        self,
        base_tokens,
        merges,
    ):
        self.base_tokens = list(
            base_tokens
        )

        self.merges = [
            tuple(pair)
            for pair in merges
        ]

        self.tokens = list(
            self.base_tokens
        )

        for left, right in self.merges:
            merged_token = left + right

            if merged_token not in self.tokens:
                self.tokens.append(
                    merged_token
                )

        self.stoi = {
            token: index
            for index, token
            in enumerate(self.tokens)
        }

        self.itos = {
            index: token
            for token, index
            in self.stoi.items()
        }

        self.vocab_size = len(
            self.tokens
        )

    @classmethod
    def train(
        cls,
        text,
        target_vocab_size,
        minimum_pair_frequency=2,
        base_tokens=None,
        verbose=True,
    ):
        if not text:
            raise ValueError(
                "Training text cannot be empty"
            )

        if base_tokens is None:
            base_tokens = sorted(
                set(text)
            )
        else:
            base_tokens = sorted(
                set(base_tokens)
            )

        missing_characters = (
            set(text) - set(base_tokens)
        )

        if missing_characters:
            raise ValueError(
                "Base vocabulary is missing "
                "training characters"
            )

        if (
            target_vocab_size
            < len(base_tokens)
        ):
            raise ValueError(
                "target_vocab_size cannot be "
                "smaller than the number of "
                "base tokens"
            )

        working_tokens = list(text)
        vocabulary = set(base_tokens)
        merges = []

        print(
            "Initial characters:",
            len(working_tokens),
        )

        print(
            "Base vocabulary size:",
            len(base_tokens),
        )

        while (
            len(vocabulary)
            < target_vocab_size
        ):
            pair_counts = count_pairs(
                working_tokens
            )

            if not pair_counts:
                print(
                    "No adjacent pairs remain."
                )
                break

            best_pair, frequency = (
                pair_counts.most_common(1)[0]
            )

            if (
                frequency
                < minimum_pair_frequency
            ):
                print(
                    "Stopping because the most "
                    "frequent pair occurs only "
                    f"{frequency} time(s)."
                )
                break

            merged_token = "".join(
                best_pair
            )

            working_tokens = merge_pair(
                tokens=working_tokens,
                pair=best_pair,
                merged_token=merged_token,
            )

            merges.append(best_pair)
            vocabulary.add(merged_token)

            merge_number = len(merges)

            if (
                verbose
                and (
                    merge_number <= 20
                    or merge_number % 100 == 0
                )
            ):
                print(
                    f"Merge {merge_number:4d}: "
                    f"{best_pair!r} "
                    f"→ {merged_token!r}, "
                    f"frequency={frequency:,}, "
                    f"sequence length="
                    f"{len(working_tokens):,}, "
                    f"vocabulary="
                    f"{len(vocabulary):,}"
                )

        tokenizer = cls(
            base_tokens=base_tokens,
            merges=merges,
        )

        print(
            "Final vocabulary size:",
            tokenizer.vocab_size,
        )

        print(
            "Final training sequence length:",
            len(working_tokens),
        )

        return tokenizer

    @classmethod
    def from_file(cls, path):
        with open(
            path,
            "r",
            encoding="utf-8",
        ) as file:
            data = json.load(file)

        return cls(
            base_tokens=data[
                "base_tokens"
            ],
            merges=data["merges"],
        )

    def encode_tokens(self, text):
        unknown_characters = sorted({
            char
            for char in text
            if char not in self.stoi
        })

        if unknown_characters:
            raise ValueError(
                "Text contains characters "
                "missing from the base vocabulary: "
                f"{unknown_characters}"
            )

        tokens = list(text)

        for left, right in self.merges:
            tokens = merge_pair(
                tokens=tokens,
                pair=(left, right),
                merged_token=left + right,
            )

        return tokens

    def encode(self, text):
        return [
            self.stoi[token]
            for token in self.encode_tokens(
                text
            )
        ]

    def decode(self, token_ids):
        return "".join(
            self.itos[int(token_id)]
            for token_id in token_ids
        )

    def save(self, path):
        path = Path(path)

        path.parent.mkdir(
            parents=True,
            exist_ok=True,
        )

        data = {
            "type": "character_bpe",
            "base_tokens": (
                self.base_tokens
            ),
            "merges": [
                [left, right]
                for left, right
                in self.merges
            ],
            "vocab_size": (
                self.vocab_size
            ),
            "fingerprint": (
                self.fingerprint()
            ),
        }

        temporary_path = path.with_suffix(
            ".tmp"
        )

        with open(
            temporary_path,
            "w",
            encoding="utf-8",
        ) as file:
            json.dump(
                data,
                file,
                ensure_ascii=False,
                indent=2,
            )

        temporary_path.replace(path)

    def fingerprint(self):
        payload = json.dumps(
            {
                "base_tokens": (
                    self.base_tokens
                ),
                "merges": [
                    list(pair)
                    for pair in self.merges
                ],
            },
            ensure_ascii=False,
            separators=(",", ":"),
        ).encode("utf-8")

        return hashlib.sha256(
            payload
        ).hexdigest()


# ==================================================
# Validation
# ==================================================

def validate_round_trip(
    tokenizer,
    text,
    split_name,
):
    encoded = tokenizer.encode(text)
    decoded = tokenizer.decode(encoded)

    if decoded != text:
        raise AssertionError(
            f"Round-trip failed for "
            f"{split_name}"
        )

    print(
        f"{split_name} round-trip passed: "
        f"{len(text):,} characters "
        f"→ {len(encoded):,} tokens"
    )

    return encoded


def save_token_tensor(
    token_ids,
    path,
):
    path = Path(path)

    path.parent.mkdir(
        parents=True,
        exist_ok=True,
    )

    tensor = torch.tensor(
        token_ids,
        dtype=torch.long,
    )

    temporary_path = path.with_suffix(
        ".tmp"
    )

    torch.save(
        tensor,
        temporary_path,
    )

    temporary_path.replace(path)

    return tensor


# ==================================================
# Arguments
# ==================================================

def parse_args():
    parser = argparse.ArgumentParser(
        description=(
            "Train a simple educational BPE "
            "tokenizer on WikiText-2"
        )
    )

    parser.add_argument(
        "--processed-dir",
        type=str,
        default="data/processed",
    )

    parser.add_argument(
        "--tokenizer-output",
        type=str,
        default=(
            "tokenizer/bpe_tokenizer.json"
        ),
    )

    parser.add_argument(
        "--encoded-output-dir",
        type=str,
        default="data/encoded_bpe",
    )

    parser.add_argument(
        "--target-vocab-size",
        type=int,
        default=500,
    )

    parser.add_argument(
        "--training-characters",
        type=int,
        default=500_000,
    )

    parser.add_argument(
        "--minimum-pair-frequency",
        type=int,
        default=2,
    )

    return parser.parse_args()


# ==================================================
# Main Program
# ==================================================

def main():
    args = parse_args()

    processed_dir = Path(
        args.processed_dir
    )

    train_path = (
        processed_dir
        / "wikitext2_train_clean.txt"
    )

    val_path = (
        processed_dir
        / "wikitext2_val_clean.txt"
    )

    test_path = (
        processed_dir
        / "wikitext2_test_clean.txt"
    )

    for path in [
        train_path,
        val_path,
        test_path,
    ]:
        if not path.exists():
            raise FileNotFoundError(
                f"Required file not found: "
                f"{path}"
            )

    train_text = train_path.read_text(
        encoding="utf-8"
    )

    val_text = val_path.read_text(
        encoding="utf-8"
    )

    test_text = test_path.read_text(
        encoding="utf-8"
    )

    print(
        "Training characters:",
        len(train_text),
    )

    print(
        "Validation characters:",
        len(val_text),
    )

    print(
        "Test characters:",
        len(test_text),
    )

    all_characters = sorted(
        set(
            train_text
            + val_text
            + test_text
        )
    )

    training_character_count = min(
        args.training_characters,
        len(train_text),
    )

    bpe_training_text = train_text[
        :training_character_count
    ]

    print(
        "\nBPE training characters:",
        len(bpe_training_text),
    )

    tokenizer = BPETokenizer.train(
        text=bpe_training_text,
        target_vocab_size=(
            args.target_vocab_size
        ),
        minimum_pair_frequency=(
            args.minimum_pair_frequency
        ),
        base_tokens=all_characters,
        verbose=True,
    )

    tokenizer.save(
        args.tokenizer_output
    )

    print(
        "\nTokenizer saved to:",
        args.tokenizer_output,
    )

    print(
        "Tokenizer fingerprint:",
        tokenizer.fingerprint(),
    )

    loaded_tokenizer = (
        BPETokenizer.from_file(
            args.tokenizer_output
        )
    )

    assert (
        loaded_tokenizer.fingerprint()
        == tokenizer.fingerprint()
    )

    print(
        "Tokenizer reload test passed."
    )

    print(
        "\nEncoding dataset splits..."
    )

    train_ids = validate_round_trip(
        tokenizer=loaded_tokenizer,
        text=train_text,
        split_name="Training",
    )

    val_ids = validate_round_trip(
        tokenizer=loaded_tokenizer,
        text=val_text,
        split_name="Validation",
    )

    test_ids = validate_round_trip(
        tokenizer=loaded_tokenizer,
        text=test_text,
        split_name="Test",
    )

    output_dir = Path(
        args.encoded_output_dir
    )

    train_tensor = save_token_tensor(
        train_ids,
        output_dir / "train_ids.pt",
    )

    val_tensor = save_token_tensor(
        val_ids,
        output_dir / "val_ids.pt",
    )

    test_tensor = save_token_tensor(
        test_ids,
        output_dir / "test_ids.pt",
    )

    for split_name, tensor in [
        ("Training", train_tensor),
        ("Validation", val_tensor),
        ("Test", test_tensor),
    ]:
        assert tensor.min().item() >= 0

        assert (
            tensor.max().item()
            < loaded_tokenizer.vocab_size
        )

        print(
            f"{split_name} tensor: "
            f"shape={tuple(tensor.shape)}, "
            f"min={tensor.min().item()}, "
            f"max={tensor.max().item()}"
        )

    character_tokens = len(
        train_text
    )

    bpe_tokens = len(
        train_ids
    )

    compression_factor = (
        character_tokens / bpe_tokens
    )

    reduction_percentage = (
        1.0
        - bpe_tokens / character_tokens
    ) * 100.0

    print(
        "\nCharacter token count:",
        f"{character_tokens:,}",
    )

    print(
        "BPE token count:",
        f"{bpe_tokens:,}",
    )

    print(
        "Compression factor:",
        f"{compression_factor:.2f}x",
    )

    print(
        "Token-count reduction:",
        f"{reduction_percentage:.2f}%",
    )

    learned_tokens = (
        loaded_tokenizer.tokens[
            len(
                loaded_tokenizer.base_tokens
            ):
        ]
    )

    longest_tokens = sorted(
        learned_tokens,
        key=len,
        reverse=True,
    )

    print("\nLongest learned tokens:")

    for token in longest_tokens[:30]:
        print(
            f"{loaded_tokenizer.stoi5d} "
            f"{token!r}"
        )

    sample = (
        "Artificial intelligence "
        "and machine learning"
    )

    unknown = [
        char
        for char in sample
        if char not in (
            loaded_tokenizer.stoi
        )
    ]

    if not unknown:
        sample_tokens = (
            loaded_tokenizer.encode_tokens(
                sample
            )
        )

        sample_ids = (
            loaded_tokenizer.encode(
                sample
            )
        )

        print("\nSample text:")
        print(sample)

        print("\nSample BPE tokens:")
        print(sample_tokens)

        print("\nSample token IDs:")
        print(sample_ids)

        print("\nDecoded sample:")
        print(
            loaded_tokenizer.decode(
                sample_ids
            )
        )

    print(
        "\nBPE tokenizer pipeline "
        "completed successfully."
    )


if __name__ == "__main__":
    main()

Ejecutarlo

La configuración predeterminada es una ejecución breve, con fines educativos:

python build_bpe_tokenizer.py

Para aprender un vocabulario más amplio:

python build_bpe_tokenizer.py \
  --target-vocab-size 1000

Para entrenar con más texto también:

python build_bpe_tokenizer.py \
  --training-characters 1000000 \
  --target-vocab-size 1000

Para una prueba rápida de humo que solo confirme que el pipeline funciona:

python build_bpe_tokenizer.py \
  --training-characters 100000 \
  --target-vocab-size 300

Si su shell no admite continuaciones de línea con barra invertida, escriba cada comando en una sola línea.

Así se verá la salida

Una ejecución podría imprimir algo como esto:

Training characters: 10,000,000
Validation characters: 1,100,000
Test characters: 1,200,000

BPE training characters: 500,000
Initial characters: 500,000
Base vocabulary size: 200

Merge    1: (' ', 't') → ' t', frequency=19,842
Merge    2: ('h', 'e') → 'he', frequency=17,415
Merge    3: (' t', 'he') → ' the', frequency=14,108
...
Final vocabulary size: 500

Las primeras fusiones son exactamente lo que uno esperaría en inglés: un espacio seguido de t, luego he, y después esos dos caracteres combinados en " the". Después de la codificación, el resumen se parece a lo siguiente:

Character token count: 10,000,000
BPE token count: 4,500,000
Compression factor: 2.22x
Token-count reduction: 55.00%

Sus números variarán. Tenga en cuenta que procesar todo el conjunto de entrenamiento con este codificador puede dominar el tiempo de ejecución en vocabularios más grandes.

¿Qué cambia en el modelo y su ventana de contexto?

Cuentas de tokens en una oración real

Tome esta oración:

The history of artificial intelligence

Un tokenizador de caracteres genera aproximadamente esta cantidad de tokens:

38 tokens

Un tokenizador BPE podría generar tokens de palabras completas como estos:

["The", " history", " of", " artificial", " intelligence"]

O podría generar fragmentos más pequeños, dependiendo de las fusiones que haya aprendido. En cualquier caso, la cantidad se sitúa en un rango similar a:

5 to 15 tokens

La segmentación no se diseña a mano; surge de las estadísticas del corpus de entrenamiento.

Más texto por ventana de contexto

Supongamos que el modelo utiliza este tamaño de bloque:

block_size = 128

Con caracteres, 128 tokens abarcan aproximadamente 128 caracteres. Con BPE, los mismos 128 tokens pueden cubrir varios cientos de caracteres. El modelo percibe más contexto lingüístico sin que la computación de atención aumente en tamaño. Esto es importante porque el costo de la autoatención crece aproximadamente con:

T²

Aquí T representa la longitud de la secuencia; por lo tanto, duplicar T hace que el costo de atención se multiplique aproximadamente por cuatro, mientras que una mejor tokenización permite incluir más texto en el mismo T.

El compromiso entre el tamaño del vocabulario

Un vocabulario BPE más grande generalmente implica secuencias más cortas, pero también hace que ciertas matrices sean más grandes. La tabla de incrustación de tokens alberga tantos parámetros:

vocab_size × embedding_dim

Con el atado de pesos, la cabeza de salida reutiliza la misma matriz en lugar de añadir una segunda. Compare un vocabulario pequeño:

Vocabulary:          500
Embedding dimension: 128
Embedding parameters: 64,000

Con uno más grande:

Vocabulary: 8,000
Embedding dimension: 128
Embedding parameters: 1,024,000

Por lo tanto, un vocabulario más grande acorta las secuencias, pero aumenta los parámetros de embedding, el costo de los logits de salida, el uso de memoria y el tiempo de entrenamiento del tokenizador. Cada token adicional también necesita suficientes apariciones para aprender un buen embedding; así que en un corpus del tamaño de WikiText-2, un vocabulario enorme genera muchos tokens apenas entrenados. El tamaño adecuado depende del conjunto de datos y del modelo.

Reentrenar el modelo con tokens BPE

Actualice el script de entrenamiento para cargar el archivo del tokenizador:

tokenizer/bpe_tokenizer.json

Y las divisiones codificadas:

data/encoded_bpe/train_ids.pt
data/encoded_bpe/val_ids.pt
data/encoded_bpe/test_ids.pt

Luego, construya la configuración a partir del tamaño del vocabulario del tokenizador:

config = MiniGPTConfig(
    vocab_size=bpe_tokenizer.vocab_size,
    block_size=128,
    embedding_dim=128,
    num_heads=4,
    num_layers=4,
    expansion_factor=4,
    dropout=0.1,
)

Y escriba puntos de control en su propio directorio:

checkpoints/bpe/

Aparte de estos cambios, el bucle de entrenamiento puede permanecer esencialmente igual que antes.

Comparar los dos modelos de manera justa

Para una comparación significativa, mantenga fijas, en la medida de lo posible, la arquitectura (ancho, profundidad, cabezas), los datos, el optimizador, el cronograma, el presupuesto de entrenamiento y el método de evaluación. Incluso así, comparar en pasos de entrenamiento iguales es engañoso, ya que un lote de tokens BPE contiene considerablemente más texto que un lote de caracteres. Mejores criterios para la comparación son:

Total characters processed
Total bytes processed
Training time
Compute budget

La perplejidad se calcula por unidad de predicción, por lo que las perplejidades por carácter y BPE no pueden compararse directamente. Para obtener un valor comparable, convierta ambos a bits por carácter; de lo contrario, evalúe cada modelo según la calidad de generación, calcule el costo y las métricas adecuadas para el tokenizador.

Errores comunes

Crear un tokenizador separado para cada división

Esto está mal:

Train tokenizer from training text
Validation tokenizer from validation text
Test tokenizer from test text

Los datos de entrenamiento, validación y prueba deben ser codificados todos por el mismo tokenizador; de lo contrario, el mismo ID significará cosas diferentes en las distintas divisiones.

Aplicación de fusiones fuera de orden

Las fusiones se basan unas en otras, por lo que deben ejecutarse exactamente en el orden en que se aprendieron:

for pair in self.merges:
    apply(pair)

Ordenar las reglas, por ejemplo de forma alfabética para mejorar la legibilidad, cambia la forma en que se segmenta el texto.

Sobrescribir los archivos codificados con caracteres

Mantenga los IDs BPE en su propio directorio:

data/encoded_bpe/

Y deje los IDs de los caracteres donde estaban:

data/encoded/

Aprendizaje de fusiones a partir de texto de validación

Las estadísticas de fusión deben provenir únicamente del texto de entrenamiento. Es aceptable tomar el conjunto básico de caracteres de cada división para garantizar cobertura, pero las frecuencias de fusión deben reflejar exclusivamente el corpus de entrenamiento; de lo contrario, las cifras de validación serán optimistas.

Expectativa de que los tokens aprendidos sean palabras completas

BPE aprende con facilidad fragmentos como estos:

"ing"
"tion"
" th"
" arti"

Los tokens subpalabra siguen la frecuencia, no los límites entre palabras definidos por humanos, y ese es un comportamiento esperado.

Suponer que un vocabulario más grande siempre es mejor

Los vocabularios más grandes aumentan los costos de los parámetros y generan tokens raros y poco entrenados. Pruebe varios tamaños y mida los resultados. De igual manera, nunca compare la perplejidad bruta entre diferentes tokenizadores.

Ejercicios

  1. Compare tres tamaños de vocabulario. Entrene con los tamaños indicados a continuación y compare el tiempo de entrenamiento, la longitud de las secuencias, la compresión, los tokens más largos y el tamaño de los embeddings.
300 tokens
500 tokens
1,000 tokens
  1. Estudia las fusiones iniciales. Imprime las primeras 50 reglas de fusión. ¿Aparecen patrones de espacios en blanco? ¿Sufijos comunes? ¿Combinaciones de puntuación? ¿Qué fusiones te sorprenden?
  2. Tokeniza una oración de ambas maneras. Codifica la siguiente oración tanto con el tokenizador de caracteres como con el tokenizador BPE, y compara los tokens, los IDs, el conteo de tokens y el resultado decodificado.
Artificial intelligence is transforming technology.
  1. Pruebe palabras poco comunes. Codifique las siguientes palabras y observe cómo BPE reúne palabras desconocidas a partir de fragmentos conocidos.
electromagnetism
neurocomputational
uncharacteristically
  1. Mida la cobertura del contexto. Decodifique los primeros 128 tokens de cada tokenizador y observe cuánto texto abarca cada ventana.
  2. Entrene un modelo BPE. Entrene con el directorio indicado a continuación y compare sus muestras con el modelo de caracteres utilizando la misma capacidad de cálculo.
data/encoded_bpe/
  1. Aplicar huellas dactilares. Guardar la huella dactilar del tokenizador en cada punto de control y negarse a ejecutar la inferencia cuando el punto de control y el tokenizador activo difieran.
  2. Acelerar la codificación. Sustituir el codificador que realiza una pasada por fusión por uno más rápido, utilizando rangos de fusión, pilas, listas de tokens enlazadas, cachés por palabra o métodos de prueba.

Reglas de tokenización que vale la pena mantener

  • Las subpalabras se sitúan entre caracteres flexibles pero largos y palabras compactas pero frágiles.
  • Un tokenizador BPE está completamente definido por sus tokens base más sus fusiones ordenadas; guardar y registrar exactamente eso.
  • La codificación reproduce las fusiones en orden, la decodificación las concatena, y los viajes de ida y vuelta son sin pérdidas.
  • Un único tokenizador sirve para cada división, y las fusiones se aprenden únicamente a partir de los datos de entrenamiento.
  • Un nuevo tokenizador implica nuevos archivos de tokens, un tamaño de vocabulario diferente y un modelo recién entrenado.
  • El tamaño del vocabulario permite secuencias más cortas a cambio de capas de embedding y de salida más grandes, y la perplejidad no se transfiere entre tokenizadores.
  • El proceso completo, desde el texto en bruto hasta un modelo reentrenado, se ve así:

    WikiText training text
    → character vocabulary
    → pair frequencies
    → repeated merge operations
    → BPE vocabulary and merge rules
    → encode all splits
    → save token tensors
    → train a new Mini-GPT
    

    El punto principal es que la tokenización no es un detalle de preprocesamiento. Determina qué unidades aprende el modelo, cuánto texto cabe en su ventana de contexto y cuán grande es el problema de predicción de salida. Una vez que un GPT pequeño funciona con tokens BPE, el siguiente paso lógico es observar cómo la misma mecánica de generación de tokens se desarrolla hasta convertirse en sistemas modernos de IA generativa:

    Next-token predictor
    → pretrained language model
    → instruction-following model
    → retrieval-augmented application
    → tool-using GenAI system
    

    Leer más

  • De GPT-1 a los modelos de razonamiento: Qué cambió cada generación para los desarrolladores: Sigue la evolución de la familia GPT desde el preentrenamiento de 2018 hasta los modelos de razonamiento, observa qué ideas aportó cada generación y utiliza la visión GPT-4o y las salidas estructuradas desde Python.
  • Entrenar un pequeño LLM desde cero en un portátil con 8 GB de RAM: Cómo configurar un pipeline de datos adecuado para entrenar un modelo transformer en hardware modesto: tokenizador BPE, TinyStories, tokens almacenados en disco y agrupación de tokens siguientes.