Tokenización: palabras, caracteres, subpalabras

Tokenización: palabras, caracteres, subpalabras

20 min read

La lección anterior, dedicada al problema de representar el lenguaje, terminó con una cuestión pendiente: allí escribimos el vocabulario VV como si sus elementos vinieran dados. No vienen dados. Esa lista hay que construirla, y construirla obliga a decidir antes dónde termina una unidad de texto y dónde empieza la siguiente —una decisión que nadie toma por ti y que el idioma no trae resuelta—. A eso se le llama tokenización, y a cada unidad resultante, un token.

Tokenizar es elegir dónde cortar, y hay tres respuestas naturales. Cortar por palabras, que es como lees tú. Cortar por caracteres, que es como está escrito el fichero. O cortar por trozos de palabra, por subpalabras: una solución intermedia que puede parecer poco intuitiva al principio, pero que hoy emplean la mayoría de los modelos de lenguaje modernos. El explorable de abajo corta una misma frase con las tres y pone, junto a cada corte, cuántos tokens le hacen falta para escribir ese mismo texto. Escribe una frase tuya y compara esos tres números.

La misma frase, tres cortes. Mira cuántos tokens produce cada uno y qué le pasa a una palabra larga como «programación».

Cada corte produce un vocabulario distinto y secuencias de longitudes muy variables. Eso es lo que hay que retener antes de seguir: no hay una tokenización verdadera esperando a ser descubierta.

El compromiso entre el tamaño del vocabulario y la longitud de la secuencia

Fijemos primero el alfabeto Σ\Sigma: el conjunto de caracteres que pueden aparecer. Para el español son las letras con sus acentos, la ñ, los dígitos, la puntuación y el espacio.

Ese conjunto tiene una trampa que conviene desactivar antes de contar nada. Unicode admite dos escrituras para la misma letra acentuada: la ñ puede ser un único carácter, o una n seguida de una tilde que se dibuja encima de la anterior. En la pantalla se ven igual; para Σ\Sigma son cosas distintas, y una de las dos ocupa dos posiciones en lugar de una. Por eso el texto se normaliza antes de tokenizar, a la forma en que cada letra acentuada es un solo carácter —la forma de normalización C (Normalization Form C, NFC). Sin ese paso, dos textos idénticos a la vista producen tokenizaciones distintas, y el vocabulario acaba conteniendo tildes sueltas.

Un texto no es un carácter, sino una sucesión de ellos, y para hablar de «todas las sucesiones que se pueden escribir con un conjunto de símbolos» hay una notación estándar que usaremos a partir de aquí. Se escribe con un asterisco: Σ\Sigma^{*} es el conjunto de todas las cadenas finitas formadas con caracteres de Σ\Sigma, incluida la cadena vacía. Tanto ñ como niño como El niño juega. son elementos de Σ\Sigma^{*}; el asterisco no dice nada sobre la longitud, solo que es finita. Cuando la cadena vacía estorba se escribe Σ+\Sigma^{+}, que es lo mismo excluyéndola. Y el asterisco vale para cualquier conjunto, no solo para el alfabeto: VV^{*} son las secuencias finitas de tokens de VV.

Con eso, un texto es un elemento de Σ\Sigma^{*} y un tokenizador es una función que lo convierte en una secuencia de elementos del vocabulario:

τ:ΣV,VΣ+.\tau : \Sigma^{*} \to V^{*}, \qquad V \subset \Sigma^{+}.

Cada token es, por tanto, una cadena no vacía de caracteres, y el vocabulario VV no se declara a mano: lo induce τ\tau al recorrer un corpus, la colección de textos que se reúne de antemano para construirlo.

De ahí sale algo que el resto del bloque da por sabido. Cada entrada de VV es un token, y qué sea depende de τ\tau: con un tokenizador de palabras las entradas son palabras enteras, y con uno de subpalabras son trozos de palabra, de modo que dámelo puede ocupar tres. Una entrada del vocabulario no es, por tanto, lo mismo que una palabra.

