Vocabulario, frecuencia y el problema OOV
20 min de lectura
La lección anterior, dedicada a la tokenización, terminó dejando convertido en una lista finita y, por serlo, con una limitación: tarde o temprano llega un token que no está en ella. Entender el alcance de esa limitación exige mirar el corpus desde otro ángulo. Saber qué tokens aparecen es la mitad de la información; la otra mitad, la que determina cuántos acabarán fuera del vocabulario, es con qué frecuencia aparece cada uno.
Esa distribución de frecuencias decide qué tokens merece la pena conservar, cuáles es razonable dejar fuera y, en consecuencia, cada cuánto se encontrará el modelo con palabras fuera de vocabulario (out-of-vocabulary, OOV). Tiene además una forma muy poco igualitaria, y es la misma en cualquier corpus y en cualquier idioma. Esa forma —no el tamaño del corpus, no la lengua— es la que fija cuánto texto llegas a cubrir con un vocabulario que quepa en memoria.
Cuenta los tokens de cualquier texto en español y aparece siempre la misma escena. En la cabeza, un puñado de piezas que salen sin parar: de, la, que, el, en, y la puntuación. En la cola, una multitud que aparece una sola vez en todo el texto y no vuelve: bibliotecario, criptomoneda, monasterio. En el corpus de juguete con el que trabajarás en esta lección, 237 tokens en total, el más frecuente sale 19 veces y 65 de las 104 cadenas distintas salen exactamente una vez.
Es una sola observación, y tira en dos direcciones opuestas. Las cadenas frecuentes son tan pocas que guardarlas todas sale casi gratis; las raras son tantas que guardarlas todas es imposible. Y la parte incómoda es que casi todo lo que te interesa de una frase vive en la cola: los nombres propios, los términos técnicos, las palabras que se inventaron el mes pasado.
La forma que tienen las frecuencias de un corpus
Contar exige separar dos cosas que hasta ahora iban juntas. Aplica a un corpus y obtienes una secuencia de tokens; cada uno es una ocurrencia, y las repeticiones cuentan cada vez. Agrúpalos por cadenas iguales y obtienes tipos: las cadenas distintas, contadas una sola vez. En la escuela de la plaza hay cinco tokens y cuatro tipos, porque la sale dos veces. Siempre , y en un corpus real es muchísimo menor.
Ordena ahora los tipos del más frecuente al menos frecuente y llama a la frecuencia del que ocupa la posición , de modo que . Por construcción, las frecuencias suman el corpus entero:
Ese orden revela una regularidad empírica que aparece en todos los idiomas medidos, la ley de Zipf: la frecuencia de un tipo es aproximadamente inversa a su posición,
con una constante que depende del corpus. El segundo tipo sale la mitad de veces que el primero, el décimo una décima parte. Es una aproximación, no un teorema —falla en las primeras posiciones y en la cola— pero responde la pregunta que importa: cuánto texto compras con cada tipo que añadas.
Llamemos cobertura de los tipos más frecuentes, y la escribiremos , a la fracción de ocurrencias del corpus que esos tipos explican:
Sustituyendo la aproximación de Zipf, tanto el numerador como se vuelven sumas de :
La constante se cancela, y esas sumas son sumas parciales de la serie armónica, que crecen como un logaritmo: , con . Por tanto
Por qué la suma de 1/i crece como un logaritmo
La función es decreciente, así que el rectángulo de base y altura queda por debajo del área bajo la curva entre e , y por encima del área entre e .
Suma la segunda desigualdad sobre todos los y la primera desde —el término vale y se saca aparte— y los rectángulos quedan atrapados:
Los dos extremos valen y , así que la suma es más algo que se queda entre y . Ese «algo» converge, y su límite es la constante de Euler-Mascheroni . Lo que la lección usa es la parte que crece: el logaritmo.
Y aquí está el resultado que decide el tamaño del vocabulario. Pide una cobertura y despeja . Como ronda para un corpus con cien mil tipos, es despreciable frente a él y la ecuación deja
Ese exponente lo decide todo: la cobertura no se paga en proporción, se paga en potencias. Con tipos, cubrir la mitad del corpus cuesta tipos, el 90 % cuesta , y el 99 %, . Los últimos nueve puntos de cobertura casi triplican el vocabulario.
Qué cuesta cortar el vocabulario
El vocabulario es entonces una decisión con dos precios enfrentados. Elegir como los tipos más frecuentes —o, desde otro ángulo, exigir una frecuencia mínima — es elegir dónde se para el intercambio.
El precio de subir lo pone la lección sobre el problema de representar el lenguaje: la representación es una función , y una función así se almacena como una tabla con una fila por entrada del vocabulario y columnas. Son
Con , un vocabulario de 30 000 tipos son nueve millones de números; uno de 100 000, treinta millones. La tabla crece con y nada la comprime: cada entrada es independiente de las demás.
El precio de bajar es el texto que queda al otro lado del corte, la fracción de las ocurrencias. Medida así parece pequeña, y ese es el problema: la misma decisión, medida sobre tipos, deja fuera a la mayoría del idioma. Con y pierdes el 10 % de las ocurrencias y el 68 % de los tipos. Y no son un 68 % cualquiera: los tipos que caen son los de la cola —nombres propios, tecnicismos, palabras nuevas—, aquello que hace que una frase diga algo en lugar de otra cosa. Un corte que suena barato en tokens está tirando justo la parte informativa.
El token que recoge lo que queda fuera
Queda la tercera pregunta: qué hacer cuando, ya en producción, llega una palabra de las que se quedaron fuera. El arreglo estándar es añadir al vocabulario una entrada más que no corresponde a ninguna palabra concreta y que las absorbe a todas, escrita <UNK> por unknown, desconocida:
y, antes de representar nada, cada palabra pasa por un filtro que la deja intacta o la sustituye:
Con eso, está definida para cualquier palabra que llegue. Esa era la totalidad, la primera de las tres exigencias de aquella lección sobre el problema de representar el lenguaje, y es la que este arreglo recupera. Lo que se paga es la inyectividad: criptomoneda y bibliotecario pasan a ser el mismo objeto. Aquella lección ya avisó de que ibas a renunciar a ella a propósito, y este es el sitio donde ocurre por primera vez.
Queda nombrar lo que cuesta. La proporción de texto que acaba en esa entrada es la tasa OOV, y se mide en las dos direcciones que esta lección lleva separando. Toma un texto nuevo, , y llama al conjunto de sus tipos, de modo que ; la prima marca lo medido sobre ese texto y no sobre el corpus original. Entonces
A la izquierda hay posiciones: si criptomoneda sale diez veces, aporta diez. A la derecha hay cadenas distintas, y esa misma palabra aporta una. Miden la misma decisión y dan números muy distintos. En un corpus real, un vocabulario razonable deja una tasa sobre tokens del 2 % o el 3 %, y una sobre tipos del 30 % o el 40 %. La primera es la que se cita, porque es la que tranquiliza. Es también la que engaña, y de dos maneras.
Una es aritmética. La tasa se mide por token, pero el texto se lee por frases, y una frase se salva solo si todos sus tokens están en . Con veinte tokens por frase y una tasa OOV del 2 %, eso ocurre con probabilidad : una de cada tres frases contiene alguna palabra que el modelo no puede leer. La otra manera es que los tokens que caen no son cualesquiera, sino los de la cola, los que cargan con lo que la frase dice. Un 2 % de tokens perdidos no es un 2 % de daño.
¿Y si agrando el corpus hasta que no queden palabras nuevas?
No funciona, y el motivo tiene su propia regularidad empírica, la ley de Heaps: el número de tipos crece con el número de tokens como una potencia, con una constante del corpus,
Al ser , el crecimiento se frena: duplicar el corpus multiplica los tipos por , no por dos. Pero nunca se para. Siempre hay tipos nuevos, y la proporción de los que aparecen una sola vez se mantiene alta por muchos millones de tokens que leas.
Compáralo con el vocabulario de caracteres de la lección anterior, que sí se satura con un par de páginas: el alfabeto está cerrado y el idioma no. Las palabras fuera de vocabulario no son un defecto de tu corpus: son una propiedad de cualquiera.
Contando tipos, tokens y cobertura en Python
La celda tokeniza un corpus corto en español con el mismo por_palabras de la lección anterior,
cuenta tipos y ocurrencias, recorre la cobertura y termina aplicando un corte a cinco frases que el
corpus no contenía. Ejecútala y mira tres cosas: qué proporción de los tipos aparece una sola vez,
cómo sube al principio y cómo se atasca después, y las dos tasas OOV, que no se
parecen.
from collections import Counter
CORPUS = " ".join([
"El niño enseña programación en español.",
"La niña aprende programación en la escuela del barrio.",
"La escuela del barrio abre a las ocho de la mañana.",
"El profesor de la escuela explica que la lengua cambia con el tiempo.",
"Los niños del barrio juegan en la plaza hasta que el sol baja.",
"Una niña pregunta por qué el mar es azul y el profesor se queda callado.",
"El libro de la escuela habla del río, del mar y de la lluvia.",
"Aprender un idioma es aprender a cortar el mundo en palabras.",
"En la plaza del barrio hay un árbol que los niños llaman el gigante.",
"La niña que aprende programación explica el ejercicio a su hermano.",
"El profesor deja el libro en la mesa y sale de la clase.",
"Cada verano los niños vuelven al río con sus bicicletas.",
"La lluvia de la tarde deja la plaza vacía y la escuela en silencio.",
"Nadie del barrio recuerda cómo se llamaba la calle antes.",
"El niño escribe una palabra nueva en su cuaderno y la subraya.",
"La lengua de la escuela no es la lengua de la plaza, y los niños lo saben.",
"Vamos al río y luego al mar, como cada verano.",
"Enseñar no es lo mismo que aprender, aunque ocurran a la vez.",
])
def normaliza(texto):
return unicodedata.normalize("NFC", 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
tokens = por_palabras(CORPUS)
frec = Counter(tokens)
T, M = len(tokens), len(frec)
hapax = sum(1 for f in frec.values() if f == 1)
print("T =", T, "tokens | M =", M, "tipos")
print("tipos que aparecen UNA sola vez:", hapax, "de", M,
"->", round(100 * hapax / M), "% de los tipos")
print()
acumulado = 0
print(" i tipo f_i cob(i)")
for i, (w, f) in enumerate(frec.most_common(8), start=1):
acumulado = acumulado + f
print(str(i).rjust(3), w.ljust(16), str(f).rjust(3),
(str(round(100 * acumulado / T, 1)) + " %").rjust(8))
print()
for k in [8, 25, 50, 100, M]:
cubierto = sum(f for _, f in frec.most_common(k))
print("k =", str(k).rjust(3), "-> cob(k) =",
(str(round(100 * cubierto / T, 1)) + " %").rjust(7))
print()
# Cinco frases que el corpus NO contenía, con dos vocabularios:
# uno recortado a los 50 tipos más frecuentes y otro sin recorte alguno.
NUEVAS = [
"El niño programa una criptomoneda en la escuela del barrio.",
"La profesora explicará mañana los pronombres átonos a los alumnos.",
"En la plaza del barrio llueve y los niños vuelven a casa.",
"El bibliotecario catalogó los manuscritos del monasterio.",
"La niña del río lee un libro sobre gramática.",
]
nuevos = [t for frase in NUEVAS for t in por_palabras(frase)]
for corte in [50, M]:
V = {w for w, _ in frec.most_common(corte)}
fuera = [t for t in nuevos if t not in V]
print("|V| =", str(corte).rjust(3),
"-> OOV sobre tokens:", (str(round(100 * len(fuera) / len(nuevos))) + " %").rjust(5),
"| sobre tipos:", (str(round(100 * len(set(fuera)) / len(set(nuevos)))) + " %").rjust(5))
print()
V = set(frec) # sin recorte: todo lo que el corpus contenía
for frase in NUEVAS:
print(" ".join(w if w in V else "<UNK>" for w in por_palabras(frase)))
La primera ejecución descarga el intérprete de Python (~15 MB). Después queda en la caché del navegador.
La cobertura sube deprisa y luego se atasca, que es la forma logarítmica vista en números: los ocho primeros tipos cubren la tercera parte del corpus, y hacen falta cincuenta para llegar a tres cuartos. La cola es larga y barata de tirar, medida en ocurrencias.
Mira despacio las dos últimas partes. La tasa sobre tipos supera a la tasa sobre tokens en los dos vocabularios, tal como dice la teoría, y el corpus de juguete exagera ambas: con 237 tokens, hasta el vocabulario sin recortar deja fuera un tercio de lo que llega, mientras que uno real bajaría la tasa sobre tokens al 2 % o el 3 %. Lo que no cambia con el tamaño es qué palabras caen. En las frases reescritas siguen en pie los artículos y las preposiciones, y desaparecen criptomoneda, bibliotecario, manuscritos y monasterio: la cuarta frase se queda en un esqueleto que ya no dice nada.
Una prueba de treinta segundos. Cambia V = set(frec) por
V = {w for w, _ in frec.most_common(50)} y ejecuta otra vez: En con mayúscula pasa a
desconocida mientras en sigue dentro. Es un tipo distinto, no un fallo —la lección anterior ya
avisó de que cortar no es limpiar, y el precio se paga aquí, en el recuento.
Comprueba tu intuición
Cuatro preguntas sobre lo que acabas de obtener: qué cuesta un corte, qué cuesta la cobertura, qué recupera y qué gasta el token desconocido, y qué significa de verdad una tasa OOV pequeña.
Un corpus de 36 tokens tiene 8 tipos, con estas frecuencias: de 12, la 9, niño 5, escuela 4, programación 2, río 2, criptomoneda 1, gramática 1. Cortas el vocabulario en . ¿Cuántas de las 36 ocurrencias quedan convertidas en <UNK>?
Se acepta un margen de ±0.
Un corpus tiene tipos y sus frecuencias siguen . Quieres un vocabulario que cubra el 90 % de las ocurrencias. ¿Cuántos tipos necesitas, aproximadamente?
Mandas a <UNK> todo lo que quedó fuera de . ¿Qué es cierto del resultado? Marca todo lo que valga.
Marca todas las opciones correctas. Se corrige todo o nada: no hay puntuación parcial.
Tu vocabulario deja fuera el 2 % de los tokens de un texto nuevo. Entonces solo el 2 % de las frases de ese texto contiene alguna palabra desconocida.
El vocabulario ya es una lista cerrada: tipos elegidos por frecuencia, más una entrada al final que recoge todo lo demás. A esa lista, la de más arriba, la llamamos de aquí en adelante, con <UNK> como una entrada más. Con eso, vuelve a estar definida sobre cualquier texto, que era la condición de partida. Pero de solo conocemos qué recibe y qué devuelve. Sigue haciendo falta decidir qué vector concreto le toca a cada una de esas entradas, y la lección del problema de representar el lenguaje dejó dicho lo que no vale: con el orden de la recta real inventa parecidos entre palabras que no los tienen.
La salida más directa es no elegir ningún parecido. Si ninguna entrada del vocabulario debe estar más cerca de otra que del resto, dale a cada una su propio eje: una dimensión por entrada, un uno en su posición y ceros en el resto. Esa representación existe, se llama one-hot encoding y no afirma nada sobre el idioma, que es justo lo que le pedíamos. Su factura acaba de quedar escrita en esta lección: vale decenas de miles, y esa es la dimensión que tendrían los vectores. La lección sobre one-hot encoding y la maldición de la dimensionalidad mira esa factura de cerca.
Para profundizar2 fuentes · 1 libro, 1 interactivo
De dónde sale lo de esta lección, y dónde seguir si quieres más. Nada de aquí hace falta para continuar el curso.
- Introduction to Information Retrieval, cap. 5: Index Compression
La ley de Zipf y la de Heaps medidas sobre un corpus real, en su §5.1. El resto del capítulo va de comprimir índices de búsqueda y no te toca; esa sección es de donde sale la forma de las frecuencias que aquí desarrollas.
- Frequency Distribution Calculator
Pega un texto y te saca el reparto de frecuencias por posición, el ajuste de Zipf y los tipos que aparecen una sola vez que aquí cuentas a mano. Trae además la entropía de Shannon, que esta lección no usa.