NLP
Del texto en bruto a los grandes modelos de lenguaje.
Procesamiento del Lenguaje Natural (NLP)
Un modelo solo sabe manipular números. El texto, en cambio, es una sucesión de caracteres cuyo sentido depende del orden, del contexto, de lo implícito y, a veces, de lo que no está escrito. Entre esos dos mundos hay que construir un puente, y la manera de construirlo determina casi todo lo que el sistema sabrá hacer y todo lo que hará mal.
Eso explica comportamientos desconcertantes. Un modelo de lenguaje capaz de redactar una disertación matizada puede equivocarse al contar las letras de una palabra. Puede afirmar con un aplomo perfecto una referencia bibliográfica que nunca existió. Estos fallos parecen aleatorios, pero no lo son. Cada uno se deduce de una decisión de diseño concreta, situada en algún punto del puente entre los caracteres y los números.
Esta lección recorre ese puente en orden: trocear el texto, transformar los fragmentos en vectores y conseguir que cada fragmento tenga en cuenta a los demás. Este tercer punto es la atención, la idea que hizo cambiar de rumbo el campo en 2017 y sobre la que descansa todo lo que hoy se llama «gran modelo de lenguaje». La programaremos en cinco líneas de NumPy.
Terminaremos con el mecanismo de la alucinación. No para lamentarla, sino para mostrar que es la consecuencia directa y previsible del objetivo que se da a estos modelos.
Trocear el texto: la tokenización
Primera decisión: ¿cuál es la unidad elemental? La respuesta evidente, la palabra separada por espacios, revela enseguida sus límites.
texte = "Le chat dort. Les chats dorment, le chaton dort aussi !"
mots = texte.lower().replace(".", " ").replace(",", " ").replace("!", " ").split()
print(mots)
# ['le', 'chat', 'dort', 'les', 'chats', 'dorment', 'le', 'chaton', 'dort', 'aussi']
print(len(mots), len(set(mots))) # 10 8
Para la máquina, chat, chats y chaton son tres símbolos sin ninguna relación entre sí, tan ajenos entre ellos como chat y aussi. Lo mismo ocurre con dort y dorment. Cada forma flexionada tendrá que aprenderse por separado, lo que multiplica los ejemplos necesarios.
El segundo problema es el vocabulario fuera de lista. Una palabra ausente del vocabulario de entrenamiento (un nombre propio, un neologismo, una errata) carece de representación y se sustituye por un símbolo desconocido genérico.
Por eso los modelos actuales trocean en subpalabras. El algoritmo más extendido, el Byte Pair Encoding, parte de los caracteres y fusiona iterativamente los pares más frecuentes del corpus. El resultado es un compromiso: las palabras comunes se mantienen en un solo fragmento y las raras se descomponen en fragmentos conocidos.
Segmentación en palabras : ["anticonstitutionnellement"] → desconocida si no está
Segmentación en subpalabras : ["anti", "constitution", "nelle", "ment"] → todo es conocido
Esta decisión tiene una consecuencia directa y a menudo mal entendida: un modelo de lenguaje no ve las letras. Ve identificadores de fragmentos. Pedirle cuántas veces aparece la letra «r» en una palabra equivale a pedirle una información que no tiene delante, lo que explica la fragilidad bien documentada de estos modelos en tareas de conteo de caracteres, anagramas o rimas.
Para los enfoques clásicos, cada documento se representa después mediante un vector de recuentos:
from sklearn.feature_extraction.text import CountVectorizer
corpus = [
"le chat dort sur le canape",
"le chien dort sur le tapis",
"le chat mange une souris",
"le chien mange une croquette",
"la souris mange du fromage",
]
v = CountVectorizer()
M = v.fit_transform(corpus)
print(M.shape) # (5, 14)
print(v.get_feature_names_out())
# ['canape' 'chat' 'chien' 'croquette' 'dort' 'du' 'fromage' 'la' 'le'
# 'mange' 'souris' 'sur' 'tapis' 'une']
Cinco frases muy cortas ya producen 14 columnas. En un corpus real, este vector tiene decenas de miles de dimensiones, casi todas con valor cero para un documento dado. Y, sobre todo, esta representación ignora por completo el orden: «le chat mange la souris» y «la souris mange le chat» son idénticas en ella.
De las palabras a los vectores: los embeddings léxicos
La idea que desbloquea la situación es lingüística antes que matemática: las palabras que aparecen en los mismos contextos tienen significados próximos. Es la hipótesis distribucional, y se verifica mediante el cálculo.
import numpy as np
vocab = v.get_feature_names_out().tolist()
# Matriz de coocurrencia: cuántas veces aparecen dos palabras
# en la misma frase.
C = np.zeros((len(vocab), len(vocab)))
for phrase in corpus:
presents = [vocab.index(m) for m in phrase.split() if m in vocab]
for i in presents:
for j in presents:
if i != j:
C[i, j] += 1
def cosinus(u, w):
return float(u @ w / (np.linalg.norm(u) * np.linalg.norm(w)))
i_chat = vocab.index("chat")
i_chien = vocab.index("chien")
i_fromage = vocab.index("fromage")
print(round(cosinus(C[i_chat], C[i_chien]), 3)) # 0.867
print(round(cosinus(C[i_chat], C[i_fromage]), 3)) # 0.258
Con solo cinco frases, chat y chien obtienen una similitud de 0,867, frente a 0,258 entre chat y fromage. El cálculo no recibió ninguna información sobre los animales: simplemente constató que chat y chien están rodeados de las mismas palabras (le, dort, sur, mange), mientras que fromage frecuenta otro vecindario.
Los embeddings léxicos (word embeddings) industrializan esta idea. En lugar de vectores dispersos del tamaño del vocabulario, se aprende para cada palabra un vector denso de unos cientos de dimensiones, optimizado para predecir su contexto. Es el principio de word2vec (Mikolov et al., 2013) y de GloVe (Pennington et al., 2014).
La medida de similitud, por su parte, sigue siendo la misma:
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.metrics.pairwise import cosine_similarity
T = TfidfVectorizer().fit_transform(corpus)
print(cosine_similarity(T[0:1], T).round(2))
# [[1. 0.61 0.38 0.18 0. ]]
La frase 0 («le chat dort sur le canapé») se parece a la frase 1 («le chien dort sur le tapis») en un 0,61 y es totalmente ajena a la frase 4 («la souris mange du fromage») con 0,00. De paso, el ponderador TF-IDF corrige un defecto del recuento bruto: reduce el peso de las palabras presentes en todas partes, como le, que no aportan ninguna información discriminante.
Persiste una limitación, y de peso: un embedding clásico asigna un único vector por palabra. La palabra «avocat» (que en francés significa tanto «abogado» como «aguacate») tiene entonces la misma representación en «un avocat au barreau» y en «un avocat bien mûr». Resolverlo exige que la representación dependa del contexto, que es justamente la aportación del mecanismo siguiente.
La atención: no mirar todas las palabras por igual
La atención responde a una pregunta sencilla: para representar una palabra dada, ¿qué otras palabras de la frase hay que tener en cuenta y en qué proporción?
El cálculo usa tres objetos por palabra. Una consulta (lo que esa palabra busca), una clave (lo que ofrece cada palabra) y un valor (la información que se recupera). Se compara la consulta con todas las claves, se transforman las puntuaciones en proporciones que suman 1 y con ellas se calcula una media ponderada de los valores.
import numpy as np
np.set_printoptions(suppress=True)
mots = ["chat", "souris", "dort"]
# Claves y valores ilustrativos, elegidos para que el cálculo sea legible.
K = np.array([[4.0, 0.0, 0.0, 0.0], # chat
[3.0, 2.0, 0.0, 0.0], # souris
[0.0, 0.0, 4.0, 0.0]]) # dort
V = np.array([[10.0], [6.0], [1.0]])
Q = np.array([[4.0, 0.0, 0.0, 0.0]]) # la consulta busca un sujeto animado
d = K.shape[1]
scores = Q @ K.T / np.sqrt(d) # división: evita puntuaciones demasiado dispersas
poids = np.exp(scores - scores.max())
poids = poids / poids.sum() # softmax
print(scores.round(2)) # [[8. 6. 0.]]
print(poids.round(3)) # [[0.881 0.119 0. ]]
print((poids @ V).round(2)) # [[9.52]]
La lectura es directa: el 88,1 % de la atención va a chat, el 11,9 % a souris y prácticamente nada a dort. La representación producida, 9,52, es casi la de chat (10,0), ligeramente arrastrada hacia la de souris (6,0).
Tres propiedades explican el éxito de este mecanismo:
- Los pesos se calculan, no se fijan. La misma palabra obtendrá un reparto distinto en otra frase. La representación se vuelve contextual, lo que resuelve el problema de «avocat».
- La distancia no cuesta nada. Una palabra en la posición 1 puede recibir toda la atención de una palabra en la posición 400 con la misma facilidad que la de su vecina inmediata. Las arquitecturas recurrentes que las precedieron, que procesaban el texto palabra a palabra, iban perdiendo la información lejana.
- Todo es paralelizable. Las puntuaciones de todas las palabras se calculan con un producto matricial y, por tanto, en tarjeta gráfica. Esta propiedad, tanto como la calidad de los resultados, es lo que permitió escalar.
Los transformers y los grandes modelos de lenguaje
La arquitectura que apila capas de atención se llama transformer y se introdujo en 2017 en el artículo «Attention Is All You Need», de Vaswani et al. Como la atención por sí sola ignora las posiciones, se le añade una codificación de posición, además de capas densas clásicas y normalizaciones.
Un gran modelo de lenguaje es un transformer entrenado en una tarea de una sencillez desarmante: dados los tokens anteriores, predecir el siguiente. No se requiere ninguna anotación humana, el texto es su propia etiqueta, lo que permite entrenar con corpus gigantescos.
Algunos órdenes de magnitud documentados, útiles para situar las escalas:
| Modelo | Año | Número de parámetros |
|---|---|---|
| BERT-base | 2018 | aproximadamente 110 millones |
| BERT-large | 2018 | aproximadamente 340 millones |
| GPT-3 | 2020 | 175 000 millones |
Estas cifras proceden de los respectivos artículos de publicación. Los modelos comerciales recientes ya no publican sistemáticamente esta información: a falta de fuente, es mejor no avanzar nada.
Lo notable, y no estaba previsto, es que este objetivo estrecho (predecir la palabra siguiente) produce capacidades que no se programaron: traducir, resumir, reformular, seguir una instrucción. Conviene, sin embargo, ser precisos sobre lo que está establecido. Lo que está establecido es el rendimiento medido en tareas de evaluación. Lo que sigue en debate es la interpretación: saber si estas capacidades son una forma de generalización o una interpolación muy fina a partir de un corpus inmenso es una cuestión abierta, y toda afirmación categórica en un sentido o en otro excede lo que los datos permiten establecer.
Por qué alucina un modelo de lenguaje
El mecanismo se demuestra con un modelo minúsculo. Un modelo de bigramas retiene una sola cosa: para cada palabra, la lista de palabras que la han seguido en el corpus. Luego genera eligiendo al azar de esa lista.
import random
from collections import defaultdict
texte = ("le chat dort sur le canape le chien dort sur le tapis "
"le chat mange une souris le chien mange une croquette")
mots = texte.split()
suivants = defaultdict(list)
for a, b in zip(mots, mots[1:]):
suivants[a].append(b)
random.seed(3)
mot = "le"
phrase = [mot]
for _ in range(11):
if mot not in suivants:
break
mot = random.choice(suivants[mot])
phrase.append(mot)
print(" ".join(phrase))
# le canape le chien mange une souris le chien dort sur le
El resultado es casi francés. Los encadenamientos locales son irreprochables: «le chien mange une souris» es una frase perfectamente formada. También es falsa con respecto al corpus, donde el perro come una croqueta y es el gato quien se come al ratón. El modelo no ha mentido ni se ha equivocado en el sentido de un bug: ha producido una continuación estadísticamente plausible, que es exactamente, y únicamente, lo que se le pidió.
Eso es la alucinación, a la escala de veinte palabras de vocabulario. Un modelo de varios miles de millones de parámetros hace lo mismo con una fluidez incomparablemente mayor, lo que hace el error mucho más difícil de detectar. La diferencia de tamaño cambia la calidad de la superficie, no la naturaleza del objetivo.
De ello se derivan cuatro consecuencias prácticas, todas verificables:
- La fluidez no es un indicio de fiabilidad. Una referencia inventada se formula con la misma seguridad que una referencia exacta, porque ambas las produce el mismo proceso.
- No hay fuente. El modelo no copia una base de conocimientos consultable; produce una sucesión de tokens. Preguntar «¿de dónde viene esta información?» obtiene una respuesta generada, no un rastro.
- La ventana de contexto es finita. Pasada cierta longitud, los tokens antiguos salen del contexto considerado.
- Los correctivos atenúan, pero no suprimen. Proporcionar documentos en el contexto y exigir citas verificables (lo que se llama generación aumentada por recuperación, RAG) reduce mucho el fenómeno al anclar la respuesta en textos reales. A día de hoy, ninguna técnica publicada lo elimina por completo.
De ahí se desprende la postura de ingeniería correcta: tratar la salida de un modelo de lenguaje como una propuesta que verificar, y no como un resultado. Allí donde la exactitud es contractual, la verificación debe ser externa al modelo.
Conclusiones
- La tokenización es la primera decisión de diseño y tiene efectos duraderos. La segmentación en subpalabras resuelve el problema de las palabras desconocidas, pero hace que un modelo nunca vea las letras, de ahí su fragilidad en el conteo de caracteres.
- La hipótesis distribucional se verifica mediante el cálculo: con cinco frases,
chatychienalcanzan una similitud de 0,867 frente a 0,258 entrechatyfromage, sin que se haya aportado ningún conocimiento zoológico. - La atención calcula, para cada palabra, la proporción de atención que debe dedicar a las demás. Hace que la representación dependa del contexto, elimina el coste de la distancia y se calcula en paralelo.
- Un gran modelo de lenguaje es un transformer entrenado para predecir el token siguiente. Este único objetivo produce capacidades no programadas; su interpretación teórica sigue, en cambio, en debate.
- La alucinación no es una avería sino la consecuencia del objetivo: el modelo optimiza la plausibilidad, no la verdad. Un modelo de bigramas de veinte palabras ya la reproduce. Toda salida destinada a un uso serio debe verificarse fuera del modelo.
Comentarios
Los comentarios funcionan con GitHub Discussions
Inicia sesión con GitHub para participar en la discusión