A τ\tau le pedimos dos cosas. Determinismo, por la misma razón que en la lección anterior, sobre representar el lenguaje: si τ\tau cortara hoy distinto que ayer, lo que el modelo aprendió dejaría de corresponderse con lo que recibe. Y reconstrucción: concatenar los tokens tiene que devolver el texto de partida, salvo el convenio que se haya adoptado con los espacios. Es lo que separa cortar de limpiar. Un tokenizador que de paso baja todo a minúsculas o borra las tildes ya no divide el texto, lo altera: después de eso papá y papa son la misma unidad, y la distinción se perdió antes de que el modelo llegara a verla. Todo lo que τ\tau descarta, el modelo no volverá a verlo jamás.

Ahora el compromiso, que se deriva de contar caracteres. Toma un corpus con CC caracteres en total sumando todos sus documentos, y aplícale una tokenización τ\tau, que lo parte en TT tokens u1,,uTu_1, \dots, u_T. Denotaremos por u\lvert u \rvert la longitud, en caracteres, del token uu. Los tokens son trozos del propio texto, así que si τ\tau conserva todos los caracteres —la condición de reconstrucción sin convenios— sus longitudes suman el texto entero:

i=1Tui=C.\sum_{i=1}^{T} \lvert u_i \rvert = C.

Si descarta los espacios, la suma se queda un poco por debajo de CC, y lo verás en la celda.

Llamemos ˉ\bar{\ell} a la longitud media de un token:

ˉ=1Ti=1Tui.\bar{\ell} = \frac{1}{T}\sum_{i=1}^{T} \lvert u_i \rvert.

El sumatorio que aparece ahí es el mismo que acabamos de igualar a CC, así que

ˉ=CT,o lo que es igual,T=Cˉ.\bar{\ell} = \frac{C}{T}, \qquad \text{o lo que es igual,} \qquad T = \frac{C}{\bar{\ell}}.

Esta igualdad resume la primera mitad del compromiso. El texto fija CC, así que TT y ˉ\bar{\ell} no pueden variar de manera independiente. Si eliges tokens más cortos, disminuye ˉ\bar{\ell} y la secuencia contiene más tokens; si eliges tokens más largos, ocurre lo contrario. Con una tokenización por caracteres, ˉ=1\bar{\ell} = 1 y T=CT = C. Con una tokenización por palabras en español, ˉ\bar{\ell} ronda los cinco caracteres, de modo que la secuencia es aproximadamente cinco veces más corta.

¿Y el otro extremo del compromiso, el tamaño de VV? Si fijamos una longitud \ell, el número de cadenas distintas que pueden formarse sobre un alfabeto Σ\Sigma es

Σ.\lvert \Sigma \rvert^{\ell}.

Es decir, el conjunto de candidatos a formar parte del vocabulario crece exponencialmente con la longitud de las unidades. El idioma solo utiliza una pequeña fracción de esas combinaciones, la inmensa mayoría de las cadenas de cinco letras no son palabras del español, pero la tendencia es la misma: cuanto más largas son las unidades elegidas, mayor es el espacio del que puede extraerse el vocabulario y, en consecuencia, mayor tiende a ser este.

τ\tauˉ\bar{\ell}TTV\lvert V \rvert típico
caracteres11CCdecenas
subpalabras3344C/3.5\approx C/3.530 000–50 000, elegido de antemano
palabras5\approx 5C/5\approx C/5cientos de miles, y creciendo
Por qué el vocabulario de palabras no es solo grande, sino ilimitado

La última columna dice «y creciendo», y esa es la diferencia importante. El vocabulario de caracteres se satura: los símbolos distintos del español son unas decenas, y con un par de páginas de texto ya han aparecido casi todos, de modo que leer un millón de páginas más no añade prácticamente ninguno. El de palabras no se satura nunca, y el español lo empeora.

Un verbo regular tiene del orden de cincuenta formas conjugadas —canto, cantas, cantaba, cantaríamos, cantasen— y cada una es una entrada distinta de VV que no comparte nada con las demás: el vocabulario de palabras no sabe que están emparentadas, porque su única noción de identidad es la igualdad de cadenas. Añade los clíticos pegados (cántalo, cántamelo), el género y el número (niño, niña, niños, niñas), la derivación (niñez, aniñado), los nombres propios y las palabras que se inventaron el mes pasado, y tienes una lista que crece con cada documento nuevo que leas.

