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.
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:
- Dividir el texto en caracteres básicos.
- Reproducir cada regla de fusión en el orden en que fue aprendida.
- 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
- 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
- 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?
- 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.
- 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
- Mida la cobertura del contexto. Decodifique los primeros 128 tokens de cada tokenizador y observe cuánto texto abarca cada ventana.
- 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/
- 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.
- 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.
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
- Depuración de un pequeño GPT en PyTorch: Pruebas que aíslan cada fallo: Un flujo de trabajo paso a paso para depurar un GPT a nivel de caracteres en PyTorch, desde los IDs de tokens y el desplazamiento de objetivos hasta los gradientes, las pérdidas NaN y los puntos de control.
- Construcción de un agente IA desde cero: Patrones, ReAct y LangGraph: Aprenda los conceptos fundamentales detrás de los agentes IA, la planificación, el uso de herramientas, la reflexión y el patrón ReAct, así como cómo LangChain y LangGraph se integran en la creación manual de uno.