Ese crecimiento sin fin es el que hace que la pregunta «¿qué hago con una palabra que no está en VV?» no sea un caso raro sino el caso normal, y es el asunto de una lección más adelante, la de vocabulario, frecuencia y palabras fuera de vocabulario.

Fijar el vocabulario y dejar que el algoritmo elija las unidades

La solución a este compromiso es la fila central de la tabla, la de las subpalabras, y su celda de V\lvert V \rvert dice en qué consiste: elegido de antemano. En lugar de dejar que el tamaño del vocabulario sea una consecuencia de la tokenización, se fija por adelantado —por ejemplo, 30 000 tokens— y se deja que un algoritmo decida qué unidades lo compondrán. El más extendido es el byte-pair encoding (BPE): empieza tratando cada carácter como una unidad y va fusionando repetidamente el par de unidades adyacentes más frecuente del corpus, hasta llegar al tamaño fijado. Las secuencias frecuentes acaban fusionadas en una sola pieza y las raras se quedan partidas en trozos que sí son frecuentes. Por eso una palabra que el algoritmo no vio jamás sigue teniendo representación: se escribe con piezas conocidas. Los detalles de BPE quedan fuera de esta lección; el explorable de arriba muestra su resultado, que es lo que hace falta aquí.

Dos tokenizadores en Python, sobre el mismo corpus

La igualdad T=C/ˉT = C / \bar{\ell} conviene verla como números y no como álgebra. La celda tokeniza un corpus corto en español de las dos formas extremas, por palabras y por caracteres, y para cada una imprime TT, el tamaño del vocabulario y la longitud media. Mira tres cosas: que TˉT \cdot \bar{\ell} reproduce CC, que los tamaños de vocabulario van en el sentido contrario a TT, y las dos últimas líneas. Ejecútala tal cual y después cambia el corpus por un texto tuyo.

import unicodedata

CORPUS = " ".join([
"El niño enseña programación en español.",
"La niña aprende programación en la escuela del barrio.",
"Dámelo, por favor: el libro es del profesor.",
"Vamos al río y luego al mar, como cada verano.",
"Los niños juegan, cantan y aprenden mientras el sol baja.",
"Enseñar no es lo mismo que aprender, aunque ocurran a la vez.",
])

def normaliza(texto):
# NFC: la ñ y las vocales acentuadas quedan como UN solo carácter
return unicodedata.normalize("NFC", texto)

def por_caracteres(texto):
return list(normaliza(texto))

def por_palabras(texto):
tokens, actual = [], ""
for c in normaliza(texto):
if c.isalnum():
actual = actual + c
continue
if actual:
tokens.append(actual)
actual = ""
if not c.isspace():
tokens.append(c) # la puntuación es un token propio
if actual:
tokens.append(actual)
return tokens

C = len(normaliza(CORPUS))
print("C =", C, "caracteres")

for nombre, tokens in [("palabras", por_palabras(CORPUS)), ("caracteres", por_caracteres(CORPUS))]:
T = len(tokens)
media = sum(len(t) for t in tokens) / T
print(nombre.rjust(10),
"| T =", str(T).rjust(3),
"| |V| =", str(len(set(tokens))).rjust(3),
"| media =", round(media, 2),
"| T x media =", round(T * media, 1))

print()
print(por_palabras("¡Dámelo! Vamos del bar al río."))

# Y lo que ocurre en cuanto llega una frase que el corpus no contenía
V = set(por_palabras(CORPUS))
nueva = por_palabras("El niño programa una criptomoneda")
print("fuera de V:", [t for t in nueva if t not in V])

La primera ejecución descarga el intérprete de Python (~15 MB). Después queda en la caché del navegador.

Con caracteres, TˉT \cdot \bar{\ell} da CC exactamente. Con palabras se queda por debajo, y la diferencia son los espacios: el tokenizador los tiró, así que ya no se puede reconstruir el texto a partir de los tokens. Es la condición de reconstrucción rota a cambio de una comodidad, y por eso los tokenizadores de subpalabras marcan de algún modo dónde estaban los espacios. El ## que ves en el explorable marca que el trozo continúa la palabra anterior —la ausencia de espacio—, y con eso el espacio se reconstruye.

La penúltima línea muestra el corte sobre una frase con clíticos y contracciones: Dámelo sale entero, del y al también, aunque cada una sea de por sí dos palabras (de el, a el). El tokenizador no lo sabe, y no necesita saberlo mientras sea coherente. La última línea es más incómoda: de los cinco tokens de una frase nueva y perfectamente corriente, tres no están en VV.

Comprueba tu intuición

Cuatro preguntas sobre lo que acabas de ver: el compromiso entre vocabulario y longitud, qué gana cortar por debajo de la palabra, la trampa que el español tiene reservada a los tokenizadores de caracteres, y una cuenta a mano.

Cambias un tokenizador de palabras por uno de caracteres, sobre el mismo corpus. ¿Qué le ocurre al tamaño del vocabulario V\lvert V \rvert y a la longitud TT de cada secuencia?

La palabra dámelo no aparece en el corpus con el que construiste VV. Sí aparecen , me y lo. ¿Qué tokenizadores pueden representarla sin tratarla como una unidad desconocida? Marca todos los que valgan.

Select every correct option. This is graded all-or-nothing: there is no partial credit.

El texto llega con la ñ escrita como dos puntos de código: una n seguida de una tilde combinante. Sin normalizar antes a la forma NFC, un tokenizador de caracteres produce dos tokens donde debería haber uno.

¿Cuántos tokens produce un tokenizador de caracteres sobre la frase El niño juega.? Cuenta también los espacios y el punto final.

A margin of ±0 is accepted.


Con la tokenización ya fijada, el vocabulario deja de ser una abstracción y pasa a ser una lista concreta de tokens. Esa lista siempre es finita, porque se construye a partir de un corpus que alguien recopiló en algún momento. Y aquí reaparece la exigencia de totalidad de la lección sobre el problema de representar el lenguaje: rr tenía que estar definida para cualquier palabra que llegue. La última línea de la celda de código enseña el agujero sin adornos, criptomoneda no está en VV, y sobre ella rr no vale nada, porque ni siquiera está definida.

Quedan entonces tres preguntas encadenadas, y son las tres de la lección siguiente. Cuántos tokens distintos aparecen de verdad en un corpus, y con qué frecuencia. La respuesta tiene una forma muy característica que decide todo lo demás. Cuáles de ellos entran en VV: guardarlos todos es caro, porque cada uno necesita su propia representación r(w)r(w), pero cada token que dejes fuera es texto que el modelo ya no puede leer. Y qué hacer cuando llega uno de los que quedaron fuera. Eso es la lección sobre vocabulario, frecuencia y el problema de las palabras fuera de vocabulario (out-of-vocabulary, OOV).

Further reading3 sources · 1 paper, 1 book, 1 interactive

Where this lesson comes from, and where to go next. None of it is needed to carry on with the course.

  • Neural Machine Translation of Rare Words with Subword Units
    paperSennrich, Haddow y Birch, 2016arXiv:1508.07909EN

    El algoritmo BPE que la lección nombra sin desarrollar: fusionar el par de piezas más frecuente hasta el vocabulario fijado. Se presenta aquí para traducción automática, no como el tokenizador general en que acabó.

  • Speech and Language Processing, cap. 2: Words and Tokens
    bookJurafsky y Martin, 3.ª ed., borrador librestanford.eduEN

    El capítulo de referencia sobre tokenización y normalización: los mismos objetos de esta lección —Unicode, NFC, BPE— con más ejemplos y menos desarrollo. El libro entero es gratis.

  • Tokenizer Playground
    interactiveTaubytetaubyte.comEN

    Pega tu texto y mira cómo lo parten modelos como GPT-4, BERT o T5, con el recuento de tokens y la razón tokens/carácter de la lección. Pruébalo en español: verás cómo las piezas se multiplican frente al inglés.