Categoría: Uncategorized

  • Large Language Model (LLM) from scratch. Post-training.

    Llegado a este punto, hemos visto la arquitectura necesaria para entrenar un LLM. Si bien, mediante la predicción del próximo token y sampleo, este pre-entrenamiento es capaz de generar textos elocuentes, en muchos casos, las respuestas son deficientes, o dañinas. A modo de ejemplo, en el siguiente artículo se muestra la una salida particular del modelo GPT-3 pre-entrenado:

    Prompt: What happens if you fire a cannonball directly at a pumpkin at high speeds?

    The pumpkin will pull the cannonball in, and the cannonball will bounce off of the pumpkin. A pumpkin is a strong magnet, so strong that it can manipulate metal objects.

    Como podemos ver, la salida no es satisfactoria desde una perspectiva humana. No obstante, cuando GPT-3 es ajustado para seguir instrucciones, la salida es la siguiente:

    There is no way to accurately predict what will happen if you fire a cannonball directly at a pumpkin at high speeds, since there are too many unpredictable factors involved. Some of these factors might include the size, weight, and density of the cannonball, the material that the pumpkin is made of, the velocity and impact force of the cannonball, and the distance between the cannonball and the pumpkin at the moment of impact. Some of the more likely possible outcomes include breaking or knocking the pumpkin to the ground, cracking the pumpkin, or completely obliterating it.

    El output anterior muestra que este ajuste convirtió una salida poco útil desde el punto de vista humano, en otra más útil y natural. En general, el hacer que el modelo sea más útil, seguro y obediente a las instrucciones, se llama model alignment. En este sentido, en la presente publicación veremos dos formas de hacer model alignment: instruction tunning y preference alignment.

    En palabras simples, instruction tunning ayuda a que el modelo entienda que debe hacer la tarea solicitada, y preference alignment entrena al modelo usando preferencias. La siguiente imagen ilustra mejor este último concepto:

    Como se muestra en la imagen, teniendo una base de datos de respuestas previamente rankeadas, el modelo puede aprender a rankear sus propias salidas. En la sección aprendizaje por preferencias veremos esto en detalle.

    Durante esta entrada, usaremos como referencia a Jurafsky y Martin (2026). Cualquier error es mi responsabilidad.

    Instruction tuning

    Tomar un LLM preentrenado y entrenarlo para que siga instrucciones relacionadas con diversas tareas es conocido como instruction tuning.

    Específicamente, instruction tuning consiste en continuar entrenando un modelo ya preentrenado mediante nuevos datos de entrenamiento compuestos por instrucciones y sus respectivas respuestas esperadas. Cabe destacar que el objetivo no es que el modelo aprenda únicamente las instrucciones concretas contenidas en el dataset, sino que mejore su habilidad general para responder instrucciones, incluso en tareas que no haya visto explícitamente durante este entrenamiento.

    A pesar de que en esta fase, al igual que en el preentrenamiento, el modelo continúa siendo entrenado para predecir el siguiente token, este método suele denominarse supervised fine-tuning. Esto se debe a que, a diferencia del preentrenamiento, el entrenamiento está guiado por ejemplos en los que se ha definido una respuesta esperada o adecuada para cada instrucción o pregunta.

    Dicho esto, ahora estamos en condiciones de ver qué tipos de datasets se utilizan para realizar instruction tuning. Jurafsky y Martin (2026) presentan una lista extensa de conjuntos de datos construidos con este fin. Como explican los autores, dichos datasets pueden crearse de cuatro formas distintas:

    Como muestra la imagen, en el primer caso, personas escriben directamente las instrucciones y sus respectivas respuestas. En el segundo, se reutilizan datasets clásicos de NLP —por ejemplo, datasets de preguntas y respuestas, traducción, resumen, análisis de sentimiento o inferencia textual— y se transforman automáticamente en ejemplos de instrucción y respuesta. En el ejemplo concreto de la ilustración, un dato originalmente destinado al análisis de sentimiento se convierte en una instrucción que pide clasificar el sentimiento expresado en la frase “Me encantó la película”. En el tercer caso, se reutilizan guías de anotación originalmente escritas para humanos, empleándolas como prompts para generar nuevos ejemplos de instruction tuning. Por último, también es posible utilizar otros LLMs para generar datos adicionales, por ejemplo, creando paráfrasis de una instrucción o produciendo respuestas esperadas que luego puedan incorporarse al dataset.

    A modo de ejemplo práctico, la iniciativa Latam-GPT dispone públicamente de datasets que contienen instrucciones y respuestas en español. Puedes ver uno de estos conjuntos de datos aquí.

    Por otra parte, vale la pena preguntarse, en el contexto de LLMedieval, cómo podría ayudarnos instruction tuning. Entre otras cosas, este método podría ser útil para enseñar al modelo a ejecutar instrucciones de modernización, medievalización, explicación lingüística o traducción asistida de fragmentos. En términos concretos, un ejemplo de entrenamiento para una tarea de modernización podría verse así:

    Si quieres ver ejemplos de instruction tuning asociados a este proyecto, te invito a estar pendiente de las actualizaciones de este, en la siguiente página: https://pulki.es/medieval/.

    Evaluación de modelos entrenados mediante instruction tuning

    Como dijimos anteriormente, al instruir un modelo ocurren principalmente dos cosas. La primera es que el LLM aprende a responder los tipos de instrucciones y tareas presentes en los datos de entrenamiento. La segunda es que puede mejorar su habilidad general para seguir instrucciones, incluso cuando estas se refieren a tareas que no fueron incluidas explícitamente en el dataset. Puesto que el objetivo principal de instruction tuning es mejorar esta segunda capacidad, su evaluación natural consiste en examinar el desempeño del modelo en tareas nuevas.

    Específicamente, como explican Jurafsky y Martin (2026), si nuestro objetivo es evaluar la capacidad general del modelo para seguir instrucciones —el segundo punto—, no basta con utilizar instrucciones nuevas si estas corresponden al mismo tipo de tarea que el modelo ya vio durante el entrenamiento. Es decir, si entrenamos al modelo con instrucciones relacionadas con:

    • Traducción
    • Resumen
    • Clasificación de sentimiento
    • Respuesta a preguntas
    • Reescritura de textos

    Y luego lo evaluamos con nuevas instrucciones de traducción, podremos medir si el modelo mejoró en esa tarea. Sin embargo, esto no nos asegura que haya desarrollado una habilidad más general para seguir instrucciones, pues la traducción ya formaba parte de los datos de entrenamiento. En este sentido, una evaluación más adecuada consiste en dejar fuera una tarea durante el instruction tuning y utilizarla posteriormente para evaluar el modelo.

    Aprendizaje basado en preferencias

    Incluso después de instruction tuning, el modelo puede seguir siendo susceptible de generar alucinaciones, producir respuestas poco útiles desde una perspectiva humana o entregar contenido inseguro. Una forma de abordar estos problemas consiste en utilizar aprendizaje basado en preferencias. En la introducción mencionamos esta idea en términos generales. Ahora la explicaremos con mayor detalle; sin embargo, un buen ejercicio pedagógico consiste en comenzar examinando cómo se estructuran los datos utilizados para este tipo de entrenamiento.

    ¿Qué forma tienen los datos de preferencias?

    El proyecto Latam-GPT también dispone de una base de datos de preferencias. A continuación, mostraremos un ejemplo de cómo se estructura una entrada de este conjunto de datos:

    Como podemos ver, una entrada contiene un prompt, una respuesta elegida (chosen) y otra rechazada (rejected). Esto no significa necesariamente que la primera sea la única respuesta correcta y la segunda sea completamente incorrecta; significa que, para ese prompt, una respuesta fue considerada preferible frente a la otra.

    A partir de este ejemplo, el lector puede preguntarse de dónde proviene el juicio que permite marcar una respuesta como chosen y otra como rejected. En general, existen tres fuentes principales de datos de preferencias. La primera consiste en utilizar evaluadores humanos, quienes comparan, seleccionan u ordenan múltiples respuestas generadas por un modelo ante un mismo prompt. La segunda consiste en extraer preferencias implícitas de ciertos sitios web. Un ejemplo de este caso es StackExchange:

    En este caso, como podemos ver, podríamos considerar la pregunta original como el prompt y las respuestas publicadas por la comunidad como las alternativas candidatas. Los votos acumulados por cada respuesta permiten inferir cuáles fueron consideradas mejores; de esta forma, una respuesta mejor valorada puede tratarse como preferida frente a otra con menor valoración.

    Por último, otra alternativa consiste en utilizar un LLM como evaluador. En este caso, distintos modelos pueden generar respuestas candidatas para un mismo prompt, y luego un LLM suficientemente potente puede compararlas u ordenarlas según su calidad. Así, el propio modelo evaluador permite generar datos sintéticos de preferencias.

    Preferencias por pares y puntuaciones por aspectos

    En la sección anterior dijimos que los evaluadores podían seleccionar, ordenar o puntuar respuestas candidatas. En el caso de la selección y el ordenamiento, el resultado puede representarse mediante preferencias entre pares de respuestas: por ejemplo, que la respuesta A es preferida sobre la respuesta B. En cambio, cuando hablamos de puntuar una respuesta, nos referimos a evaluarla individualmente en determinadas dimensiones, como su utilidad, honestidad, corrección o claridad.

    Cuando contamos con una preferencia entre pares, sólo sabemos que, para un mismo prompt, una respuesta fue considerada mejor que otra. Es decir, tenemos una observación como “la respuesta A es preferida sobre la respuesta B”, pero no contamos con una puntuación individual asociada a cada una de ellas. En la próxima sección veremos cómo imputar una puntuación de este tipo.

    Modelando una preferencia

    Como dijimos anteriormente, cuando los evaluadores seleccionan una respuesta preferida entre dos alternativas generadas para un mismo prompt, no obtenemos una puntuación explícita para cada respuesta. Sólo sabemos que una fue preferida sobre la otra. Sin embargo, nos gustaría construir un modelo capaz de asignar una puntuación numérica a cada alternativa, de manera que podamos ordenarlas según qué tan preferibles resultan. La siguiente imagen ilustra esta idea:

    Si bien en la imagen anterior mostramos sólo dos respuestas, en un dataset de preferencias podemos tener múltiples respuestas candidatas para un mismo prompt. Supongamos, por ejemplo, que para un prompt xx disponemos de ocho respuestas, oA,oB,...,oHo_{A}, o_{B},…,o_{H}, y que los evaluadores han expresado las siguientes preferencias sobre algunos pares:

    • oAoB|xo_{A} \succ o_{B} | x
    • oCoD|xo_{C} \succ o_{D} | x
    • oFoE|xo_{F} \succ o_{E} | x
    • oGoH|xo_{G} \succ o_{H} | x

    El símbolo \succ significa “es estrictamente preferida sobre”. Así, la primera comparación indica que, dado el prompt xx, la respuesta A fue considerada mejor que la respuesta B.

    A partir de estos juicios, nos gustaría modelar la probabilidad de que una respuesta sea preferida sobre otra para un mismo prompt. En términos formales, queremos calcular:

    (oioj|x)\mathbb{P}(o_{i}\succ o_{j}|x)

    Es decir, queremos conocer qué tan probable es que la respuesta oio_{i} sea preferida sobre la respuesta ojo_{j}, dado el prompt xx.

    Para ello, asumimos que cada respuesta tiene asociada una puntuación latente, es decir, una puntuación que no observamos directamente. Denotaremos la puntuación de la respuesta oio_{i}como ziz_{i}​. Bajo el modelo Bradley-Terry, la probabilidad de preferir una respuesta sobre otra depende de la diferencia entre sus puntuaciones:

    (oioj|x)=σ(zizj)\mathbb{P}(o_{i}\succ o_{j}|x) = \sigma(z_{i} – z_{j})

    donde σ\sigma corresponde a la función sigmoide. Si ambas respuestas reciben puntuaciones similares, la probabilidad de preferir una sobre la otra será cercana a 0.50.5. En cambio, si la puntuación de oio_{i} es mucho mayor que la de ojo_{j}, la probabilidad de preferir oio_{i} será cercana a 1.

    El problema es que las puntuaciones ziz_{i}​ no forman parte de nuestros datos. Los evaluadores sólo nos informan que una respuesta fue preferida sobre otra; no nos entregan una puntuación individual para cada salida. Por esta razón, necesitamos aprender una función r(x,o)r(x,o) que, dado un prompt y una respuesta candidata, produzca una puntuación escalar. De este modo, podemos reescribir la expresión anterior como:

    (oioj|x)=σ(r(x,oi)r(x,oj))\mathbb{P}(o_{i} \succ o_{j}|x) = \sigma(r(x,o_{i}) – r(x,o_{j}))

    En otras palabras, r(x,oi)r(x,o_{i}) representa la puntuación que el modelo aprende a asignar a la respuesta oio_i en el contexto del prompt xx. Esta función debe aprenderse utilizando los datos de preferencias que ya tenemos. Para cada par observado, designaremos como owo_{w} a la respuesta preferida —la winner— y como olo_{l} a la respuesta rechazada —la loser. Puesto que el dataset indica que owo_{w} fue preferida sobre olo_{l}, utilizamos esa observación como etiqueta de entrenamiento: el evento owolo_{w} \succ o_{l}​ recibe el valor objetivo 11.

    La función de pérdida de entropía cruzada para un único par viene dada por:

    LCE(x,ow,ol)=log((owol|x))L_{CE}(x,o_{w},o_{l}) = -\log(\mathbb{P}(o_{w}\succ o_{l}|x))
    LCE(x,ow,ol)=log(σ(r(x,ow)r(x,ol)))L_{CE}(x,o_{w},o_{l}) = -\log(\sigma(r(x,o_{w}) – r(x,o_{l})))

    Esta pérdida será alta cuando el modelo asigne una puntuación mayor a la respuesta rechazada, o cuando apenas distinga entre ambas respuestas. Por el contrario, será baja cuando la respuesta preferida reciba una puntuación suficientemente mayor que la rechazada.

    Finalmente, la pérdida sobre el conjunto de entrenamiento DD se obtiene calculando este mismo criterio sobre todos los pares de preferencias disponibles:

    LCE=𝔼(x,ow,ol)D[log(σ(r(x,ow)r(x,ol)))]L_{CE} = -\mathbb{E}_{(x,o_{w},o_{l})\sim D}[\log( \sigma( r(x,o_{w}) – r(x,o_{l})) )]

    En términos simples, esta función penaliza al modelo cada vez que sus puntuaciones no respetan adecuadamente las preferencias registradas en los datos.

    Evidentemente, para calcular esta pérdida necesitamos un modelo capaz de producir los valores de r(x,o)r(x,o). Una forma habitual de hacerlo consiste en convertir un LLM preentrenado en un reward model: se reemplaza su capa final de predicción de tokens por una capa lineal que produce un único número para cada combinación de prompt y respuesta. Así, el modelo deja de generar texto y pasa a evaluar respuestas mediante una puntuación escalar.

    Durante el entrenamiento, el reward model evalúa tanto la respuesta preferida como la rechazada, calcula la pérdida anterior y ajusta sus parámetros entrenables mediante backpropagation y descenso de gradiente. Como resultado, las puntuaciones producidas por r(x,o)r(x,o) se vuelven progresivamente más coherentes con las preferencias observadas. A continuación, explicaremos este proceso en mayor detalle.

    1. Convertir un LLM en reward model

    Como muestra la imagen, un reward model puede construirse a partir de una arquitectura muy similar a la de un LLM generativo convencional. No obstante, ambos modelos se diferencian en la tarea que realizan y en el tipo de salida que producen.

    La diferencia principal se encuentra en la capa final del modelo. En un LLM generativo, esta capa se utiliza para producir probabilidades sobre los tokens que podrían continuar el texto. En cambio, para construir un reward model, la capa final de predicción de tokens se reemplaza por una capa lineal de puntuación. De esta forma, la salida del modelo ya no es una continuación textual, sino una única puntuación numérica.

    2. Bradley-Terry

    Dado que el reward model produce una puntuación para cada respuesta, podemos utilizar Bradley-Terry para convertir la diferencia entre dichas puntuaciones en una probabilidad de preferencia. Si estamos comparando las respuestas oAo_{A} y oBo_{B}​ para el prompt xix_{i}​, esta probabilidad viene dada por:

    (oAoB|xi)=σ(r(xi,oA)r(xi,oB))\mathbb{P}(o_{A}\succ o_{B}|x_{i}) = \sigma(r(x_{i},o_{A})-r(x_{i},o_{B}))

    Esta probabilidad puede contrastarse con la preferencia registrada en los datos. Supongamos, por ejemplo, que el evaluador humano prefirió oAo_{A}​ sobre oBo_{B}​. A partir de esta situación, podemos distinguir dos casos:

    a) El reward model está equivocado.

    Supongamos que el evaluador humano prefirió oAo_{A}​ sobre oBo_{B}​, pero el reward model produce:

    r(xi,oB)>r(xi,oA)r(x_{i},o_{B}) > r(x_{i},o_{A})

    En este caso, la diferencia r(xi,oA)r(xi,oB)r(x_{i},o_{A}) – r(x_{i},o_{B}) es negativa. En consecuencia, Bradley-Terry asignará una probabilidad inferior a 0.50.5 al evento de que oAo_{A} sea preferida sobre oBo_{B}​. Como esta estimación contradice la preferencia observada en los datos, la pérdida será alta y el entrenamiento deberá ajustar los parámetros del reward model.

    b) El reward model ordena correctamente las respuestas

    Si el evaluador humano prefirió oAo_{A}​ sobre oBo_{B}​, y el reward model produce:

    r(xi,oA)>r(xi,oB)r(x_{i},o_{A}) > r(x_{i},o_{B})

    entonces la diferencia entre puntuaciones es positiva y Bradley-Terry asignará una probabilidad superior a 0.50.5 al evento de que oAo_{A}​ sea preferida sobre oBo_{B}. Además, mientras mayor sea la diferencia a favor de oAo_{A}​, mayor será la probabilidad asignada a la preferencia observada y menor será la pérdida.

    3. Se ajustan los parámetros del modelo

    En términos generales, existen dos posibilidades para entrenar un reward model. La primera consiste en ajustar únicamente la nueva capa de puntuación, manteniendo congelados los parámetros del transformer. La segunda consiste en ajustar la capa de puntuación y permitir que también se actualicen algunos o todos los parámetros del transformer. Para comprender la diferencia entre ambas posibilidades, primero conviene explicar brevemente cómo opera backpropagation.

    En términos simples, backpropagation permite determinar qué parámetros entrenables deberían cambiar, y en qué dirección, para que la pérdida producida por el modelo disminuya. Para entenderlo, recordemos primero el flujo normal de cálculo de un reward model. Para una respuesta candidata, el modelo opera de la siguiente forma:

    Prompt + respuesta → Transformer → Representación → Capa lineal → Puntuación

    Como nuestros datos contienen una respuesta preferida y otra rechazada para un mismo prompt, este proceso se realiza para ambas respuestas:

    Prompt + respuesta preferida → Reward model → r(x,ow)r(x,o_{w})

    Prompt + respuesta rechazada → Reward model → r(x,ol)r(x,o_l)

    r(x,ow)r(x,o_{w}) y r(x,ol)r(x,o_l) → Pérdida

    Durante la pasada hacia adelante, el modelo calcula las puntuaciones de ambas respuestas y, a partir de ellas, la pérdida. Luego, backpropagation recorre hacia atrás el grafo de cálculo utilizado para obtener esa pérdida. Mediante la regla de la cadena, calcula cómo cambiaría la pérdida si se modificara cada parámetro entrenable del modelo.

    Dicho de otro modo, si la respuesta preferida recibió una puntuación demasiado baja respecto de la rechazada, backpropagation calcula qué cambios permitirían aumentar esa diferencia en la dirección correcta. Posteriormente, un algoritmo de optimización, como gradient descent, utiliza esos gradientes para actualizar los parámetros entrenables y reducir la pérdida en las siguientes iteraciones.

    Una vez entendido esto, la diferencia entre las dos posibilidades de entrenamiento es sencilla. Si el transformer permanece congelado, únicamente se actualizan los parámetros de la capa lineal de puntuación. En ese caso, el modelo aprende a asignar mejores puntuaciones utilizando las representaciones internas que el transformer ya era capaz de producir.

    En cambio, si también permitimos ajustar parámetros del transformer, los gradientes pueden modificar no sólo la capa de puntuación, sino también las representaciones internas utilizadas para evaluar las respuestas. En este segundo caso, el modelo puede aprender a representar con mayor claridad los rasgos que hacen que una respuesta sea preferible frente a otra.

    Notas finales, y próxima entrada…

    En este entrada vimos conceptos básicos sobre el proceso de post-training. Específicamente, estudiamos cómo hacer que el modelo sea mejor siguiendo instrucciones, y cómo modelar preferencias, útil para producir respuestas mas afin con las que preferirían los humanos, y menos dañinas, entre otras cosas.

    En la próxima publicación veremos Masked Language Models. Stay tuned!

    José Miguel Muñoz Urra – jmunozu@pulki.es

  • Large Language Model (LLM) from scratch. Transformers.

    En la publicación pasada entendimos la estructura general de un LLM. Este paso es vital, puesto que nos da una visión panorámica sobre la morfología de estos modelos. Con esto en mente, ahora podemos estudiar la arquitectura de aquellos.

    Existen múltiples arquitecturas para diseñar un LLM, no obstante, en esta entrada vamos a estudiar transformers, puesto que es la que emplean los LLM más famosos, tales como GPT, BERT, T5, LLaMa, etc.

    Al igual que en la entrada anterior, usaremos como referencia a Jurafsky y Martin (2026), como es usual. Cualquier error es mi responsabilidad.

    Atención

    En el contexto específico de transformers, el concepto de atención fue introducido por Vaswani et al. (2017). Este artículo, y concepto, son importantes por varias razones, no obstante, en este momento se me ocurren dos muy obvias: la atención está presente en los LLMs más famosos, tales como GPT, BERT, etc., y además, el artículo tiene más de 250 mil citas (a mayo de 2026).

    En términos formales, la atención es un mecanismo que permite construir una nueva representación contextual de un token combinando información de otros tokens de la secuencia.

    Pero, ¿Por qué necesitamos una nueva representación contextual?. Primero que todo, recordemos algunos conceptos expuestos en la entrada sobre embeddings. En esta publicación, representábamos el significado de una palabra de forma vectorial y estática, es decir, los valores de los componentes del vector de la palabra no dependen del contexto en el que se encuentre. Esto es problemático porque muchas veces la idoneidad de la palabra es definida por el contexto. Por ejemplo:

    El coche no siguió avanzando por el camino porque este estaba mojado.

    El coche no siguió avanzando por el camino porque este estaba averiado.

    En este caso, en la primera oración, el coche no avanzó porque el camino estaba mojado, y en la segunda, porque el coche estaba averiado. Como podemos ver, el contexto determina el significado de la palabra «este».

    Entonces, respondiendo a la pregunta, sobre si una nueva representación conceptual es necesaria, si, es absolutamente necesaria, puesto que ayuda al modelo a distinguir este tipo de sutilidades.

    Ahora que sabemos por qué una representación contextual es necesaria, estamos en condiciones de explicar el concepto de atención. Formalmente, se podría definir como el mecanismo mediante el cual el transformer toma xix_i, es decir, la representación vectorial del token que está en la posición ii de la secuencia, y produce aia_i, una nueva representación del mismo token, pero enriquecida con información de los tokens anteriores. En un modelo causal, como GPT, esta información sólo puede venir del propio token y de los tokens que aparecen antes de él, no de los tokens futuros.

    Para entender esta idea, usaremos una versión simplificada de lo que realmente ocurre, tal y como lo muestra Jurafsky y Martin (2026). Para esto, asumiremos la siguiente estructura:

    ai=jiαijxja_{i} = \sum_{j\leq i}\alpha_{ij}x_{j}

    Es decir, en la posición ii de la secuencia, tomamos la representación vectorial de los tokens anteriores (incluyendo ii), y los ponderamos por αij\alpha_{ij}. Luego, para obtener este último, calculamos el score, y lo pasamos por la softmax:

    score(xi,xj)=xixjscore(x_{i},x_{j}) = x_{i} \cdot x_{j}

    αij=softmax(score(xi,xj)),ji\alpha_{ij} = softmax(score(x_{i},x_{j}) ), \forall j\leq i

    ¿Por qué calculamos el producto punto? Recordemos que este se puede interpretar como una medida de similitud entre dos vectores, entonces, si el vector de un token previo xjx_j​ es parecido al vector del token actual xix_i, entonces probablemente contiene información útil para interpretar o representar mejor a xix_i. En términos prácticos, supongamos el siguiente ejemplo:

    El gato es buena mascota. Este felino…

    Supongamos que en esta frase estamos analizando xfelinox_{felino}. Es de esperar, que la similitud entre xfelinox_{felino} y xgatox_{gato}, sea mayor a la similitud entre xfelinox_{felino}y xbuenax_{buena}. Lo anterior implica que αfelino,gato>αfelino,buena\alpha_{felino,gato} >\alpha_{felino,buena}. Por tanto, el vector contextualizado del token felino, afelinoa_{felino}, contendrá más información de gato que del token buena.

    Esta metodología fue desarrollada por Jurafsky y Martin (2026) con fines pedagógicos, no obstante, el funcionamiento de la atención es más compleja. Esta, tiene conceptos adicionales como attention head, query, key y value. A continuación veremos cada uno, y cómo se relacionan.

    Attention head, query y value

    Una attention head es un componente de atención que toma las representaciones vectoriales de los tokens y las proyecta en tres espacios distintos: queries, keys y values. La query corresponde a la representación del token actual en su rol de búsqueda; las keys corresponden a las representaciones de los tokens del contexto en su rol de comparación; y los values corresponden a la información que finalmente será ponderada y sumada. De esta forma, la cabeza de atención compara la query del token actual con las keys de los tokens disponibles en el contexto —en un transformer causal, el propio token y los tokens anteriores—, transforma esas comparaciones en pesos de atención y usa esos pesos para combinar los values. El resultado es una nueva representación del token actual, enriquecida con información contextual. En la siguiente imagen, podemos ver con más claridad qué ocurre:

    En el ejemplo de la ilustración, tenemos tres tokens de entrada que vienen en forma de embeddings: x1x_{1}, x2x_{2}y x3x_{3}, siendo este último, el token que estamos analizando. Nuestro objetivo es calcular a3a_{3}, es decir, una nueva representación contextualizada de ese token. Para ello, primero proyectamos cada vector de entrada en tres representaciones distintas: key, query y value. Estas proyecciones se obtienen mediante tres matrices aprendidas por el modelo:

    qi=xiWQq_{i} = x_{i}W^{Q}

    ki=xiWKk_{i} = x_{i}W^{K}

    vi=xiWVv_{i} = x_{i}W^{V}

    donde qiq_{i} es la query, kik_{i}es la key y viv_{i}es el value correspondiente al token en la posición ii.

    Como queremos calcular la salida de atención para x3x_3, usamos la query de x3x_3, es decir, q3q_3, y la comparamos con las keys de los tokens disponibles en el contexto. Puesto que estamos en un modelo causal, x3x_3 puede atender a x1x_1, x2x_2 y a sí mismo, pero no a tokens futuros. Por lo tanto, calculamos los siguientes scores:

    x3contra x1:score(x3,x1)=q3k1dkx_{3} \text{contra } x_{1} : score(x_{3},x_{1}) = \frac{q_{3}\cdot k_{1}}{\sqrt{d_{k}}}

    x3contra x2:score(x3,x2)=q3k2dkx_{3} \text{contra } x_{2} : score(x_{3},x_{2}) = \frac{q_{3}\cdot k_{2}}{\sqrt{d_{k}}}

    x3contra x1:score(x3,x3)=q3k3dkx_{3} \text{contra } x_{1} : score(x_{3},x_{3}) = \frac{q_{3}\cdot k_{3}}{\sqrt{d_{k}}}

    El producto punto q3kjq_3 \cdot k_j mide la compatibilidad entre la query del token actual y la key de cada token del contexto. Luego, dividimos por dk\sqrt{d_k} para evitar que los scores crezcan demasiado cuando la dimensión de las queries y keys es alta.

    Posteriormente, pasamos estos scores por una softmax. Esto nos permite convertir los scores en pesos de atención:

    α31,α32,α33\alpha_{31}, \alpha_{32}, \alpha_{33}

    Formalmente:

    α3j=escore(x3,xj)m3escore(x3,xm)\alpha_{3j} = \frac{e^{score(x_{3},x_{j})}}{\sum_{m\leq3}e^{score(x_{3},x_{m})}}

    para j{1,2,3}j \in \{1,2,3\}. De esta forma, los pesos de atención son números positivos que suman 1:

    α31+α32+α33=1\alpha_{31} + \alpha_{32} + \alpha_{33} =1

    Cada α3j\alpha_{3j}​ indica cuánta información del token xjx_j será incorporada en la nueva representación de x3x_3.

    Una vez obtenidos los pesos de atención, podemos calcular la salida de la cabeza de atención para el token x3x_3. Para ello, hacemos una suma ponderada de los value vectors:

    head3=α31v1+α32v2+α33v3head_{3} = \alpha_{31}v_{1} + \alpha_{32}v_{2} + \alpha_{33}v_{3}

    El vector head3head_3​ tiene dimensión:

    1×dv1\times d_{v}

    Sin embargo, la arquitectura del transformer necesita que la salida de la atención tenga la misma dimensión que la representación original del token, es decir:

    1×dmodel1\times d_{model}

    Por esta razón, multiplicamos head3head_3 por una matriz de salida WOW^O:

    a3=head3Woa_{3} = head_{3}W^{o}

    donde:

    WOdv×dmodelW^{O} \in \mathbb{R}^{d_{v}\times d_{model}}

    Así, la operación tiene la siguiente forma:

    [1×dv][dv×dmodel]=[1×dmodel][1\times d_{v}]\cdot [d_{v}\times d_{model}] = [1\times d_{model}]

    El resultado final es:

    a31×dmodela_{3} \in \mathbb{R}^{1\times d_{model}}

    Es decir, a3a_3 es una nueva representación del token x3x_3​, enriquecida con información de x1x_1, x2x_2 y del propio x3x_3​. Esta representación mantiene la misma dimensionalidad que la entrada, lo que permite integrarla al flujo residual del transformer y pasarla a las siguientes capas.

    Para verlo con un ejemplo pequeño, supongamos que:

    dv=3   y   dmodel=2d_{v} = 3 \ \ \ \text{y} \ \ \ d_{model}=2

    Entonces head3head_3 podría ser un vector de tres dimensiones:

    headi=[h1 h2 h3]head_{i} = [h_{1} \ h_{2} \ h_{3}]

    y WOW^{O} tendría dimensión 3×23\times 2:

    WO=[w11w12w21w22w31w32]W^{O} = \begin{bmatrix} w_{11} & w_{12} \\ w_{21} & w_{22} \\ w_{31} & w_{32} \end{bmatrix}

    La multiplicación sería:

    a3=[h1h2h3][w11w12w21w22w31w32]a_{3} = \begin{bmatrix} h_{1} & h_{2} & h_{3} \end{bmatrix} \cdot \begin{bmatrix} w_{11} & w_{12} \\ w_{21} & w_{22} \\ w_{31} & w_{32} \end{bmatrix}

    Por lo tanto:

    a3=[h1w11+h2w21+h3w31h1w12+h2w22+h3w32]a_{3} = \begin{bmatrix} h_{1}w_{11} + h_{2}w_{21} + h_{3}w_{31} & h_{1}w_{12} + h_{2}w_{22} + h_{3}w_{32} \end{bmatrix}

    Así, WOW^O aprende cómo transformar y mezclar la información contenida en head3head_3 para devolverla al espacio general del modelo.

    Dicho esto, en términos formales, el conjunto de ecuaciones para obtener aia_{i}vendría dado por:

    qi=xiWQ  ;  kj=xjWK  ;  vj=xjWVq_{i} = x_{i}W^{Q} \ \ ; \ \ k_{j} = x_{j}W^{K} \ \ ; \ \ v_{j} = x_{j}W^{V}

    score(xi,xj)=qikjdkscore(x_{i},x_{j}) = \frac{q_{i}k_{j}}{\sqrt{d_{k}}}

    αij=softmax(score(xi,xj))  ji\alpha_{ij} = softmax(score(x_{i},x_{j})) \ \ \forall j \leq i

    headi=jiαijvjhead_{i} = \sum_{j\leq i} \alpha_{ij}v_{j}

    ai=headiWoa_{i} = head_{i}W^{o}

    Múltiples attention heads

    En la sección anterior mostramos el funcionamiento de una sola attention head. Sin embargo, los transformers no suelen usar una única cabeza de atención, sino varias trabajando en paralelo. La idea es que cada cabeza pueda aprender una forma distinta de mirar el mismo contexto. Por ejemplo, una cabeza podría especializarse en ciertas relaciones gramaticales, otra en relaciones semánticas, otra en repeticiones o dependencias de largo alcance. En este sentido, multi-head attention permite que el modelo construya varias representaciones contextuales complementarias a partir de los mismos tokens de entrada.

    Siguiendo la notación de Jurafsky y Martin (2026), si tenemos AA cabezas de atención, cada cabeza cc tiene su propio conjunto de matrices aprendidas:WQc,WKc,WVcW^{Q_c}, \quad W^{K_c}, \quad W^{V_c}

    Estas matrices proyectan los vectores de entrada en queries, keys y values específicos para cada cabeza:

    qic=xiWQcq_i^c = x_i W^{Q_c}kjc=xjWKck_j^c = x_j W^{K_c}vjc=xjWVcv_j^c = x_j W^{V_c}

    para cada cabeza cc, con 1cA1 \leq c \leq A. Luego, para calcular la atención del token en posición ii, la cabeza cc compara la query qicq_i^c​ con las keys kjck_j^c​ de los tokens disponibles en el contexto:

    scorec(xi,xj)=qickjcdkscore^c(x_i,x_j) = \frac{q_i^c \cdot k_j^c}{\sqrt{d_k}}

    Estos scores se normalizan mediante una softmax, produciendo los pesos de atención:

    αijc=softmax(scorec(xi,xj))\alpha_{ij}^c = \text{softmax}(score^c(x_i,x_j))

    A partir de estos pesos, cada cabeza calcula su propia suma ponderada de los value vectors:

    headic=jiαijcvjchead_i^c = \sum_{j \leq i} \alpha_{ij}^c v_j^c

    Finalmente, las salidas de todas las cabezas se concatenan y se proyectan mediante una matriz WOW^O:

    ai=(headi1headi2headiA)WOa_i = (head_i^1 \oplus head_i^2 \oplus \dots \oplus head_i^A)W^O

    Así, la función de multi-head attention puede expresarse como:

    MultiHeadAttention(xi,[x1,,xi])=aiMultiHeadAttention(x_i, [x_1, \dots, x_i]) = a_i

    Vale la pena destacar que la salida de cada cabeza headichead_i^c tiene dimensión:

    [1×dv][1 \times d_v]

    Por lo tanto, si tenemos AA cabezas, la concatenación de todas ellas produce un vector de dimensión:

    [1×Adv][1 \times A d_v]

    Luego, la matriz WOW^O proyecta esta concatenación de vuelta al espacio del modelo:

    WOAdv×dmodelW^{O} \in \mathbb{R}^{Ad_{v}\times d_{model}}

    De esta forma, la salida final de multi-head attention conserva la dimensión esperada:

    ai1×dmodela_{i} \in \mathbb{R}^{1\times d_{model}}

    Esto es importante porque permite que la salida de la atención pueda integrarse al flujo residual del transformer y pasar a las siguientes capas.

    Transformer Blocks

    Multi-head attention es uno de los subcomponentes de un transformer block. Lo hemos explicado en un apartado especial porque es, probablemente, el componente más característico y complejo de la arquitectura. Sin embargo, un transformer block moderno incluye también otros elementos importantes, como LayerNorm, conexiones residuales y una feedforward network.

    El objetivo de esta sección es entender cómo interactúan estos subcomponentes dentro del bloque. Para ello, resulta útil introducir la idea de residual stream. En un transformer moderno, la representación de cada token no se reemplaza por completo cada vez que pasa por un subcomponente. Más bien, existe un flujo principal de información —el residual stream— que transporta la representación del token a través del bloque. Los distintos módulos leen desde ese flujo, calculan nueva información y luego suman su resultado de vuelta mediante conexiones residuales. En particular, la multi-head attention incorpora información contextual proveniente de otros tokens, mientras que la feedforward network transforma la representación de cada token de forma independiente. Este principio se ilustra en la siguiente figura:

    Como se puede ver, cada token tiene su propio flujo vertical de información. En la figura, el token principal es xix_i. Ese vector sube por el bloque, mientras algunos módulos leen desde él, calculan nueva información y la suman de vuelta al flujo principal.

    En la sección pasada explicamos el módulo de multi-head attention, sin embargo, nos falta explicar feedforward, y layernorm.

    Capa feedforward

    La capa feedforward de esta arquitectura-cuyo concepto fue explicado en esta entrada– es una red neuronal completamente conectada de dos capas, cuya capa oculta (dffd_{ff}) suele tener una dimensión más grande que la del modelo (dmodeld_{model}). Conceptualmente, la red hace algo similar a esto:

    [1×dmodel][1×dff][1×dmodel][1\times d_{model}] \to [1\times d_{ff}] \to [1\times d_{model}]

    En este caso, llevamos al vector de entrada a un espacio más grande (de dmodeld_{model} a dffd_{ff}), porque así la red tiene más “lugar” para representar combinaciones, patrones y transformaciones no lineales. Después, comprime esa información de vuelta a la dimensión del modelo (dmodeld_{model}). Esta compresión es necesaria para que sigamos trabajando con el vector en el flujo residual, de otra forma, dejaríamos el vector de salida en una dimensión que no es la que espera el subcomponente siguiente.

    Dicho esto, en términos matemáticos, este paso se puede expresar de la siguiente forma:

    FFN(xi)=ReLU(xiW1+b1)W2+b2FFN(x_{i}) = ReLU(x_{i}W_{1} + b_{1} ) W_{2} + b_{2}

    donde xi1×dmodelx_{i} \in \mathbb{R}^{1\times d_{model}}, W1dmodel×dffW_{1} \in \mathbb{R}^{d_{model}\times d_{ff}} y W2dff×dmodelW_{2} \in \mathbb{R}^{d_{ff}\times d_{model}} (Tarea: chequea que FFN(xi)1×dmodelFFN(x_{i}) \in \mathbb{R}^{1\times d_{model}}).

    Capa LayerNorm

    Layer norm (de Layer Normalization) normaliza el vector de representación de cada token para que sus valores no crezcan, se desplacen o se vuelvan demasiado extremos a medida que pasan por muchas capas.

    Si pensamos en xi1×dmodelx_{i} \in \mathbb{R}^{1\times d_{model}}, layer norm toma ese vector y calcula la media y la desviación estándar de sus propias dimensiones internas. Es decir, no normaliza toda la capa completa ni todos los tokens juntos, sino el vector individual de un token. En términos matemáticos, primero hace

    μ=1dmodelr=1dmodelxir\mu = \frac{1}{d_{model}}\sum_{r=1}^{d_{model}}x_{i}^{r}

    σ=1dmodelr=1dmodel(xirμ)2\sigma = \sqrt{\frac{1}{d_{model}}\sum_{r=1}^{d_{model}}(x_{i}^{r}-\mu)^{2}}

    Luego, normaliza:

    x^i=xiμσ\hat{x}_{i} = \frac{x_{i}-\mu}{\sigma}

    y finalmente, aplica dos parámetros aprendibles (α\alpha y β\beta):

    LayerNorm(x^i)=αxi^+β\text{LayerNorm}(\hat{x}_{i}) = \alpha \hat{x_{i}} + \beta

    Bloque completo

    Empleando la notación de Jurafsky y Martin (2026), podemos expresar los cálculos que hace un transformer block en el siguiente conjunto de ecauciones:

    ti1=LayerNorm(xi)t_{i}^{1} = \text{LayerNorm}(x_{i})

    ti2=MultiheadAttention(ti1,[t11,...tN1])t_{i}^{2} = \text{MultiheadAttention}(t_{i}^{1}, [t_{1}^{1},…t_{N}^{1}])

    ti3=ti2+xit^{3}_{i} = t_{i}^{2} + x_{i}

    ti4=LayerNorm(ti3)t_{i}^{4} = \text{LayerNorm}(t_{i}^{3})

    ti5=FFN(ti4)t_{i}^{5} = \text{FFN}(t_{i}^{4})

    hi=ti5+ti3h_{i} = t_{i}^{5} + t_{i}^{3}

    Primero, en ti1t_i^1, se normaliza la representación de entrada del token xix_i. Luego, en ti2t_i^2​, esa representación normalizada entra al módulo de multi-head attention, que calcula una contribución contextual para el token ii, usando información del propio token y de los tokens disponibles en el contexto. Después, en ti3t_i^3, la salida de attention se suma al vector original xix_i, formando la primera conexión residual.

    A continuación, en ti4t_i^4​, se normaliza esta representación actualizada ti3t_i^3​. Luego, en ti5t_i^5, la representación normalizada pasa por la red feedforward, que transforma el vector del token de forma independiente respecto de los demás tokens. Finalmente, la salida de la feedforward se suma nuevamente a ti3t_i^3, produciendo hih_i, la salida final del transformer block para el token en la posición ii.

    Sobre la entrada xix_{i}

    Hasta este punto, hemos discutido lo que hacemos con el vector xi1×dmodelx_{i} \in \mathbb{R}^{1\times d_{model}} , no obstante, no hemos dicho mucho sobre él. En esta sección veremos que no sólo es necesario el embedding, si no también, la posición de dicho vector en la secuencia.

    Supongamos que tenemos una secuencia de N tokens, donde N es el largo del contexto. El input del transformer se podría escribir como XN×dmodelX \in \mathbb{R}^{N\times d_{model}} , donde dmodeld_{model} es el largo del embedding. Para ilustrar esta matriz, a modo de ejemplo, asumiremos que cada token es una palabra, y que nuestro contexto viene dado por la siguiente frase (N=7):

    Esto me an buelto myos enemigos malos

    Dicha secuencia deberá tener 7 embeddings, que vienen dados por:

    • Esto = [x11,...,xdmodel1x_{1}^{1},…,x_{d_{model}}^{1}]
    • me = [x12,...,xdmodel2x_{1}^{2},…,x_{d_{model}}^{2}]
    • an = [x13,...,xdmodel3x_{1}^{3},…,x_{d_{model}}^{3}]
    • malos = [x17,...,xdmodel7x_{1}^{7},…,x_{d_{model}}^{7}]

    Por lo que la matriz X vendría dada por:

    X=[x11...xdmodel1.........x17...xdmodel7]X = \begin{bmatrix} x_{1}^{1} & … & x_{d_{model}}^{1} \\ … & … & … \\ x_{1}^{7} & … & x_{d_{model}}^{7} \end{bmatrix}

    En este punto, es importante entender que los embeddings obtenidos no registran ningún tipo de posición en el texto -en esta entrada podrás entender por qué-. No obstante, como podrás intuir después de leer el apartado de atención, la posición importa. En la siguiente imagen ilustramos por qué importa:

    En este caso, podemos ver que el significado de la oración cambia levemente al intercambiar el orden de las palabras. Entonces, para que el transformer enriquezca los vectores según el contexto de forma correcta, es necesario que reciba el token bajo análisis, más los tokens que lo preceden en el orden original en que aparecieron en la secuencia.

    Para llevar a cabo esta tarea, el modelo usa positional embeddings. Estos embeddings son vectores asociados a las posiciones de la secuencia. Por ejemplo, si tenemos una secuencia de N tokens, tendríamos N vectores:

    • P11×dmodelP_{1} \in \mathbb{R}^{1\times d_{model}}
    • P21×dmodelP_{2} \in \mathbb{R}^{1\times d_{model}}
    • PN1×dmodelP_{N} \in \mathbb{R}^{1\times d_{model}}

    Luego, para construir la representación final de la entrada del token en la posición ii, llevamos a cabo la siguiente suma:

    xi=E[tokeni]+Pix_{i} = E[token_{i}] + P_{i}

    donde E|V|×dmodelE \in \mathbb{R}^{|V|\times d_{model}}, es el vector de embeddings, y |V||V| es el tamaño del vocabulario.

    ¿Cómo se aprende el modelo PiP_{i}? Al inicio del entrenamiento, los vectores P1,,PNP_1,\dots,P_N​ suelen estar inicializados con valores aleatorios. Durante el entrenamiento, el modelo intenta predecir correctamente los siguientes tokens. Si la información de posición ayuda a reducir el error, los gradientes van ajustando esos vectores posicionales. Con muchas frases, el modelo aprende patrones como: qué suele pasar al comienzo de una oración, qué roles suelen tener ciertas posiciones, cómo afecta el orden a la interpretación, etc.

    The Language Modeling Head

    Hasta este punto ya sabemos lo que hace la capa del transformer. En términos coloquiales, en dicha capa entra un bloque completo de tokens como una matriz, y el transformer procesa todas las posiciones en paralelo. Gracias a la máscara causal, cada posición sólo puede usar información de los tokens anteriores y de sí misma. En términos matemáticos, una capa de transformer mapea una ventana completa de vectores de entrada (x) en vectores de salida contextualizados (h), es decir,

    (x1,x2,...,xN)(h1,h2,...,hN)(x_{1},x_{2},…,x_{N}) \to (h_{1},h_{2},…,h_{N})

    La siguiente ilustración resume parte de lo que hemos explicado si entrase la secuencia «A diestro dexan a Sant Esteuan, mas cae aluen.»

    Con estos vectores ya enriquecidos, estamos en condiciones de predecir la siguiente palabra (como lo hicimos en esta entrada). Este proceso es la función principal de un language modeling head. La siguiente imagen explica esta función en detalle:

    La figura muestra el flujo final de un transformer usado como modelo de lenguaje. Primero, una secuencia de tokens de entrada w1,w2,,wNw_1, w_2, \dots, w_N​ atraviesa el último bloque transformer, produciendo una representación contextualizada para cada posición: h1L,h2L,,hNLh_1^L, h_2^L, \dots, h_N^L​. Durante la generación, normalmente nos interesa el último vector contextualizado, hNLh_N^L​, porque contiene información del contexto disponible hasta el token NN.

    Luego, el vector hNLh_N^L entra en la language modeling head. En esta etapa, se proyecta desde el espacio interno del modelo hacia el espacio del vocabulario mediante una capa de unembedding, usualmente representada como U=ETU = E^T, donde URd×VU \in \mathbb{R}^{d \times |V|}. Matemáticamente, esta operación se expresa como u=hNLUu = h_N^L U, con uR1×Vu \in \mathbb{R}^{1 \times |V|}. El vector resultante uu contiene los logits, es decir, un puntaje no normalizado para cada token posible del vocabulario. Finalmente, una función softmax transforma esos logits en una distribución de probabilidad yR1×Vy \in \mathbb{R}^{1 \times |V|}. Cada componente de yy indica la probabilidad que el modelo asigna a un posible siguiente token.

    Fase de entrenamiento

    En el entrenamiento de un transformer causal, el modelo aprende a predecir el siguiente token en cada posición de una secuencia, es decir,

    (aluen.|A diestro dexan a Sant Esteuan, mas cae aluen).\mathbb{P}(\text{aluen.} | \text{A diestro dexan a Sant Esteuan, mas cae aluen}).

    Para ello, produce una distribución de probabilidad sobre el vocabulario y se compara esa distribución con el token correcto. La pérdida usada es la cross-entropy loss, que penaliza al modelo cuando asigna baja probabilidad al token real siguiente (si has seguido esta serie de publicaciones, esto te resultaría familiar, en caso contrario, visita esta entrada). Luego, mediante gradient descent, los pesos del modelo se ajustan para reducir esa pérdida promedio.

    Es importante precisar que, durante el entrenamiento, el modelo no procesa todo el corpus completo de una sola vez. En la práctica, se define una ventana de contexto —por ejemplo, de algunos miles de tokens— y el corpus se organiza en segmentos o chunks que caben dentro de esa ventana. Si un documento es más corto que la ventana, pueden empaquetarse varios documentos en un mismo chunk, normalmente separados por un token especial. Cada chunk se procesa como una secuencia de entrenamiento: el modelo recibe sus tokens y aprende a predecir el siguiente token en cada posición, respetando la máscara causal. De esta manera, puede entrenarse sobre corpus muy grandes, pero siempre mediante fragmentos de tamaño limitado por su ventana de contexto.

    Notas finales, y próxima entrada…

    En esta entrada vimos el concepto de transformers con mayor profundidad. Esta idea ha sido fundamental en la construcción de los LLM’s más famosos, por tanto, su lectura es obligatoria.

    En la próxima publicación veremos prácticas asociadas al post-entrenamiento, tales como instuction tuning y alignment. Stay tuned!

    José Miguel Muñoz Urra – jmunozu@pulki.es

  • Large Language Model (LLM) from scratch. Large Language Models.

    En esta entrada introduciremos los LLMs. Al igual que el resto de las publicaciones relacionadas con este tópico, seguiremos fielmente el libro de Jurafsky y Martin (2026), puesto que es extremadamente ilustrativo. En esta línea, partiremos la presente entrada definiendo los LLM’s tal y como lo hacen estos autores en el capítulo 7. En particular, los definen como agentes que pueden interactuar conversacionalmente con gente.

    Naturalmente, para que esos agentes sean funcionales, no sólo hay que tener una estrategia estadística, si no también, conjuntos de dato especiales. En esta publicación, veremos dicha estrategia y el tipo de datos necesario para que este agente sea funcional.

    La estructura de esta publicación es la siguiente. Primero definiremos algunos conceptos básicos detrás de la construcción de un LLM; luego veremos cuáles son las arquitecturas estándar utilizadas para construir uno; después introduciremos el concepto de prompting; y finalmente uniremos todas las piezas para entender el flujo habitual de entrenamiento de un LLM.

    1) Introduciendo conceptos…

    ¿Qué es un LLM?

    Un Large Language Model (LLM) es sólo un modelo de lenguaje en una escala relativamente más grande. En este sentido, vale la pena recordar qué es un modelo de lenguaje. En pocas palabras, es un sistema computacional que predice la siguiente palabra dada palabras previas, o contexto. Para llevar a cabo esta predicción, se realiza una etapa de preentrenamiento.

    Preentrenamiento

    Preentrenamiento es la primera gran etapa de entrenamiento de un LLM. Consiste en tomar una cantidad enorme de texto y entrenar al modelo para predecir el siguiente token/palabra dado el contexto anterior. La idea clave es que, al hacer esto millones o billones de veces, el modelo aprende patrones de lenguaje, gramática, semántica, hechos del mundo, asociaciones entre conceptos y regularidades del discurso. En la siguiente sección veremos esto con mayor detalle.

    ¿Qué pueden aprender los LLM de la predicción del siguiente token?

    Como sabemos, LLM como Claude, ChatGPT o Gemini son capaces de responder preguntas de matemáticas, geografía, gramática, etc. Esto es posible en gran medida gracias a la etapa de preentrenamiento. ¿Por qué? Imaginemos que el corpus sobre el que estamos trabajando contiene repetidamente las siguientes oraciones:

    • Dos más dos es cuatro.
    • La capital de Chile es Santiago.

    Luego, después del preentrenamiento, si queremos calcular cuál es el token que tiene la probabilidad más alta condicional a la cadena »Dos más dos es », es decir,

    maxxiX\max_{x_{i} \in X}P(xix_{i} | »Dos más dos es »)

    donde XX es el conjunto de todas las palabras de nuestro vocabulario, es de esperar que xi=“cuatro”x_{i} = \text{«cuatro»} sea el token que maximice esa expresión. Este mismo ejercicio se puede repetir con la siguiente frase:

    maxxiX\max_{x_{i} \in X}P(xix_{i} | »La capital de Chile es »)

    En este caso, “Santiago” sería la opción esperable. Y así sucesivamente. Por tanto, mediante esta estrategia el sistema puede aprender relaciones estadísticas y semánticas presentes en el lenguaje, lo que le permite responder preguntas sobre matemáticas, geografía, gramática, entre muchos otros temas.

    Generación de texto

    En el apartado anterior vimos que un modelo de lenguaje puede predecir la siguiente palabra. Empleando esta misma estrategia, también es posible generar cadenas de texto. En particular, la generación se lleva a cabo mediante muestreo desde la distribución de probabilidad de la siguiente palabra. Esta idea es uno de los fundamentos de la IA generativa.

    Habiendo explicado en grandes rasgos lo que es un LLM y cómo funciona, en el resto de la entrada veremos sus principios computacionales.

    2) Arquitectura para modelos de lenguaje

    Las tres arquitecturas más comunes en los modelos de lenguaje modernos son encoder, decoder y encoder-decoder.

    Decoder

    Un decodificador toma como entrada una serie de tokens y, de manera iterativa, predice el siguiente token de la secuencia. Un ejemplo de modelo con una arquitectura decoder-only es GPT-2.

    La siguiente imagen resume su arquitectura:

    A pesar de que en las próximas publicaciones veremos con mayor profundidad cómo trabaja un decoder, podemos adelantar brevemente qué ocurre en una capa de GPT-2. Antes de entrar a la pila de decoders, el texto de entrada se convierte primero en una secuencia de tokens. Luego, cada token se transforma en un embedding, es decir, en una representación vectorial. Por tanto, si el input contiene tres tokens, el modelo trabajará inicialmente con tres vectores. En el caso de GPT-2 small, la dimensionalidad del modelo es 768, por lo que cada uno de esos vectores contiene 768 valores numéricos.

    Una vez dentro de la capa decoder, estos vectores son modificados mediante mecanismos como la masked self-attention y una red feedforward. El resultado son representaciones más ricas y dependientes del contexto. A estas representaciones podemos llamarlas vectores contextualizados: son versiones actualizadas de los vectores iniciales, ajustadas según el lugar que cada token ocupa dentro de la secuencia. En otras palabras, el modelo ya no representa cada token de forma aislada, sino en función del contexto disponible. Para más información sobre los modelos GPT puedes consultar esta web.

    Encoder

    Un encoder toma como input una secuencia de tokens, y genera como output una representación vectorial de cada uno. La diferencia clave con GPT-2 es que el encoder no genera el siguiente token de izquierda a derecha. En cambio, procesa la secuencia completa y produce una representación enriquecida de cada token. En otras palabras, un encoder transforma cada token en una representación ajustada por el contexto completo. Es decir, el modelo no representa cada token de manera aislada, sino considerando cómo se relaciona con los demás tokens de la secuencia.

    Los modelos BERT son conocidos por usar esta arquitectura. Si quieres saber más sobre ellos, puedes consultar esta web.

    Encoder-decoder

    Finalmente, un encoder-decoder toma como entrada una secuencia de tokens y como salida múltiples tokens. Esta arquitectura se usa especialmente cuando el input y el output pueden ser secuencias distintas: por ejemplo, traducción automática, resumen, o reconocimiento de voz. Un ejemplo de un modelo encoder-decoder es el BART. En esta web puedes encontrar información sobre él.

    3) Generación condicional de texto

    La generación condicional de texto consiste en darle al modelo una secuencia inicial, llamada prompt, y pedirle que continúe generando texto a partir de ella. En otras palabras, el modelo no genera texto »desde cero», sino condicionado por los tokens que ya recibió como entrada.

    La idea básica es la siguiente. Dado un prompt, el modelo calcula una distribución de probabilidad para el siguiente token. Luego selecciona uno de esos tokens y lo agrega al texto. Después repite el mismo procedimiento: vuelve a calcular la distribución del siguiente token, ahora considerando tanto el prompt original como el token que acaba de generar. Así, el texto se produce paso a paso, token por token.

    Por ejemplo, si entregamos al modelo el siguiente prompt (ejemplo sacado de Jurafsky y Martin (2026)):

    The sentiment of the sentence “I like Jackie Chan” is:

    el modelo debería generar como continuación algo como:

    positive

    ¿Por qué? Porque, dado ese contexto, es esperable que el token »positive» tenga una probabilidad mayor que alternativas como »negative». En términos simples, el modelo compara cuál continuación es más probable bajo el contexto entregado:

    P(»positive»∣prompt)>P(»negative»∣prompt)

    Esta misma lógica puede utilizarse para responder preguntas. Por ejemplo, si escribimos:

    Q: Who wrote the book “The Origin of Species”?
    A:

    el modelo interpretará que debe completar la respuesta después de A:. En ese caso, una continuación probable sería:

    Charles Darwin

    Por tanto, la generación condicional permite transformar una gran variedad de tareas —clasificación, traducción, resumen o respuesta a preguntas— en un mismo problema general: dado un contexto, predecir una continuación probable. En la siguiente sección veremos cómo esta idea se puede perfeccionar mediante mejores estrategias de prompting.

    4) Prompting

    ChatGPT, Claude y otros LLM modernos, además de predecir texto, están entrenados para responder preguntas y seguir instrucciones. Una de las técnicas más importantes para lograr este comportamiento es el instruction-tuning.

    Instruction-tuning

    Después de entrenar un modelo de lenguaje para predecir el siguiente token, es posible continuar su entrenamiento utilizando un conjunto de datos compuesto por pares instrucción-respuesta. Este proceso suele llamarse instruction-tuning.

    La idea es exponer al modelo a ejemplos donde una instrucción dada por un usuario va acompañada de una respuesta esperada. De esta forma, el modelo aprende patrones sobre cómo responder preguntas, seguir órdenes, resumir textos, traducir contenido, entre muchas otras tareas.

    Un ejemplo práctico de esta idea puede encontrarse en el proyecto Latam-GPT. En la siguiente página publican algunas de las instrucciones utilizadas para entrenar el modelo. Una instrucción que podemos encontrar en esta web viene dada por la siguiente:

    [ { «content»: «Necesito ideas para una acción comunitaria visual que muestre el impacto del abandono rural en los territorios de montaña, que pueda organizar con vecinos en una localidad de Castilla-La Mancha esta semana.», «role»: «user» }, { «content»: «¡Qué hermosa iniciativa! El abandono rural en zonas de montaña de Castilla-La Mancha —como en los municipios de la Sierra de Alcaraz, la Serranía de Cuenca, o las sierras de Toledo— es un tema profundamente humano y visualmente potente. Aquí tienes una propuesta de **acción comunitaria visual** que puedes organizar esta semana con vecinos, sencilla de implementar, con gran impacto emocional y simbólico, y que puede convertirse en un \»documento visual\» del territorio:\n\n—\n\n### 🎨 **Acción Comunitaria Visual: \»El Silencio que Habla\»**\n\n#### 📌 **Concepto central:**\nCrear una **instalación artística en espacios públicos** (plaza, camino, escuela, iglesia) que muestre el abandono rural a través de **imágenes y objetos del pasado** que han sido dejados atrás, pero que aún \»hablan\» del territorio.\n\n—\n\n### ✅ **Cómo hacerlo (pasos prácticos para esta semana):**\n\n#### 1. **Recolectar objetos simbólicos (antes del sábado):**\n- Invita a vecinos a traer **objetos de sus casas, granjas, escuelas o almacenes abandonados** que representen la vida rural: \n – Una puerta de madera con herrajes oxidados. \n – Una silla de madera desvencijada. \n – Un sillín de caballo. \n – Una campana de ganado. \n – Un libro escolar viejo. \n – Una maquinaria agrícola pequeña (si está en buen estado). \n – Láminas de revistas antiguas con publicidad de productos rurales.\n\n> 📌 **Importante:** No se trata de \»tirar\» objetos, sino de rescatarlos y darles nuevo sentido.\n\n#### 2. **Crear una \»Casa abandonada en el pueblo\» (instalación visual):**\n- Elige un espacio público (plaza, jardín, camino de acceso principal).\n- Construye una **estructura de madera o cartón** con forma de casa pequeña, como si fuera una casita de muñecas, pero desvencijada: puerta torcida, ventanas rotas, techo medio caído.\n- Coloca los objetos recolectados **dentro y alrededor** de la casa.\n- Añade **fotografías enmarcadas** de personas del pueblo en sus trabajos (labores agrícolas, escuela, fiestas, mercado).\n- Puedes iluminarla con luces de bajo consumo para que se vea de noche.\n\n#### 3. **Incorpora una \»voz\» del abandono:**\n- Pide a personas mayores o antiguos vecinos que **graben un audio** (en 1 minuto) contando:\n – Una memoria del pueblo.\n – Una reflexión sobre el cambio.\n – Una pregunta: *\»¿Dónde está el niño que jugaba aquí?\»*\n- Coloca un **altavoz con sonido ambiental** (viento, palomas, silencio) y reproduzca el audio en bucle.\n\n#### 4. **Involucra a los más jóvenes:**\n- Organiza un **taller de dibujo o pintura** con niños y jóvenes: que dibujen **\»el pueblo que quisieran tener\» o \»el pueblo que se fue\»**.\n- Expón los dibujos en una pared o en carteles alrededor de la instalación.\n\n#### 5. **Llamado al pueblo (difusión):**\n- Crea un **cartel con el título: \»El Silencio que Habla\»** y el mensaje: \n > *\»Este pueblo no se ha ido. Está aquí, en lo que dejó. ¿Qué hacemos con esto?\»* \n- Colócalo en la entrada del pueblo, redes sociales, y en la web del ayuntamiento (si hay).\n\n—\n\n### 📸 **¿Qué se ve? (Impacto visual):**\n- Una casa vacía que grita con objetos.\n- Fotos que recuerdan.\n- Voces que susurran el pasado.\n- Dibujos que imaginen el futuro.\n\n👉 Es una **instalación efímera** (para que dure solo 7 días), pero que deja huella.\n\n—\n\n### 🌱 **Posibles seguimientos (una vez terminada la acción):**\n- Publicar una **galería fotográfica** del proyecto en redes o en el periódico local.\n- Proponer al ayuntamiento una **\»Ruta del Abandono y la Memoria\»** con puntos como este.\n- Iniciar un **archivo oral** de historias del pueblo.\n\n—\n\n### 💬 Frase clave para usar:\n> *\»No es que el pueblo se haya ido. Es que lo que queda es lo que más importa.\»*\n\n—\n\n### ✅ Beneficios de esta acción:\n- Fácil de organizar con pocos recursos. \n- Incluye a todas las edades. \n- Genera diálogo sobre el futuro del territorio. \n- Se puede hacer en una finca, en el centro del pueblo o en un camino de montaña.\n\n—\n\n¿Te gustaría que te ayude a crear el cartel, el cartel de invitación, o el guion para el audio? Puedo hacerlo ahora mismo. ¡Tu iniciativa puede inspirar a otros pueblos de Castilla-La Mancha! 💚🌄», «role»: «assistant» } ]

    Este ejemplo tiene exactamente la estructura típica de un dato usado para instruction tuning: un usuario formula una instrucción y el asistente entrega una respuesta considerada adecuada. Al entrenar con muchos ejemplos de este tipo, el modelo aprende no sólo a continuar texto, como en el pretraining, sino a comportarse como un asistente: interpretar solicitudes, organizar respuestas, seguir instrucciones y adoptar un formato conversacional.

    Una vez que el modelo ha sido entrenado con esta base de datos, puede ser debidamente prompteado.

    Prompt

    Es una cadena de texto que envía el usuario a un modelo de lenguaje para producir alguna respuesta útil. Si bien el objetivo principal de los prompts es generar texto, también cumplen el rol de entender si el modelo ha aprendido correctamente, es por esto, que este concepto será especialmente importante para este proyecto.

    En el contexto del prompting, también aparece el concepto de in-context learning, que es el tipo de »aprendizaje» que ocurre cuando el modelo mejora su comportamiento gracias a la información contenida en el prompt, pero sin actualizar sus parámetros. Es decir, el modelo parece adaptarse a una nueva tarea porque el prompt le dio suficientes pistas, pero internamente sus pesos no cambiaron.

    Por último, también está el concepto de system-prompt, que es un prompt normalmente invisible para el usuario, que define el rol general del modelo, su tono y sus instrucciones de comportamiento. Por ejemplo, puede decirle al modelo que actúe como asistente útil, que responda de forma clara, que mantenga cierto tono o que siga ciertas restricciones. Ese system prompt se antepone al mensaje del usuario, de modo que el modelo genera su respuesta condicionado por ambos: primero las instrucciones del sistema y luego el mensaje del usuario.

    Generación y sampleo

    En la sección sobre generación condicional de texto vimos que la estrategia estándar para generar el siguiente token viene dada por una probabilidad condicional, es decir, si tenemos un conjunto X de palabras candidatas para ser la próxima en nuestro proceso de generación:

    X = {»solución», »imagen»,»respuesta»,»idea»},

    un criterio natural para elegir la siguiente palabra que viene después de la cadena »Hoy vamos a crear una », es mediante el siguiente cálculo

    P(x | »Hoy vamos a crear una »),

    que se puede ilustrar como

    En este punto, necesitamos una estrategia de elección. Evidentemente, el criterio más intuitivo es elegir la/el palabra/token que tiene la probabilidad más alta de seguir en la secuencia, es decir, en este caso sería »solución», con un 41%. Este criterio se llama greedy decoding. En la siguiente sección lo veremos en detalle.

    Greedy decoding

    Formalmente, en este método elegimos el token con la probabilidad más alta en cada iteración. Si bien dicha estrategia parece ser la correcta -dada la tendencia que tenemos de elegir óptimos locales-, lo cierto es que este método no es muy usado en LLMs. ¿La razón? Produce salidas extremadamente deterministas, y los textos que produce parecen genéricos. Por este motivo, se suelen implementar otros métodos, como random sampling, que explicaremos en la siguiente sección.

    Random sampling

    Este procedimiento implica seleccionar el próximo token sampleando la distribución sobre la próxima palabra. En términos concretos, ilustramos este principio en la siguiente imagen:

    Como podemos ver, primero debemos tener cada token/palabra con su respectiva probabilidad. En este punto, las probabilidades suman 1, puesto que la softmax ya fue aplicada sobre el vector logit (si no entendiste nada de esto, revisa la sección «Estructura de una red neuronal» de esta entrada). Luego, tomamos un número aleatorio de una distribución uniforme con soporte [0,1], y vemos en qué punto de la cdf cayó. Dependiendo de la región, será la próxima palabra que elegiremos.

    A pesar de que este método añade novedad a las respuestas, tampoco funciona muy bien, puesto que los textos que produce son algunas veces demasiado aleatorios. El principio de esta idea se muestra en la siguiente imagen:

    Como podemos ver, después de la distribución inicial, si el número que salió de la distribución uniforme es consistente con la palabra »solución», entonces ahora el nuevo contexto es »Hoy vamos a crear una solución ». Luego, si queremos generar la palabra siguiente, la distribución parece más idónea, o al menos, las palabras siguientes son relativamente idóneas comparadas al caso 2B. En este caso, si por algún motivo el número que sale de la distribución uniforme favorece a la palabra »idea», las palabras siguientes generan una oración menos elocuente. En la siguiente sección veremos una solución intermedia: temperature sampling.

    Temperature sampling

    Este método cambia la forma de la distribución de probabilidad, por tanto, según un parámetro que usualmente es τ\tau, podemos aumentar la probabilidad de las palabras/tokens con probabilidad alta, y disminuir la probabilidad de las palabras con probabilidad baja. Esto, se hace mediante la siguiente transformación (nuevamente, si no entiendes esto, debes leer esta entrada)

    y=softmax(uτ)y = softmax(\frac{u}{\tau})

    Luego, en la siguiente lámina mostramos como quedaría la distribución de probabilidad para distintos valores de τ\tau:

    Como podemos ver, un valor alto de τ\tau (en nuestro ejemplo, τ=2.0\tau=2.0), aplana la distribución, haciéndola similar a la uniforme, mientras que valores bajos hacen que el extremo superior acumule más masa, y el inferior menos. Esto es muy útil, puesto que el modelo ahora tendrá espacio para generar cadenas de texto relativamente más creativas que greedy decoding (para τ<1\tau <1), pero más elocuentes que random sampling.

    5) Entrenando LLM’s

    Hasta ahora, hemos descrito conceptos clave para entender qué es un LLM. En esta sección veremos cómo algunas de esas partes se organizan, para conformar el proceso de entrenamiento de este.

    Según nuestra referencia, Jurafsky y Martin (2026), existen tres grandes etapas, pretraining, instruction tuning y alignment. A continuación explicaremos cada una de ellas.

    5.1) Pretraining

    Esta es la primera etapa del entrenamiento, y consta de entrenar al modelo para predecir la siguiente palabra sobre un corpus de texto gigante. En términos estadísticos, en esta etapa se usan las herramientas vistas en esta entrada, es decir, para llevarla a cabo es preciso entender ideas como neural networks, cross-entropy loss, etc. Con respecto al volumen de datos, es enorme. Para que te hagas una idea, GPT-3 fue entrenado con 300 billones de tokens (ver footnote de la tabla 2.1), es decir, si aproximamos 1 token como 4 caracteres, y 1 carácter como 1 byte, llegamos a una cifra estimativa de 1200GB de texto plano (debes ser cauteloso con esta cifra, es una aproximación, y como tal, puede variar).

    5.2) Instruction tuning (o Supervised finetuning)

    En esta etapa el modelo es entrenado nuevamente, pero esta vez para seguir instrucciones tales como escribir código, traducir oraciones, etc. Como vimos anteriormente, es entrenado en un corpus especial, similar al que mostramos en la sección instruction-tuning.

    5.3) Alignment

    Como lo expresa su palabra en inglés, esta etapa alinea el modelo para evitar que sea dañino, y mejorar su utilidad. En este punto, los investigadores usan un conjunto de datos que consiste en un contexto y dos potenciales continuaciones, una continuación aceptada y otra rechazada, cuya continuación es generalmente curada por seres humanos. Entonces, el modelo es entrenado para seguir la aceptada, y no la rechazada.

    En conjunto, estas tres etapas permiten construir los LLM modernos. Durante el pretraining, el modelo aprende regularidades estadísticas, sintácticas y semánticas del lenguaje a partir de enormes cantidades de texto. Luego, mediante instruction tuning, aprende a responder instrucciones de forma más útil para los usuarios. Finalmente, en la etapa de alignment, el modelo es ajustado para generar respuestas más seguras, útiles y alineadas con preferencias humanas.

    A pesar de toda esta complejidad, la idea fundamental sigue siendo sorprendentemente simple: en cada paso, el modelo intenta predecir cuál es el siguiente token más probable dado el contexto disponible. Sin embargo, cuando esta estrategia se escala a corpus gigantescos, redes neuronales profundas y grandes capacidades computacionales, emergen habilidades que hacen posible sistemas como ChatGPT, Claude o Gemini.

    Notas finales, y próxima entrada…

    En esta entrada vimos los componentes básicos de un LLM, y en qué consiste su entrenamiento. Todos estos conceptos serán vistos en detalle en las próximas entradas, no obstante, antes de abordarlos era preciso tener una imagen panorámica de cómo se interconectan. Stay Tuned!!

    José Miguel Muñoz Urra – jmunozu@pulki.es

  • Large Language Model (LLM) from scratch. LLMedieval.

    En la entrada anterior desarrollamos un ejemplo de clasificación de texto usando redes neuronales. En particular, nuestro objetivo fue determinar si un texto estaba escrito en castellano medieval o no.

    En dicho ejercicio estimamos el modelo con textos escritos en castellano medieval desde esta web y textos escritos en castellano moderno provenientes de la iniciativa “Santiago en 100 Palabras”, por tanto, la predicción es parcialmente correcta sólo si son ingresados textos distintos a los usados para el entrenamiento, pero provenientes de estas fuentes. Por el contrario, si ingresamos textos cuya morfología es similar a la del castellano medieval, que no son de la iniciativa “Santiago en 100 Palabras”, y que no han sido escritos en castellano medieval (como textos en portugués), es muy probable que sean erróneamente clasificados como medieval.

    Evidentemente, para responder si un texto está escrito en castellano medieval o no, es necesario tener un corpus suficientemente grande, que entre otras cosas, incluya múltiples idiomas. Con esta precisión en mente, presentamos la versión en línea del clasificador: https://pulki.es/medieval/clasificar.php. No obstante, además del clasificador, esta página albergará un proyecto que llamaremos LLMedieval. En la siguiente sección te explicamos de qué se trata.

    LLMedieval

    LLMedieval será un Large Language Model cuyo principal objetivo es generar texto en castellano medieval. Este proyecto tiene fines estrictamente didácticos, sin embargo, su realización puede ser útil para:

    • Investigación filológica y lingüística
    • Apoyo a edición y transcripción de manuscritos
    • Educación y divulgación
    • Humanidades digitales
    • Recreación histórica y creación de contenido
    • Clasificación y atribución estilística

    El proceso se irá datando en https://pulki.es/medieval/. Stay tuned!

    José Miguel Muñoz Urra – jmunozu@pulki.es

  • Large Language Model (LLM) from scratch. Neural Networks: Ejemplo práctico.

    En la entrega pasada explicamos algunos conceptos clave para entender redes neuronales. Dada su importancia, en la presente veremos si una red neuronal puede distinguir entre fragmentos de castellano medieval y de español moderno. Además, con este ejemplo en mente, compararemos la performance entre una red neuronal y una regresión logística.

    Para esto, construiremos un pequeño dataset que consistirá en textos escritos en castellano contemporáneo y medieval. Luego, con esos datos, estimaremos una regresión logística cuyo objetivo principal será clasificar si un texto es medieval o no. Finalmente, emplearemos una red neuronal para llevar a cabo la misma tarea, y compararemos cuál de los dos hace un mejor trabajo de clasificación.

    Construyendo el dataset

    Para estimar ambos modelos es necesario contar con textos escritos en castellano medieval y contemporáneo. Los textos contemporáneos serán 498 cuentos pertenecientes a la iniciativa “Santiago en 100 Palabras”, y se pueden encontrar en la siguiente página web. Cada uno está compuesto por aproximadamente 100 palabras, y son escritos por personas que describen la vida en la ciudad. En algunas ocasiones el tono es formal, y en otras relativamente más coloquial, no obstante, siempre están escritos en castellano contemporáneo.

    Para las observaciones de castellano medieval, tomaremos textos desde la siguiente página web, y extraeremos 500 fragmentos de 100 palabras. Uno de ellos, es el siguiente:

    que dizen acuzant e es que sea lo que tuellen mucho e uerde Estas son las sennales de la cançre que seles faze en los quexares e es que seles camia lo que tuellen de manera que lo que tuellen una uegada no es atal como lo que tuellen otra e es buelto de blanco e de uermeio Estas son las sennales del mal del yesso e de la uentosidad que seles faze en los cuerpos e es que lo quetuellen es taiado e a en ello amarellura e lo que se quaia dello fazesse duro e aspero de manera

    Como podemos ver en el fragmento de ejemplo, no hay signos de puntuación. Aunque en los textos originales sí aparecen, en este ejercicio los hemos eliminado para reducir la complejidad. No obstante, en el futuro los tendremos en cuenta.

    Regresión Logística

    Dado el dataset que tenemos, el siguiente paso es definir las variables independientes. En este punto hay varias alternativas. La primera sería seguir la estrategia que empleamos en una publicación anterior, es decir, usar como regresores palabras típicas del castellano medieval y del castellano contemporáneo. No obstante, como ya vimos, esta no es una buena idea: se trata de casos específicos que no siempre estarán presentes en los fragmentos y que, además, no resultan suficientemente informativos sobre la morfología del castellano medieval ni del contemporáneo.

    En esta publicación intentaremos con otra estrategia. En lugar de definir regresores manualmente, construiremos automáticamente los rasgos a partir de trigramas de caracteres y los ponderaremos mediante TF-IDF (term frequency–inverse document frequency). Para ello, tomamos todos los textos del conjunto de entrenamiento —tanto medievales como no medievales—, extraemos sus trigramas y calculamos, para cada documento dd y cada trigrama gg, el peso

    tfidf(g,d)=tf(g,d)idf(g)tfidf(g,d) = tf(g,d)\cdot idf(g)
    idf(g)=log(N+1df(g)+1)+1idf(g) = \log\bigg(\frac{N+1}{df(g) + 1}\bigg) +1

    Aquí, gg representa un trigrama, dd un documento del corpus de entrenamiento, NN el número total de documentos y df(g)df(g) la cantidad de documentos en que aparece ese trigrama. La intuición es simple: si un trigrama aparece muchas veces en un texto, su peso aumenta; si además aparece en pocos documentos del corpus, su peso aumenta todavía más. En cambio, si aparece en casi todos los documentos, su peso disminuye, porque aporta poca información distintiva. En nuestra implementación, una vez calculados estos pesos, el vector resultante se normaliza con norma L2L_2, de modo que los documentos puedan compararse en una misma escala.

    Entonces, el número total de regresores será el total de n-gramas que fueron obtenidos desde los textos de entrenamiento, y el valor de los regresores vendrá dado por tfidf(g,d)tfidf(g,d). A modo de ejemplo, supongamos que nuestro corpus de entrenamiento consta de un texto medieval y otro no medieval. El medieval viene dado por:

    E los demas de los sabios de los turcos otro si se trabaiaron dend. e el mas nombrado Rey.

    y el no medieval por:

    Nunca me dejó. Decía que no podía respirar
    con ellos cerca. Un día desapareció. Ya no volvió.

    Luego, al calcular los trigramas de ambos textos, tenemos:

    trigramafrecuencia
    Elo1
    los4
    osd2
    sde3
    dem1
    ema1
    ..
    ..
    ..

    Esto implica que la regresión logística viene dada por:

    σ(z)=w1Elo+w2los+w3osd+w4sde+w5dem+...+\sigma(z) = w_{1}\cdot Elo + w_{2}\cdot los + w_{3}\cdot osd + w_{4}\cdot sde + w_{5}\cdot dem + … +

    donde cada regresor es la función tfidf(g,d)tfidf(g,d), para ese trigrama gg. En la siguiente sección calcularemos los trigramas para el corpus de entrenamiento real, y su respectivo tfidf(g,d)tfidf(g,d).

    Calculando trigramas

    La estrategia que emplearemos será computar los trigramas para cada observación. En otras palabras, para cada texto medieval y no medieval, calcularemos trigramas. Al hacer esto, obtenemos lo siguiente:

    Número total de documentos998
    Documentos medievales500
    Documentos modernos498
    Número de trigramas distintos7.396

    Lo anterior implica que tendremos 7.396 regresores. Respecto a aquellos, los 9 que tienen mayor TF-IDF promedio (1Nd=1Ntfidf(g,d)\frac{1}{N} \sum_{d=1}^{N} tfidf(g,d)) son:

    TrigramasTF-IDF promedio
    ‘as ‘0.095239
    ‘ de’0.090066
    ‘ e ‘0.085153
    ‘os ‘0.073972
    ‘ qu’0.069798
    ‘en ‘0.068841
    ‘es ‘0.068074
    ‘ la’0.065219
    ‘que’0.062593

    En este sentido, vale la pena destacar que el valor del regresor para esa estimación es tfidf(g,d)tfidf(g,d), no el promedio que presentamos en la tabla. Con tfidf(g,d)tfidf(g,d) calculado, estamos en condiciones de estimar los parámetros de la regresión logística.

    Regresión Logística

    Una vez estimada la regresión logística, un ejercicio útil es identificar qué trigramas tienen coeficientes positivos, y cuales tienen negativos. Esto nos permite determinar aquellos trigramas que empujan un texto a ser medieval (coeficientes positivos) o a ser no medieval (coeficientes negativos). En este sentido, los top 9 positivos vienen dados por:

    TrigramaCoeficiente
    ‘ e ‘4.525484
    ‘as ‘2.379491
    ‘es ‘2.010735
    ‘ qu’1.767268
    ‘en ‘1.765731
    ‘s e’1.726395
    ‘las’1.568585
    ‘que’1.291647
    ‘ de’1.291306

    y los top 9 negativos por:

    TrigramaCoeficiente
    ‘ y ‘-1.373146
    ‘me ‘-1.094712
    ‘a, ‘-0.959669
    ‘o, ‘-0.955032
    ‘ía ‘-0.931607
    ‘ ve’-0.860371
    ‘s, ‘-0.849333
    ‘a. ‘-0.823249
    ‘ ha’-0.808638

    como podemos ver a simple vista, al parecer, la regresión logística no ha sido particularmente buena en detectar combinaciones de caracteres propias del castellano medieval, puesto que las combinaciones que más empujan a medieval, son muy similares a las que podríamos ver en textos no medievales. En la siguiente sección, testearemos esta intuición de una forma más rigurosa.

    Testeando rendimiento

    Para testear el rendimiento de la estimación emplearemos 200 textos medievales que no están en el training set, y luego veremos qué porcentaje es correctamente clasificado como medieval (desde aquí en adelante,recall medieval).

    Número de textos medievales en test200
    Textos medievales predichos correctamente como medievales150
    Porcentaje de acierto sobre textos medievales75%
    Recall medieval0.75

    La tabla anterior muestra que de 200 textos medievales, el modelo acierta correctamente a 150. Para poder analizar el rendimiento en términos relativos, tenemos que hacer el mismo ejercicio usando redes neuronales. En la siguiente sección llevaremos a cabo esto.

    Redes Neuronales

    La arquitectura que emplearemos será una red neuronal feedforward con dos capas ocultas. La primera tendrá 128 neuronas, y la segunda 64. Además, la función de activación será ReLU. Al estimarla, obtenemos los siguientes resultados:

    Número de textos medievales en test200
    Textos medievales predichos correctamente como medievales200
    Porcentaje de acierto sobre textos medievales100%
    Recall medieval1

    Como podemos ver en nuestro experimento, la red neuronal superó a la regresión logística al clasificar correctamente todos los textos medievales del conjunto de prueba, mientras que la regresión logística identificó solo el 75%. Una explicación plausible es que la regresión logística combina los trigramas de manera lineal: cada rasgo aporta de forma independiente a la decisión final. La red neuronal, en cambio, puede aprender combinaciones no lineales entre rasgos ortográficos, morfológicos y gráficos del castellano medieval. Eso le permite captar patrones más complejos, como interacciones entre secuencias de caracteres, que un modelo lineal no representa bien. En esta tarea, esa flexibilidad parece haber marcado la diferencia.

    Debido a que el modelo es relativamente exitoso, sería interesante explorar si es capaz de detectar peculiaridades del castellano medieval. Una manera de hacerlo es identificar qué trigramas son los que empujan un texto a ser medieval o no. A pesar de que no podemos obtener este número de forma directa (como en el caso de la regresión logística), es posible llevar a cabo una aproximación, que implica multiplicar las matrices de los pesos para obtener una especie de efecto agregado desde la entrada hasta la salida. Al hacer esto, podemos ver que los trigramas que parecen empujar más hacia medieval son:

    TrigramaCoeficiente
    ‘tvl’0.255863
    ‘ças’0.253850
    ‘sel’0.250878
    ‘rau’0.248537
    ‘sea’0.244449
    ‘hd ‘0.241685
    ‘çer’0.235730
    ‘son’0.234452
    ‘xii’0.233908

    y los que empujan más hacia moderno son:

    TrigramaCoeficiente
    ‘ón ‘-0.245191
    ‘, p’-0.220347
    ‘mis’-0.220332
    ‘ ve’-0.218641
    ‘, s’-0.216067
    ‘ja ‘-0.216041
    ‘adr’-0.215468
    ‘tió’-0.214826
    ‘ame’-0.213638

    Estos resultados muestran una foto distinta a la obtenida con la regresión logística. En particular, podemos ver que el carácter ‘ç‘, que se usó en el español medieval, pero luego fue abandonado tras el reajuste de las sibilantes durante los siglos XVI y XVII, aparece de forma más frecuente en los trigramas que empujan un texto a ser medieval, lo que claramente es una buena señal, puesto que es una peculiaridad que pertenece exclusivamente al castellano medieval, cuando es comparado con el moderno.

    Notas finales, y próxima entrada…

    En el siguiente ejercicio ponemos a prueba la capacidad de la regresión logística y de una red neuronal feedforward para clasificar textos medievales. Los resultados muestran que la red neuronal fue relativamente más eficaz. En particular, logró clasificar correctamente todos los textos medievales del conjunto de prueba, mientras que la regresión logística solo identificó correctamente el 75% de ellos.

    En la próxima entrega, introduciremos los Large Language Models. Stay tuned!

    José Miguel Muñoz Urra – jmunozu@pulki.es

  • Large Language Model (LLM) from scratch. Neural Networks.

    En esta entrada introduciremos las redes neuronales, una pieza clave en el desarrollo de nuestro LLM. En el contexto de este proyecto, su importancia radica en que permiten transformar secuencias de tokens en representaciones internas que capturan patrones y relaciones complejas del lenguaje, muchas de ellas no lineales. Gracias a ello, el modelo puede predecir el siguiente token de forma mucho más flexible y potente que un modelo lineal.

    Para ilustrar su relevancia, comenzaremos explicando qué entendemos por red neuronal y luego veremos un componente clave de ellas: las capas. Posteriormente, mediante un ejemplo —el problema XOR—, mostraremos que añadir capas permite construir representaciones internas más ricas, lo que amplía la capacidad del modelo para resolver problemas que un modelo lineal no puede resolver.

    Redes neuronales: intro

    Siguiendo a Jurafsky y Martin (2026), una red neuronal moderna puede entenderse como una red de pequeñas unidades computacionales, cada una de las cuales recibe un vector de entrada y produce una única salida. El término “neuronal” proviene de la neurona de McCulloch-Pitts, un modelo simplificado de la neurona biológica descrito en términos de lógica proposicional. Sin embargo, las redes neuronales contemporáneas se apartan de esa formulación original y hoy se utilizan sobre todo como modelos computacionales abstractos.

    La arquitectura que veremos en esta entrada se llama feedforward network. Como es usual en redes neuronales, esta red estará compuesta por pequeñas unidades computacionales, donde cada unidad tiene la siguiente estructura:

    z=b+iwixi.z = b + \sum_{i}w_{i}x_{i}.

    Como ya hemos visto en otras entradas, zz será la salida, bb el sesgo, wiw_{i} los pesos, y xix_{i} los valores de entrada. Al igual que en otras publicaciones, aplicaremos una función no lineal sobre zz, y lo que salga de ahí se llamará el valor de activación (a).

    En la práctica, existen tres funciones no lineales importantes. Por una parte está tanh()\tanh():

    a=tanh(z)=ezezez+eza = tanh(z) = \frac{e^{z} – e^{-z}}{e^{z} + e^{-z}}

    Luego tenemos la sigmoide:

    a=σ(z)=11+eza = \sigma(z) = \frac{1}{1+e^{-z}}

    y finalmente, ReLU (del inglés rectified linear unit):

    a=ReLU(z)=max(0,z).a = ReLU(z) = \max(0,z).

    Tal como explican Jurafsky y Martin (2026), en este contexto las funciones de activación más empleadas son tanh —muchas veces en reemplazo de la sigmoide, porque en la práctica suele funcionar mejor— y ReLU. La función tanh es popular porque es suavemente diferenciable y porque lleva los valores extremos hacia el centro. ReLU, por su parte, es muy utilizada porque conserva ciertas ventajas de las funciones lineales al ser lineal cuando z>0z>0. En este sentido, en funciones como la sigmoide y tanhtanh, valores muy altos en magnitud de zz hacen que la salida se sature: en la sigmoide hacia 0 o 1, y en tanhtanh hacia -1 o 1. En esas zonas, las derivadas se vuelven muy pequeñas, lo que puede causar un problema conocido como vanishing gradient problem. En cambio, para ReLU, este problema no aparece de la misma forma para valores altos de activación.

    En términos visuales, la unidad que mencionamos anteriormente se puede ilustrar de la siguiente forma:

    En la práctica, es común utilizar múltiples unidades y múltiples capas. En la siguiente sección veremos esto en detalle.

    Añadiendo capas

    Anteriormente mostramos una unidad, no obstante, podemos conformar una red uniendo múltiples unidades. La arquitectura estándar para hacer esto se muestra en la siguiente imagen:

    Como podemos ver, tenemos una red feedforward totalmente conectada, con una capa de entrada, una capa oculta (hh), y una de salida. Es totalmente conectada, porque cada xix_i se conecta con todos los hjh_j, y cada hjh_j​ con todos los nodos de salida. Además, es feedforward porque la información fluye únicamente desde la entrada hacia la salida, sin ciclos ni retroalimentación. Si deseas profundizar en la intuición, recomiendo que visites el siguiente recurso.

    Añadiendo capas: el problema XOR.

    En la introducción de esta publicación dijimos que añadir capas permite construir representaciones internas más ricas. Una forma clásica de ilustrar esta idea es mediante el problema XOR, que muestra que un perceptrón no puede computar esta función. La razón es que XOR no es linealmente separable. En cambio, al añadir una capa oculta, la red puede construir una nueva representación de la entrada que sí permite resolver el problema.

    Para mostrar brevemente la lógica detrás de este problema, debemos definir qué es un perceptrón, y qué hace la operación AND, OR y XOR.

    Un perceptron, es una unidad neuronal con la siguiente estructura:

    y={0,si 𝐰𝐱+b01,si 𝐰𝐱+b>0y = \begin{cases} 0, & \text{si } \mathbf{w}\cdot\mathbf{x} + b \leq 0 \\ 1, & \text{si } \mathbf{w}\cdot\mathbf{x} + b > 0 \end{cases}

    La operación AND se define como:

    AND=x1x2y000010100111\text{AND} = \qquad \begin{array}{c c|c} x_1 & x_2 & y \\ \hline 0 & 0 & 0 \\ 0 & 1 & 0 \\ 1 & 0 & 0 \\ 1 & 1 & 1 \end{array}

    la operación OR como:

    OR=x1x2y000011101111\text{OR} = \qquad \begin{array}{c c|c} x_1 & x_2 & y \\ \hline 0 & 0 & 0 \\ 0 & 1 & 1 \\ 1 & 0 & 1 \\ 1 & 1 & 1 \end{array}

    y XOR como

    XOR=x1x2y000011101110\text{XOR} = \qquad \begin{array}{c c|c} x_1 & x_2 & y \\ \hline 0 & 0 & 0 \\ 0 & 1 & 1 \\ 1 & 0 & 1 \\ 1 & 1 & 0 \end{array}

    Luego, siguiendo a Jurafsky y Martin (2026), podemos ver que si elegimos los siguientes pesos,

    w1w_{1}w2w_{2}bb
    11-1

    es posible construir la función AND. ¿Cómo? En la siguiente tabla lo hacemos en detalle:

    x1x2wx+by001×0+1×0100011×0+1×1100101×1+1×0100111×1+1×11>01\begin{array}{c c| c |c} x_1 & x_2 & w\cdot x + b & y \\ \hline 0 & 0 & 1 \times 0 + 1\times 0-1 \leq 0 & 0 \\ 0 & 1 & 1 \times 0 + 1\times 1-1 \leq 0 & 0 \\ 1 & 0 & 1 \times 1 + 1\times 0-1 \leq 0 & 0 \\ 1 & 1 & 1 \times 1 + 1\times 1-1>0 & 1 \end{array}

    También, con los siguientes pesos,

    w1w_{1}w2w_{2}bb
    110

    la función OR es representable, tal como lo evidencia la siguiente tabla:

    x1x2wx+by001×0+1×0+000011×0+1×1+0>01101×1+1×0+0>01111×1+1×1+0>01\begin{array}{c c| c |c} x_1 & x_2 & w\cdot x + b & y \\ \hline 0 & 0 & 1 \times 0 + 1\times 0+0 \leq 0 & 0 \\ 0 & 1 & 1 \times 0 + 1\times 1+0 > 0 & 1 \\ 1 & 0 & 1 \times 1 + 1\times 0+0 > 0 & 1 \\ 1 & 1 & 1 \times 1 + 1\times 1+0>0 & 1 \end{array}

    No obstante, sin importar los pesos que elijamos, la función XOR no puede ser replicada, debido a que XOR no es linealmente separable (para una intuición simple, ver Jurafsky y Martin (2026) y para una más compleja, puedes consultar aquí). En cambio, al añadir una capa oculta, la red puede construir una nueva representación de la entrada que sí permite resolver el problema.

    En la siguiente imagen, mostramos un esquema en el que la función XOR puede ser resuelta con una capa oculta:

    A modo de ejercicio, prueba con los parámetros de la imagen, y verifica que la función XOR se pueda representar con aquellos.

    Estructura de una red neuronal

    Para ilustrar la estructura de una red neuronal, partiremos con un tipo de red simple llamado feedforward. Aquella, está compuesta por unidades de entrada, unidades ocultas y unidades de salida. Siguiendo la notación de Jurafsky y Martin (2026), denotaremos la capa oculta como hh, y cada una de sus unidades como hih_i. Como en este ejemplo tenemos una red completamente conectada, cada unidad de una capa tomará como entrada las salidas de todas las unidades de la capa anterior. Además, puesto que aquí trabajaremos con una sola capa oculta, hh será único. En cambio, si la red tuviera múltiples capas ocultas, usaremos una notación indexada por capa, como a[i]a^{[i]}, para distinguir la activación de cada una de ellas.

    Por otra parte, siguiendo Jurafsky y Martin (2026), definiremos una matriz WW, donde el elemento ii representa la conexión desde la entrada xix_{i} hasta la unidad oculta hjh_{j} y bb un vector de sesgos. Por ejemplo, para la imagen, la matriz viene dada por:

    W=[wh1,x1wh1,x2wh2,x1wh2,x2],W=\begin{bmatrix} w_{h_{1},x_{1}} & w_{h_{1},x_{2}} \\ w_{h_{2},x_{1}} & w_{h_{2},x_{2}}\end{bmatrix},

    y X y B por:

    X=[x1x2]B=[bh1bh2]X =\begin{bmatrix} x_{1}\\ x_{2}\end{bmatrix} \quad B=\begin{bmatrix} b_{h_{1}}\\ b_{h_{2}}\end{bmatrix}

    Entonces, en este ejercicio, la capa oculta hh, viene dada por:

    h=σ(Wx+b).h = \sigma(Wx + b).

    es decir,

    h=σ([wh1,x1x1+wh1,x2x2+bh1wh2,x1x1+wh2,x2x2+bh2])h =\sigma\bigg(\begin{bmatrix} w_{h_{1},x_{1}}x_{1} + w_{h_{1},x_{2}}x_{2} + b_{h_{1}}\\ w_{h_{2},x_{1}}x_{1} + w_{h_{2},x_{2}}x_{2} + b_{h_{2}}\end{bmatrix}\bigg)

    Luego, aplicamos σ()\sigma(\cdot) a cada elemento, para obtener:

    h=[σ(wh1,x1x1+wh1,x2x2+bh1)σ(wh2,x1x1+wh2,x2x2+bh2)].h =\begin{bmatrix} \sigma(w_{h_{1},x_{1}}x_{1} + w_{h_{1},x_{2}}x_{2} + b_{h_{1}})\\ \sigma(w_{h_{2},x_{1}}x_{1} + w_{h_{2},x_{2}}x_{2} + b_{h_{2}})\end{bmatrix}.

    Hasta este punto hemos obtenido hih_{i}, no obstante, el objetivo es calcular la salida final yy. Dependiendo de la aplicación, yy puede ser un número real, o un vector. Si por ejemplo, la intención del investigador es calificar si algún texto es medieval o no, yy será un número real que contendrá una probabilidad. Por otra parte, si el objetivo es clasificar si el texto está escrito en español, inglés o portugués, probablemente, yy será un vector.

    En el caso de que yy es un vector (de dos elementos, y1y_{1} e y2y_{2} ), y usando la notación de Jurafsky y Martin (2026), definiremos una matriz U que albergará los pesos de salida:

    U=[wy1,h1wy1,h2wy2,h1wy2,h2],U=\begin{bmatrix} w_{y_{1},h_{1}} & w_{y_{1},h_{2}} \\ w_{y_{2},h_{1}} & w_{y_{2},h_{2}}\end{bmatrix},

    Luego, con la siguiente operación, obtenemos la salida intermedia:

    z=Uhz=Uh

    Finalmente, para convertir la salida intermedia en la final, debemos convertir el vector anterior en uno que contenga probabilidades. Para eso, usamos la función softmax:

    yi=softmax(zi)=exp(zi)j=1dexp(zj),iidy_{i} = softmax(z_{i}) = \frac{exp(z_{i})}{\sum_{j=1}^{d}exp(z_{j})}, \quad i \leq i \leq d

    donde dd es el tamaño del vector zz (con d=2d=2 para este ejemplo). En este punto, ya estamos listos para calcular los pesos, es decir, la matriz U y W. A continuación, esbozaremos el método para llevarlo a cabo.

    Aprendiendo los pesos para cada capa

    Tal como lo hemos hecho en otras entradas, el objetivo es aprender los parámetros de la red para que y^\hat y se aproxime lo mejor posible a yy. Para esto, usábamos una loss function. En este caso, al igual que en la entrada pasada, usamos la cross-entropy loss:

    LCE(y^,y)=logp(y|x)=[ylogy^+(1y)log(1y^)].L_{CE}(\hat{y}, y) = -\log p(y \mid x) = -\left[y \log \hat{y} + (1-y)\log(1-\hat{y})\right].

    Finalmente, como sabemos, dicha función debe ser minimizada. En este sentido, dado el alcance de esta serie no expondremos la estrategia de minimización, no obstante, un lector interesado puede consultar la sección 6.6.2 de Jurafsky y Martin (2026) para mayor detalle.

    Notas finales, y próxima entrada…

    En esta entrada introdujimos las redes neuronales y explicamos sus fundamentos teóricos más básicos. Dado que se trata de un tópico central para nuestro proyecto, en la siguiente entrada desarrollaremos un ejemplo práctico.

    José Miguel Muñoz Urra – jmunozu@pulki.es

  • Large Language Model (LLM) from scratch. Embeddings.

    En la entrega pasada vimos como clasificar textos usando la regresión logística. En la presente, discutiremos embeddings, que en términos simples, se define como una representación vectorial del significado de las palabras.

    Aprender embeddings en el contexto de este proyecto es importante, porque son una piedra angular en los LLM contemporáneos. Como es usual, usaremos como referencia, el capítulo 5 de Jurafsky y Martin, 2025.

    Lemma, wordform, y sense

    Un LLM exitoso debería entender cómo se relacionan los significados de las palabras. Para eso, sería esperable que capture similitud, antonimia, connotación, distintas perspectivas sobre un mismo evento, y además permitir inferencias útiles para tareas como QA o diálogo. A continuación explicaremos en detalle cada una de ellas:

    Similitud

    El modelo debe ser capaz de reconocer qué palabras tienen un significado parecido aunque no sean idénticas. Por ejemplo, en Jurafsky y Martin, 2025, muestran que si bien, perro y gato no son sinónimos, son parecidas, debido a que, por ejemplo, ambos son animales. Esta noción de similitud es muy útil porque ayuda a estimar cuán parecidos son dos fragmentos más grandes, como frases u oraciones, algo central en tareas como question answering, paráfrasis y summarization.

    Antonimia

    La similitud nos permite identificar si dos palabras están relacionadas, pero no nos dice cómo se relacionan. Por ejemplo, caliente y frío están fuertemente relacionadas, pero no sólo porque estén asociadas a diferencias de temperatura, también porque son opuestos. Esto es importante porque una representación pobre podría tratar calor y frio como simplemente “palabras frecuentes del mismo tema”, cuando semánticamente cumplen funciones opuestas.

    Connotación

    Aquí el punto es capturar la carga afectiva o valorativa que lleva la palabra, esto es útil para tareas como sentiment analysis, stance detection y análisis de lenguaje político o reseñas.

    Distintas perspectivas sobre un mismo evento

    Jurafsky y Martin (2025) dice que palabras como buy, sell y pay ofrecen perspectivas distintas sobre un mismo evento subyacente: una transacción. Si yo compro algo, alguien me lo vende, y probablemente yo le pago.

    Esto quiere decir que el significado no está solo en objetos aislados, sino también en roles dentro de una situación.

    • comprar pone el foco en el comprador;
    • vender pone el foco en el vendedor;
    • pagar pone el foco en la transferencia de dinero.

    No son sinónimos. Pero tampoco son eventos totalmente independientes. Son distintas “vistas” de la misma escena. Una teoría buena del significado debería poder representar esa estructura.

    Permitir inferencias

    Según los autores una buena representación debe permitir inferencias para resolver tareas relacionadas con el significado. Por ejemplo:

    • Texto: “María compró un libro a Pedro.”
    • Pregunta: “¿Pedro vendió un libro?”

    Un sistema realmente semántico debería responder algo como “probablemente sí”, aunque la palabra vendió no aparezca literalmente. ¿Por qué? Porque entiende la relación entre comprar y vender como dos perspectivas del mismo evento. Ese tipo de salto es una inferencia semántica.

    En resumen, el significado de una palabra no puede reducirse a una etiqueta. Una teoría útil debe modelar al menos parte de la red de relaciones que una palabra mantiene con otras palabras, con eventos, con valoraciones afectivas y con posibles inferencias.

    Como veremos en la siguiente sección, el significado de una palabra puede representarse como un vector, y ese vector se obtiene a partir de los contextos en los que la palabra aparece.

    Semántica vectorial

    En la sección anterior vimos qué aspectos del significado de una palabra debería captar una buena teoría semántica. En esta sección veremos una forma de representar ese significado en NLP.

    La idea principal es definir el significado de una palabra a partir de su distribución en el uso del lenguaje, es decir, de las palabras que suelen aparecer cerca de ella y de los entornos gramaticales en los que aparece.

    Por ejemplo, supongamos que no sabemos el significado de la palabra faze, proveniente del castellano medieval. Está claro, que al extraer las siguientes frases del Libro de las animalias, de Moamyn,

    E estos poluos q agora
    diremos son pora la caletura q seles faze en las
    bocas.

    los gusanos q seles faze
    en las moliellas.

    dela seqdad q seles
    faze dentro en los cuerpos

    podemos ver que las oraciones sugieren que faze está relacionada con el verbo «hacer», en español moderno. En este sentido, podemos llevar este principio a la arena computacional, representando la palabra faze como un punto en un espacio semántico multidimensional; derivado usando la distribución de las palabras vecinas a ella. A esta representación vectorial del significado de faze, lo llamamos embedding.

    Computando embeddings: modelo basado en el conteo

    Empezaremos con el modelo vectorial de significado más simple. Para implementarlo, necesitamos llenar una matriz de co-occurrencia, que es una forma de representar cuán seguido las palabras co-occurren. Un tipo particular de matriz de co-occurrencia es la word-context matrix, en la que cada fila es una palabra del vocabulario, y cada columna indica cuán seguido aparecen las palabras vecinas de una fila particular. A modo de ejemplo, supongamos que disponemos de un corpus de dos oraciones, con una ventana de dos palabras vecinas:

    q seles faze dentro en

    q seles faze en las

    En base a esta información, una versión simplificada de la word-context matrix viene dada por:

    qselesdentroenlas
    faze22121

    Si bien la dimensión de esta matriz es 1×5, una word-context matrix debería tener dimensiones |V|×|V||V|\times|V|, esto, debido a que cada fila es una palabra del vocabulario, y cada columna, es una posible palabra vecina. Dicho esto, en nuestro caso, la matriz debería tener dimensión 6×66\times6.

    Como dijimos anteriormente, un embedding es una representación vectorial del significado de una palabra. En el caso de nuestra word-context matrix simplificada, faze es representada como el vector [2,2,1,2,1] -aunque la word-context matrix completa debería ser [2,2,1,2,1,0].

    Puesto que el objetivo inicial de esta entrada es analizar cómo se relaciona el significado de dos palabras distintas, y ya sabemos cómo representarlo de forma vectorial, el siguiente paso lógico es medir qué tan similares son esas representaciones entre dos o más palabras. En la siguiente sección veremos cómo hacerlo.

    Midiendo similitud entre vectores

    La métrica que usaremos para medir similitud entre dos vectores, es la similitud coseno (puedes encontrar una definición acabada acá), cuya fórmula viene dada por:

    sim_cos(v,w)=vw|v||w|=i=1Nviwii=1Nvi2i=1Nwi2sim\_cos(v,w) = \frac{v\cdot w}{|v||w|} = \frac{\sum_{i=1}^{N}v_{i}w_{i}}{\sqrt{\sum_{i=1}^{N}v_{i}^{2}}\sqrt{\sum_{i=1}^{N}w_{i}^{2}}}

    donde vv y ww son representaciones vectoriales de las palabras v y w.

    En teoría, sim_cossim\_cos puede devolver valores entre -1 y 1, con -1 para vectores apuntando en dirección opuesta, 1 para vectores apuntando a la misma dirección, y 0 para vectores ortogonales. Pero, debido a que viv_{i} y wiw_{i} nunca serán negativos (porque son frecuencias), en esta aplicación, sim_cossim\_cos siempre estará entre 0 y 1.

    Para ilustrar esta idea, tomaremos 20 textos medievales, y calculamos la word-context matrix correspondiente con una ventana de contexto de dos palabras. En este ejercicio, producimos un vocabulario de 35.340 palabras, con un vector de 35.340 elementos para cada palabra. En este sentido, si analizamos faze, podemos ver que los diez elementos más grandes del vector son

    PalabraFrecuencia
    que437
    se292
    el196
    en158
    la124
    por89
    lo77
    las75
    seles75
    lo74

    indicando una buena señal, puesto que faze (hoy hace, del verbo hacer), suele estar acompañada de que y se.

    Además, al calcular cosine similarity de faze contra el resto de las palabras del vocabulario, las diez que presentan mayor similitud son:

    PalabraCosine similarity
    fazie0.904950
    fazen0.903953
    mostraua0.897187
    tornasse0.891229
    yua0.885668
    entienden0.885050
    cumpliesse0.878845
    cumpla0.876391
    guardasse0.874317
    nunqua0.871570

    Vale la pena recordar que las palabras que aparecen en esta tabla son aquellas que ocurren en contextos parecidos a faze, y no necesariamente sus sinónimos. Por ejemplo, según estos resultados, fazie y fazen aparecen en entornos sintácticos similares. Esto resulta razonable, pues son formas cercanas a faze: fazie solía significar “hacía” y fazen, “hacen”. No obstante, aunque comparten contextos de uso similares, no son sinónimos estrictos.

    Para visualizar de forma gráfica el concepto de similitud, utilizamos PCA para proyectar los vectores de palabras en dos dimensiones y, a continuación, graficamos algunas de ellas.

    Como podemos ver, palabras que cumplen una función similar, como fazia, fazie y fizo, se encuentran cerca, y palabras con un significado distinto, no comparten una región similar.

    Word2vec: skip-gram con sampleo negativo

    Los vectores que hemos visto hasta el momento tienen una dimensión equivalente a la del tamaño del vocabulario. En nuestro ejemplo, faze, tiene dimensión 35.340, donde muchos componentes son cero.

    En esta sección presentaremos embeddings representados mediante vectores relativamente cortos y densos, cuyos valores pueden incluso ser negativos. Como señalan Jurafsky y Martin (2025), este tipo de representación suele funcionar mejor que los vectores dispersos, es decir, aquellos con una gran cantidad de ceros. Una de las intuiciones principales es que, al tener menor dimensionalidad, el modelo aprende menos parámetros, lo que puede favorecer la generalización y reducir el riesgo de overfitting. Además, los embeddings densos suelen capturar mejor la similitud entre palabras semánticamente cercanas.

    Para ilustrarlo, supongamos que en nuestro corpus medieval encontramos dos contextos muy parecidos:

    dixo aquel que fezo el libro.

    dixo aql q fezo el libro.

    Si representamos estos contextos mediante vectores dispersos, aquel y aql ocuparán dimensiones distintas del vocabulario. Así, desde el punto de vista del vector, ambos contextos diferirán en coordenadas distintas, aunque para nosotros resulten muy cercanos en contenido. En cambio, con embeddings densos ya no tenemos una dimensión por cada palabra del vocabulario, sino un vector corto de rasgos latentes aprendidos a partir de los contextos de uso.

    La intuición del algoritmo que introduciremos ahora es la siguiente: en vez de limitarnos a contar cuántas veces aparece una palabra cerca de dixo, entrenaremos un clasificador que estime si una palabra candidata es o no un contexto probable de dixo. Los pesos aprendidos por ese clasificador serán precisamente los embeddings.

    El clasificador

    El clasificador que queremos programar es uno que, dada una palabra objetivo ww y una palabra candidata cc, entregue la probabilidad de que cc sea una palabra de contexto real de ww. Por ejemplo, si consideramos la oración

    dixo aquel que fezo el libro

    y usamos una ventana de contexto ±2\pm 2, entonces el par (fezo,libro)(\text{fezo}, \text{libro}) constituye un ejemplo positivo, ya que libro aparece realmente en la ventana de contexto de fezo. En cambio, si tomamos una palabra del vocabulario que no aparece en esa ventana, por ejemplo aql, entonces el par (fezo,aql)(\text{fezo}, \text{aql}) puede utilizarse como ejemplo negativo.

    Para formalizar esta tarea, definimos una variable binaria DD tal que

    D={1si (w,c) es un par positivo0si (w,c) es un par negativoD = \begin{cases} 1 & \text{si } (w,c) \text{ es un par positivo} \\ 0 & \text{si } (w,c) \text{ es un par negativo} \end{cases}

    Entonces, el objetivo del clasificador es estimar la probabilidad

    P(D=1|W=w,C=c),P(D=1|W=w,C=c),

    es decir, la probabilidad de que el par observado (w,c)(w,c) corresponda a una co-ocurrencia real de la palabra objetivo con una palabra de contexto.

    En términos prácticos, para calcular esa probabilidad, el modelo asigna un vector denso para la palabra objetivo, y otro vector denso para la palabra candidata. Luego, para ver si ambos vectores son similares, se calcula el producto punto entre ambos, es decir,

    wc=w1c1+w2c2+...+cdwd,w\cdot c = w_{1}c_{1} + w_{2}c_{2} + … + c_{d}w_{d},

    y finalmente, para convertirlo en una probabilidad, se pasa por la sigmoide

    σ(wc)=11+e(wc)=P(D=1|W=w,C=c).\sigma(w\cdot c) = \frac{1}{1+e^{-(w\cdot c)}} = P(D=1|W=w,C=c).

    Recordar que la sigmoide es creciente en su argumento, por tanto un mayor producto punto -que indica una mayor similitud entre vectores-, implicará una mayor probabilidad de que la palabra de que (w,c) sea un par positivo. Al asumir que todas las palabras de contexto son independientes, tenemos:

    P(D1=1,D2=1,...,DL=1|W=w,C1=c1,C2=c2,...,CL=cL)=i=1Lσ(ciw)P(D_{1}=1, D_{2} =1,…,D_{L} =1|W =w,C_{1} = c_{1}, C_{2} = c_{2}, …, C_{L} = c_{L}) = \prod_{i=1}^{L}\sigma(c_{i}\cdot w)

    que es la expresión para la probabilidad del contexto completo observado, dado el target ww, bajo la hipótesis de que las palabras de contexto son condicionalmente independientes, donde LL es el tamaño la ventana de contexto.

    Aprendiendo los parámetros del modelo

    El algoritmo de aprendizaje de skip-gram con sampleo negativo toma como entrada un corpus de texto y un tamaño de vocabulario NN. A continuación, asigna de manera aleatoria embeddings iniciales a las palabras del vocabulario. Más precisamente, el modelo almacena dos vectores por palabra: uno cuando la palabra actúa como palabra objetivo y otro cuando actúa como palabra de contexto.

    Luego, a partir del corpus, extrae pares positivos formados por una palabra objetivo ww y una palabra de contexto real cposc^{pos} dentro de una ventana dada. Para cada uno de estos pares, genera además kk pares negativos (w,cneg)(w,c^{neg}), donde cnegc^{neg} es una palabra de ruido muestreada aleatoriamente del léxico. A modo de ejemplo, consideremos la siguiente oración:

    q fezo sus cosas bien copuestas.

    cuya palabra objetivo es cosas y ventana de contexto +-2 palabras, entonces, podemos construir la siguiente tabla:

    fezosuscosasbiencompuestas
    c1c_{1}c2c_{2}wc3c_{3}c4c_{4}

    Luego, debemos seleccionar los ejemplos positivos y negativos. Para el primer grupo tenemos:

    wwcposc^{pos}
    cosasfezo
    cosassus
    cosasbien
    cosascompuestas

    Para caracterizar el segundo grupo, debemos fijar el valor de kk. En este caso, con k=2k=2 tenemos:

    wwcnegc^{neg}
    cosaslimpia
    cosasfablo
    cosasdella
    cosasrecibieron
    cosasaltos
    cosasreyes
    cosasassi
    cosasdizen

    Cuando caracterizamos el segundo grupo en la tabla anterior, elegimos arbitrariamente las ‘palabas ruidosas’. No obstante, en la práctica, deben ser tomadas de forma aleatoria desde el lexicon (cuya única restricción es que no contengan la palabra objetivo). La principal función que tienen estas palabras, es proporcionar un ejemplo negativo de entrenamiento. De este modo, el modelo aprende no solo a acercar la palabra objetivo a sus contextos reales, sino también a alejarla de palabras muestreadas como ruido en los ejemplos negativos.

    Dichas palabras son elegidas de acuerdo a la siguiente probabilidad:

    Pα(w)=count(w)αwcount(w)α.P_{\alpha}(w) = \frac{count(w)^{\alpha}}{\sum_{w’}count(w’)^{\alpha}}.

    Donde count(w) cuenta cuántas veces aparece ww en el corpus, y el denominador normaliza la suma de los conteos ponderados de todas las palabras del vocabulario. α\alpha es un parámetro que cuando es menor que uno, permite que los eventos raros sean mas probables que cuando α=1\alpha=1.

    A modo de resumen parcial, hasta este punto tenemos:

    • Conjunto inicial de embeddings: cada palabra del vocabulario tiene un vector para cuando actúa como palabra objetivo y palabra de contexto, inicializados de forma aleatoria, con dd elementos.
    • Conjunto de ejemplos positivos y negativos: el conjunto de ejemplos positivos se obtuvo del corpus, y el negativo se sampleó acorde a Pα(w)P_{\alpha}(w).

    Con esto en mente, la estrategia que debemos seguir, es hacer que el modelo aprenda la posición de las palabras objetivo en el espacio de embeddings, es decir, cuáles quedan “lejos”, y cuáles quedan “cerca”. En este sentido, “cerca” o “lejos”, dependerá de P(D=1|W=w,C=c)P(D=1|W=w,C=c), que a su vez, como vimos, depende del producto punto entre ww y cc. Específicamente, si una palabra cposc^{pos} apareció en la ventana real de la palabra objetivo (ww), el modelo asignará pesos tal que el producto punto (wcposw\cdot c^{pos}) sea alto, porque así, P(D=1|W=w,C=c)P(D=1|W=w,C=c) también dará un valor relativamente alto. Por otra parte, si cnegc_{neg} fue muestreada como noise word, entonces, se asignarán pesos tal que wcnegw\cdot c^{neg} sea bajo, para que el modelo considere improbable que ese par sea un contexto real. Este principio se puede formalizar con la siguiente loss function:

    L(w,cpos,cneg)=[logσ(cposw)+i=1klogσ(cnegiw)].L(w, c^{\text{pos}}, c^{\text{neg}*}) = -\left[ \log \sigma(c^{\text{pos}} \cdot w) + \sum_{i=1}^{k} \log \sigma(-\,c^{\text{neg}_i} \cdot w) \right].

    Finalmente, podemos resumir el algoritmo completo en los siguientes pasos:

    • Se fija un tamaño de vocabulario NN y una ventana de contexto, por ejemplo ±2\pm 2.
    • Inicializamos dos embeddings aleatorios por palabra.
    • Construimos ejemplos positivos: Recorremos el texto, y para cada palabra objetivo ww, tomamos las palabras que están dentro de su ventana de contexto como pares positivos (w,cpos)(w,c^{pos}).
    • Construimos ejemplos negativos: Para cada par positivo (w,cpos)(w,c^{pos}), generamos kk pares negativos (w,cneg)(w,c^{neg}). Cada cnegc^{neg} es una noise word, es decir, una palabra aleatoria del vocabulario, con la única restricción explícita de no ser la palabra objetivo ww. Esas palabras negativas no se eligen uniformemente, se muestrean según Pα(w)P_{\alpha}(w).
    • Definimos un clasificador binario: Dado un par (w,c)(w,c), el modelo quiere estimar la probabilidad de que cc sea una palabra de contexto real de ww. Para eso, usamos P(D=1|W=w,C=c)=σ(cw)P(D=1|W=w,C=c) = \sigma(c \cdot w).
    • Definimos la siguiente loss function:
      L(w,cpos,cneg)=[logσ(cposw)+i=1klogσ(cnegiw)].L(w, c^{\text{pos}}, c^{\text{neg}*}) = -\left[ \log \sigma(c^{\text{pos}} \cdot w) + \sum_{i=1}^{k} \log \sigma(-\,c^{\text{neg}_i} \cdot w) \right].
    • Actualizamos los embeddings con descenso por gradiente estocástico: El modelo recorre el corpus y, en cada ejemplo, ajusta las filas relevantes de WW y CC para minimizar esa loss function.
    • Repetir muchas veces.
    • Finalmente, obtenemos el embedding final de cada palabra: Como el modelo aprendió dos vectores por palabra, algunos investigadores la representan como wi+ciw_i+c_i, o bien, solo como wiw_i.​

    Como dijimos anteriormente, los vectores densos resultantes de este proceso suelen capturar mejor la similitud entre palabras semánticamente cercanas, y poseen menos dimensiones que los vectores calculados a partir del método basado en el conteo.

    Notas finales, y próxima entrada…

    En esta entrada revisamos el concepto de embeddings, es decir, representaciones vectoriales que capturan aspectos del uso y significado de las palabras. Vimos que estas representaciones pueden construirse tanto con vectores dispersos como con vectores densos, y analizamos las principales propiedades de cada enfoque.

    En la próxima entrada nos adentraremos en las redes neuronales, un componente fundamental en la construcción de los LLM. Stay tuned!

    José Miguel Muñoz Urra – jmunozu@pulki.es

  • Large Language Model (LLM) from scratch. Clasificación.

    En la entrada anterior vimos n-gramas, cuyo concepto es fundamental para entender la generación de texto, característica que será clave para construir el LLM que tenemos en mente. Como mencionamos en una entrada pasada, si bien el algoritmo de generación de texto es importante a la hora de desarrollar un LLM, los datos en que el modelo es entrenado son igualmente importantes. Es por esto que aprender a clasificar textos es relevante para este proyecto.

    En esta nueva publicación veremos en detalle la regresión logística, y cómo se puede aplicar a la clasificación de texto. Como es habitual, usaremos como referencia el libro de Jurafsky y Martin, 2025 (capítulo 4).

    ¿Qué entenderemos por clasificar?

    De acuerdo con Jurafsky y Martin (2025), el objetivo principal de clasificar, cuando se trata de un texto, es extraer características relevantes de este y, a partir de ellas, asignarlo a una categoría. A modo de ejemplo, supongamos que queremos etiquetar un texto entre dos categorías mutuamente excluyentes. Concretamente, deseamos determinar si está escrito en castellano medieval. En ese caso, tendremos una variable llamada medievali=1medieval_i = 1 si el texto ii está escrito en castellano medieval, y medievali=0medieval_i = 0 en caso contrario. Ahora bien, en consecuencia con esta definición, debemos determinar cuáles serán las características relevantes para la clasificación. En este sentido, es sabido que en castellano medieval, “suso” y “yuso” eran adverbios de lugar comúnmente utilizados. Por tanto, una variable aparentemente válida para identificar si un texto es medieval podría ser el número de veces que aparecen las palabras “suso” o “yuso”. Llamaremos a esta variable yu_su.

    En este punto nos enfrentamos a los primeros desafíos. Por una parte, que esas palabras no aparezcan no implica que el texto no sea medieval, y que aparezcan tampoco implica necesariamente que lo sea. En otras palabras, si nos basáramos sólo en esta variable, podríamos cometer tanto falsos positivos como falsos negativos. Por este motivo, es preciso añadir variables que nos entreguen más indicios sobre la naturaleza del texto. Para ello, podríamos pensar en una variable binaria, llamada cont_words, que tome el valor de 1 si el texto contiene palabras como internet, blog, ordenador, teléfono, electrónico, fútbol, globalización, etc., es decir, palabras que no existían en el castellano medieval o que, al menos, no eran comunes. En la práctica, podríamos repetir este ejercicio incorporando múltiples variables. No obstante, para efectos de este ejemplo consideraremos sólo estas dos.

    A partir de aquí, es preciso definir un umbral de decisión para discernir si el texto es medieval o no. A modo de ejemplo, podemos imponer la siguiente regla:

    Si yu_su es mayor que 5 y cont_words es igual a 0, entonces el texto es medieval.

    En este caso, 5 y 0 son umbrales arbitrarios elegidos únicamente para ilustrar el ejemplo. Sin embargo, si analizáramos un conjunto grande de textos medievales y no medievales, podríamos encontrar puntos de corte relativamente buenos para discriminar entre ambas clases.

    Claro está que, además del criterio anterior, podríamos proponer muchas otras reglas. No obstante, en esta entrada nos enfocaremos en la regresión logística binaria.

    Regresión binaria logística

    Supongamos que disponemos de un vector de características x=[x1,x2]x=[x_1,x_2], donde x1x_1​ corresponde al número total de veces que aparece la palabra “yuso” o “suso” (yu_su), y x2x_2​ es una variable binaria que indica si existen palabras contemporáneas (cont_words). Dado ese vector, nuestro objetivo es calcular P(medieval=1x)P(\text{medieval}=1 \mid x). Esta cantidad nos dirá qué tan probable es que el texto de entrada sea medieval, condicional a las características extraídas. Para obtener esta probabilidad, la regresión logística supone la siguiente estructura:

    z=b+w1x1+w2x2z = b + w_1x_1 + w_2x_2

    donde wiw_i representa el peso asociado a cada variable ii al momento de determinar si el texto es medieval o no, y bb corresponde al término de sesgo.

    Puesto que zz puede tomar cualquier valor real, no necesariamente estará entre 0 y 1. Por esa razón, no podemos interpretarlo directamente como una probabilidad. Para hacerlo, lo pasaremos por la función sigmoide, es decir,

    σ(z)=11+ez\sigma(z)=\frac{1}{1+e^{-z}}

    Esto asegura que

    limzσ(z)=1ylimzσ(z)=0\lim_{z\to\infty}\sigma(z)=1 \qquad \text{y} \qquad \lim_{z\to-\infty}\sigma(z)=0

    Por tanto, σ(z)\sigma(z) siempre tomará valores entre 0 y 1. Además, nuestras probabilidades deben cumplir que

    P(y=1)+P(y=0)=1P(y=1)+P(y=0)=1

    de modo que podemos definir

    P(y=1)=σ(z)yP(y=0)=1σ(z)P(y=1)=\sigma(z) \qquad \text{y} \qquad P(y=0)=1-\sigma(z)

    Esto implica que

    P(y=1)=11+ezyP(y=0)=ez1+ezP(y=1)=\frac{1}{1+e^{-z}} \qquad \text{y} \qquad P(y=0)=\frac{e^{-z}}{1+e^{-z}}

    Con esto presente, ya podemos calcular P(medieval=1x)\mathbb{P}(\text{medieval}=1\mid x). Ahora solo falta definir el umbral que determinará cuándo un texto será clasificado como medieval o no. Usualmente, se considera que una probabilidad superior a 0.50.5 es suficiente para asignar el texto a la clase positiva, es decir,

    D(x)={1si P(medieval=1x)>0.50en otro casoD(x)= \begin{cases} 1 & \text{si } \mathbb{P}(\text{medieval}=1 \mid x)>0.5 \\ 0 & \text{en otro caso} \end{cases}

    Definido el umbral, ya estamos en condiciones de aprender los parámetros del modelo. En la siguiente sección veremos cómo hacerlo.

    Aprendiendo los parámetros del modelo

    Queremos elegir unos parámetros tales que, si el texto que estamos analizando es medieval, es decir, si medieval=1medieval=1, la probabilidad estimada sea lo más alta posible, esto es, P(medieval=1x)1\mathbb{P}(\text{medieval}=1\mid x)\to 1.

    Para lograrlo, comúnmente se utiliza una función que mide qué tan distinta es la predicción del modelo respecto del valor real. Esta función, llamada función de pérdida, debe ser lo más pequeña posible. En el caso de la regresión logística, la pérdida que se usa habitualmente es la cross-entropy loss, que también puede interpretarse como la negative log likelihood.

    En términos matemáticos, la función de pérdida puede escribirse como L(y^,y)L(\hat{y},y), donde

    y^=σ(w1x1+w2x2+b)\hat{y} = \sigma(w_{1}x_{1} + w_{2}x_{2} + b)

    En este caso, la estrategia que emplearemos para encontrar w^1\hat{w}_1, w^2\hat{w}_2 y b^\hat{b} será la máxima verosimilitud. La idea es simple: escogeremos los parámetros que hagan lo más probable posible observar las etiquetas correctas en los datos de entrenamiento. Como la log-verosimilitud debe maximizarse, y la función de pérdida se define como su negativo, maximizar la verosimilitud es equivalente a minimizar la función de pérdida.

    Para derivar la función de pérdida, debemos empezar por expresar la probabilidad de una observación individual. Puesto que tenemos una variable binaria, dicha probabilidad viene dada por:p(yx)=y^y(1y^)1yp(y\mid x)=\hat{y}^{\,y}(1-\hat{y})^{1-y}

    Al tomar logaritmos obtenemos:

    logp(yx)=ylog(y^)+(1y)log(1y^)\log p(y\mid x)=y\log(\hat{y})+(1-y)\log(1-\hat{y})

    Esta es la log-verosimilitud que queremos maximizar. Sin embargo, como en aprendizaje automático solemos trabajar minimizando una pérdida, definimos:

    L(y^,y)=logp(yx)L(\hat{y},y)=-\log p(y\mid x)

    Reemplazando, llegamos a:

    L(y^,y)=(ylog(y^)+(1y)log(1y^))L(\hat{y},y)= -\big(y\log(\hat{y})+(1-y)\log(1-\hat{y})\big)

    y, usando y^=σ(wx+b)\hat{y}=\sigma(wx+b), obtenemos finalmente:

    L(y^,y)=(ylog(σ(wx+b))+(1y)log(1σ(wx+b)))L(\hat{y},y)= -\big(y\log(\sigma(wx+b))+(1-y)\log(1-\sigma(wx+b))\big)

    Esta es la función de pérdida que usaremos para aprender los parámetros del modelo.

    En este punto, estamos en condiciones de trabajar con los datos, y así obtener w1^\hat{w_{1}}, w2^\hat{w_{2}}, w3^\hat{w_{3}}, y b^\hat{b}. Para esto, extraeremos 20 textos escritos en castellano medieval de esta web, y 20 textos contemporáneos: 19 periodísticos y un artículo académico referente a las palabras “yuso” y “suso”. La siguiente tabla muestra el valor de cada variable:

    medievalyu_sucont_words
    1740
    1180
    130
    180
    1190
    100
    160
    1571
    1260
    11660
    120
    13960
    110
    1651
    110
    1620
    180
    1111
    131
    1650
    01940
    000
    000
    000
    000
    000
    000
    000
    000
    000
    000
    000
    001
    000
    000
    000
    000
    000
    000

    Curiosamente, cont_words, que es una dummy cuyo valor es 1 si el texto contiene palabras contemporáneas, toma el valor 1 con mayor frecuencia en los textos medievales. Esto se debe la palabra ordenador aparece en esta clase de textos, como se puede ver en el siguiente fragmento de castellano medieval:

    se<n>naladamientre q<ue> fue mayor dellos;
    de qui tiene logar. E aun onrran a
    toda la xp<ist>iandat; cuya cabeça es. cuemo ordenador & mantenedor de la ley.

    Además, podemos observar que existe un texto no medieval cuyo conteo de palabras «yuso» o «suso» es alto. Como mencionamos anteriormente, esa observación corresponde al artículo académico que analiza ambas palabras. Dicho esto, a continuación, mostramos los resultados de la estimación:

    Los pesos de yu_su y cont_words son positivos, eso quiere decir que aportan positivamente a la probabilidad de que un texto sea medieval. En la siguiente sección veremos cómo emplear la información de la tabla para predecir si un texto fuera de nuestro conjunto de entrenamiento es medieval.

    Predicción de categoría

    Con la información de la tabla, podemos escribir la siguiente ecuación:

    P(medieval=1|x)=11+e(0.01yu_su+1.53cont_words0.47)P(medieval=1|x) = \frac{1}{1+e^{-(0.01*yu\_su + 1.53*cont\_words -0.47)}}

    En este punto, sólo necesitamos calcular yu_su y cont_words para el texto que queramos analizar, que en este caso será el Libro de Apolonio, escrito en castellano medieval. Sus valores son:

    yu_sucont_words
    10

    Puesto que este texto solo contiene la palabra “suso”, y cont_words es cero, la probabilidad de que el texto sea medieval es relativamente baja con respecto al umbral (0.386), por tanto, el clasificador lo etiqueta como texto no medieval.

    Ahora, por el contrario, si tomamos un texto periodístico sobre deportes, tenemos los siguientes valores:

    yu_sucont_words
    01

    Debido a que en este texto aparece la palabra fútbol, cont_words vale 1, y la probabilidad de que el texto sea medieval se dispara (0.741), clasificando nuevamente el texto de forma errónea, es decir, esta vez es etiquetado como medieval.

    Claramente, en este caso necesitamos añadir más variables, y revisar los criterios actuales, entre ellos, definir uno en el que el peso de cont_words no sea positivo.

    Como pudimos observar en este ejercicio, el rendimiento del modelo es visiblemente deficiente, no obstante, en muchos casos, calificar su performance no es trivial. Por este motivo, en la siguiente sección introduciremos un método para evaluar el desempeño del clasificador.

    Evaluación

    Antes de abordar las estrategias de evaluación, conviene definir qué entendemos por gold label. En nuestro caso, el objetivo del modelo es clasificar si un texto es medieval o no. Para ello, la regresión logística no entrega directamente una etiqueta definitiva, sino una probabilidad P(medieval=1x)P(\text{medieval}=1 \mid x), es decir, una estimación de cuán probable es que el texto pertenezca a la clase “medieval”. Sin embargo, desde el punto de vista del conjunto de datos, cada texto sí tiene una clasificación de referencia: o es medieval, o no lo es. Esa etiqueta correcta, asignada por criterio humano, es lo que llamamos gold label.

    Una métrica para evaluar el desempeño de nuestro modelo, es construir una matriz de confusión, que en pocas palabras grafica como se desempeña en términos de los gold labels y las etiquetas que este predice. La siguiente imagen muestra cómo se vería esta matriz:

    En esta matriz podemos observar tres métricas importantes: precisión, recall y accuracy. Usualmente, accuracy no es una buena métrica por dos motivos. El primero es que, si las categorías están desbalanceadas, es decir, si hay muchos más textos no medievales que textos medievales, un clasificador que prediga siempre la clase mayoritaria podría obtener un accuracy alto, aun cuando fuera incapaz de detectar textos medievales. Si bien en nuestra aplicación estamos trabajando con una cantidad balanceada de textos medievales y no medievales, en la práctica esto no siempre ocurre. El segundo problema de esta métrica es que combina en un solo valor los verdaderos positivos (VP) y los verdaderos negativos (VN). Esto puede ser problemático si queremos que el modelo sea especialmente bueno encontrando textos medievales, ya que, mirando únicamente el accuracy, no podemos distinguir si el modelo destaca por identificar bien los textos medievales o simplemente por clasificar correctamente los textos no medievales.

    Por este motivo, precisión y recall suelen ser métricas más informativas que accuracy. En nuestro caso, la precisión sería especialmente útil si queremos que, cada vez que el modelo clasifique un texto como medieval, esa predicción sea confiable. En otras palabras, la precisión mide qué proporción de los textos etiquetados por el modelo como medievales efectivamente pertenece a esa categoría.

    No obstante, recall también puede ser de gran utilidad, ya que mide qué proporción de los textos medievales reales fue efectivamente identificada por el modelo.

    Puesto que ambos indicadores aportan información relevante, es posible combinarlos en una sola métrica llamada F-measure:

    Fβ=(β2+1)PRβ2P+RF_{\beta} = \frac{(\beta^{2}+1)*P*R}{\beta^{2}*P+R}

    donde β\beta mide la importancia de recall o precisión. β>1\beta > 1 favorece recall, β<1\beta < 1 favorece precisión, y β=1\beta = 1 asigna el mismo peso a ambas.

    Regularización

    El diseño original de este ejemplo contemplaba solo la variable yu_su, de modo que el modelo quedaba reducido a

    (medieval=1|x)=11+e(b+w1yu_su).\mathbb{P}(medieval = 1|x) = \frac{1}{1+e^{-(b + w_{1}*yu\_su)}}.

    Además, el texto del artículo académico en el que aparecían las palabras “yuso” y “suso” no formaba parte del conjunto de entrenamiento. En nuestra muestra, todos los textos medievales contenían al menos una vez una de esas palabras y, al mismo tiempo, ningún texto contemporáneo las contenía. Como consecuencia, la variable yu_su funcionaba como un predictor perfecto dentro del training set.

    Esto generaba un problema importante: el modelo aprendía a asociar de manera excesivamente fuerte la presencia de “yuso” o “suso” con la clase medieval. En otras palabras, se ajustaba demasiado bien a los datos de entrenamiento. Así, cuando enfrentábamos al modelo a un texto nuevo —por ejemplo, un artículo académico contemporáneo que mencionaba esas palabras—, este tendía a clasificarlo como medieval, aunque en realidad no lo fuera.

    En términos generales, este fenómeno se conoce como overfitting: el modelo reproduce muy bien los patrones del conjunto de entrenamiento, pero pierde capacidad de generalización sobre datos no vistos. La regularización busca mitigar justamente este problema, penalizando pesos demasiado grandes y obligando al modelo a aprender reglas menos extremas.

    Si bien, para mitigar el problema original que tuvimos en el diseño del ejemplo, una solución es añadir más variables que capturen características más generales del castellano medieval, otra estrategia para evitar el overfitting consiste en incorporar un término de regularización R(θ)R(\theta) a la función objetivo, de la siguiente forma:

    θ^=arg maxi=1mlog((y(i)|x(i)))αR(θ)\hat{\theta} = \argmax \sum_{i=1}^{m}\log(\mathbb{P}(y^{(i)}|x^{(i)})) – \alpha R(\theta)

    El objetivo de este nuevo término es penalizar pesos demasiado grandes. De este modo, una configuración de parámetros que ajusta perfectamente el conjunto de entrenamiento, pero lo hace mediante pesos muy altos, será menos preferida que otra que ajuste un poco peor, aunque con pesos más pequeños. En este sentido, mientras mayor sea α\alpha, mayor será la penalización y, por tanto, menor tenderá a ser la magnitud de los parámetros del modelo.

    Existen dos tipos de regularización especialmente populares. La primera es lasso, que corresponde a la regularización L1:

    R(θ)=i=1nθiR(\theta)=\sum_{i=1}^{n}|\theta_i|

    La segunda es ridge, que corresponde a la regularización L2:

    R(θ)=i=1nθi2R(\theta)=\sum_{i=1}^{n}\theta_i^2

    Qué enfoque conviene usar dependerá de la aplicación. Por una parte, ridge suele ser más fácil de optimizar, ya que su derivada es simple, mientras que lasso es más complejo porque el valor absoluto no es diferenciable en cero. Por otra parte, en términos de los pesos resultantes, ridge tiende a preferir vectores con muchos pesos pequeños, mientras que lasso favorece soluciones dispersas: algunos pesos pueden conservar valores relativamente grandes, pero muchos otros son llevados exactamente a cero. En consecuencia, lasso tiende a producir modelos con menos variables activas que ridge.

    Notas finales, y próxima entrada…

    En esta entrega vimos cómo clasificar texto mediante regresión logística, así como algunas de las complicaciones que pueden surgir en su aplicación. Aunque trabajamos con el caso de una variable dependiente binaria, este modelo puede generalizarse a nn categorías. Esa versión más general se explica en detalle en el capítulo 4 de Jurafsky y Martin.

    En la próxima entrada veremos que son los embeddings y por qué son una forma central de representar el significado de las palabras en NLP. Stay tuned!

    José Miguel Muñoz Urra – jmunozu@pulki.es

  • Large Language Model (LLM) from scratch. N-gram Language Models

    En la entrada anterior vimos un ejemplo práctico del algoritmo BPE. Entender esto es importante porque es la forma estándar en que las máquinas descomponen un texto en una representación que pueden manipular. Puesto que ahora sabemos hacerlo, el siguiente paso lógico es emplear esos fragmentos (o tokens) de alguna manera, para así generar texto elocuente.

    En este sentido, la estrategia más popular —enfatizo: popular, no necesariamente mejor— para generar texto “nuevo” a partir de un contexto consiste en calcular la probabilidad de la siguiente palabra o token, condicional al contexto; es decir, P(siguiente palabra o token | contexto). Si nos restringimos a esta estrategia, una manera práctica de obtener esa probabilidad es usando n-gramas. No obstante, también se pueden usar redes neuronales —que es lo que usa ChatGPT—, entre otras alternativas.

    En este punto, el lector podría estar preguntándose: “¿Por qué aprender n-gramas si no es lo que utiliza ChatGPT?”. La respuesta es simple: es un marco conceptual muy ilustrativo, que nos permitirá aprender conceptos clave y, así, avanzar hacia el uso de redes neuronales. Como es usual, emplearemos como referencia el capítulo 3 de Jurafsky y Martin, 2025. Dicho esto, empecemos!

    Language models

    Un modelo de lenguaje (Language Model) es un modelo probabilístico que, dado un contexto, predice la siguiente palabra o token. Esta predicción se realiza construyendo una distribución de probabilidad sobre las posibles siguientes palabras/tokens. En términos concretos, supongamos que tenemos el siguiente corpus:

    Aquí llegó, donde otro no ha llegado,
    don Alonso de Ercilla, que el primero
    en un pequeño barco deslastrado,
    con solos diez pasó el desaguadero
    el año de cincuenta y ocho entrado
    sobre mil y quinientos, por hebrero,
    a las dos de la tarde, el postrer día,
    volviendo a la dejada compañía.

    La Araucana, Canto XXXVI. Alonso de Ercilla.

    y queremos predecir la palabra que viene después de la secuencia {a las dos de la}. Para eso, podemos calcular la distribución de probabilidad sobre las siguientes palabras condicional a venir de la secuencia {a las dos de la}. Si, por ejemplo, estamos interesados en saber la probabilidad de que la palabra siguiente sea {tarde}, deberíamos calcular P({tarde} | {a las dos de la}).

    En este punto, un lector ávido podría preguntarse: ¿por qué elegimos “a las dos de la” y no una secuencia más larga? El problema es que, en un corpus finito, cuanto más largo es el contexto, más raro se vuelve observar exactamente ese mismo contexto otra vez. En el extremo, si el contexto es demasiado largo, la estimación se vuelve muy frágil: muchos contextos no aparecen nunca, y los que aparecen pocas veces producen distribuciones casi deterministas (una única continuación con probabilidad cercana a 1), lo que puede socavar la variedad del texto generado.
    Por el contrario, si elegimos un contexto muy corto, perdemos coherencia: por ejemplo, incluso con un corpus grande, después de “y” puede venir casi cualquier palabra. En fin, en términos empíricos, el largo del contexto afecta de forma decisiva al resultado. Al escogerlo, el programador está imponiendo una estructura sobre cómo “depende” el siguiente token del pasado: en los modelos n-gram, esta idea se formaliza en la Markov assumption, según la cual la siguiente palabra depende solo de las últimas n1n-1 palabras del contexto (si quieres saber más sobre cadenas de Markov, puedes ver este video).

    Específicamente, cuando fijamos cuántos tokens usaremos como contexto, estamos eligiendo implícitamente nn en un modelo de n-gramas (ya que el contexto tiene tamaño n1n-1). Por ejemplo, un modelo de bigramas asume que la probabilidad de la siguiente palabra depende únicamente de la palabra anterior (si tomamos cada token como una palabra).

    ({tarde}|{la})\mathbb{P}(\{tarde\}|\{la\}),

    por otra parte, un trigrama asume que la probabilidad de la siguiente palabra depende de las dos palabras que preceden a aquella,

    ({tarde}|{de la}).\mathbb{P}(\{tarde\}|\{de \ la\}).

    Dicho esto, ahora estamos en condiciones de estimar estas probabilidades.

    Estimando las probabilidades…

    El método estándar para estimar estas probabilidades es usar Máxima Verosimilitud. Por ejemplo, para el caso de un bigrama, para calcular la probabilidad de una palabra wnw_{n}, dada una palabra wn1w_{n-1}, llevamos a cabo el siguiente cálculo,

    C(wn1wn)wC(wn1w).\frac{C(w_{n-1}w_{n})}{\sum_{w}C(w_{n-1}w)}.

    El siguiente código en Python calcula dicha frecuencia relativa empleando el Quijote de la Mancha de Miguel de Cervantes:

    import re
    from collections import Counter, defaultdict
    from typing import Dict, List, Tuple
    from pathlib import Path
    
    WORD_RE = re.compile(r"[^\W\d_]+(?:[-'][^\W\d_]+)*", re.UNICODE)
    
    def tokenize_words(text: str, lowercase: bool = True) -> List[str]:
        if lowercase:
            text = text.lower()
        return WORD_RE.findall(text)
    
    def bigrams(tokens: List[str], add_start_end: bool = True) -> List[Tuple[str, str]]:
        if add_start_end:
            tokens = ["<s>"] + tokens + ["</s>"]
        return list(zip(tokens[:-1], tokens[1:]))
    
    def bigram_mle_words(text: str, lowercase: bool = True, add_start_end: bool = True):
        toks = tokenize_words(text, lowercase=lowercase)
        bi = bigrams(toks, add_start_end=add_start_end)
    
        context_counts = Counter([w1 for (w1, w2) in bi])   # C(w_{i-1})
        bigram_counts  = Counter(bi)                        # C(w_{i-1}, w_i)
    
        probs: Dict[str, Dict[str, float]] = defaultdict(dict)
        for (w1, w2), c12 in bigram_counts.items():
            probs[w1][w2] = c12 / context_counts[w1]       # MLE
    
        return {"tokens": toks, "context_counts": context_counts, "bigram_counts": bigram_counts, "probs": probs}
    
    def top_next(probs: Dict[str, Dict[str, float]], prev: str, k: int = 10):
        if prev not in probs:
            return []
        return sorted(probs[prev].items(), key=lambda x: x[1], reverse=True)[:k]
    
    if __name__ == "__main__":
        path = Path("H:\\quijote.txt") 
        text = path.read_text(encoding="utf-8")
        model = bigram_mle_words(text)
        print("Tokens:", model["tokens"]) 
        print("Top después de 'tropezando':", top_next(model["probs"], "tropezando", 5))

    Al ejecutar el código, podemos ver que la palabra «tropezando», aparece en cuatro oportunidades:

    dificultoso, tropezando aquí, cayendo allí, levantándose acullá,

    y

    resucitar la ya muerta andante caballería, y ha muchos días que, tropezando aquí,

    y

    y don Quijote, a gran priesa, tropezando aquí y cayendo

    y, finalmente en

    Quijote, van ya tropezando, y han de caer del todo, sin duda alguna. Vale.

    Entonces, wC({tropezando}{w})=4\sum_{w}C(\{tropezando\}\{w\}) = 4, C({tropezando}{aqui})=3,C(\{tropezando\}\{aqui\}) = 3, y C({tropezando}{y})=1.C(\{tropezando\}\{y\}) = 1. Luego, la probabilidad de que «aquí» sea la próxima palabra después de «tropezando» es 34\frac{3}{4} e «y», 14\frac{1}{4}, tal cual como la ejecución del código predice:

    También, con fines didácticos podemos empezar con una palabra, y elegir las que exhiben la probabilidad más alta de ser las siguientes. A modo de ejemplo, si partimos con la palabra «Fazaña» (gracias Aradia), la secuencia que generamos es:

    Fazaña fue el cual se le dijo don quijote

    Donde las probabilidades son:

    PalabraProbabilidad
    Fazaña
    fue1
    el0.0998
    cual0.0343
    se0.0639
    le0.0677
    dijo0.0662
    don0.1648
    quijote0.8182

    También, podemos calcular la probabilidad de esta frase como

    P({Fazan~a fue el cual se le dijo don quijote})=1×P({fue}|{Fazan~a})×P({el}|{fue})×P({cual}|{el})×P({se}|{cual})×P({le}|{se})×P({dijo}|{le})×P({don}|{dijo})×P({quijote}|{don})P(\{Fazaña \ fue \ el \ cual \ se \ le \ dijo \ don \ quijote\}) = 1 \times P(\{fue\}|\{Fazaña\})\times P(\{el\}|\{fue\})\times P(\{cual\}|\{el\})\times P(\{se\}|\{cual\}) \times P(\{le\}|\{se\}) \times P(\{dijo\}|\{le\}) \times P(\{don\}|\{dijo\})\times P(\{quijote\}|\{don\})

    cuyo valor viene dado por:

    0.00000013218703

    Como podemos ver, dicho valor es pequeño, al igual que el de las probabilidades condicionales. Cuando n, o nuestro modelo crece, las probabilidades pueden ser aún más pequeñas, provocando un problema conocido como numerical underflow. Para evitarlo, usualmente se guardan las probabilidades en log, y luego, si queremos recuperar el número original, sólo debemos tomar el exponente. Este truco será utilizado más adelante.

    Evaluando modelos de lenguaje

    La mejor manera de evaluar modelos es mediante la evaluación extrínseca. En términos concretos, si por ejemplo, queremos evaluar que tan bueno es un autocomplete que estamos desarrollando, una evaluación extrínseca sería determinar el porcentaje de sugerencias que fueron aceptadas por el escritor. Debido a que esta evaluación es costosa, usualmente se lleva a cabo otro método llamado evaluación intrínseca. Este tipo de evaluación, mide un modelo por una métrica interna que refleja qué tan bien cumple su objetivo directo, es decir, mide la calidad del modelo independiente de su aplicación. Dentro de este tipo de evaluaciones, una popular es llamada perplexity. En la siguiente sección la veremos en detalle.

    Evaluación intrínseca: Perplexity.

    Volvamos al ejemplo del autocomplete. Supongamos que entrenamos un modelo de bigramas con el texto del Quijote de la Mancha. Posteriormente, como es usual, evaluamos su eficacia en un texto de prueba. Si nuestro modelo predice mejor las siguientes palabras (o tokens) en ese conjunto, entonces estará “menos sorprendido” cuando aparezca la palabra correcta, en comparación con otro modelo. En el caso ideal, un autocomplete muy bueno asignaría una probabilidad muy alta a la palabra correcta y muy baja a las alternativas incorrectas.

    Dicho esto, es intuitivo pensar que la probabilidad que el modelo asigna al texto de prueba debe ser un ingrediente relevante para una métrica. Sin embargo, no se suele usar la probabilidad total del texto, porque depende fuertemente de su longitud: al ser un producto de muchas probabilidades condicionales, disminuye a medida que el texto se hace más largo. Por eso, una métrica adecuada debe normalizar por la cantidad de palabras (o tokens), y medir el desempeño promedio por token; de ahí surge la perplexity . En particular, su fórmula viene dada por:

    perplexity=(w1,w2,...,wN)1Nperplexity = \mathbb{P}(w_{1},w_{2},…,w_{N})^{-\frac{1}{N}}

    Al observar la fórmula, podemos ver que depende inversamente de la probabilidad conjunta. Por este motivo, una menor «perplejidad», implica un mejor modelo. Teniendo esto en mente, a continuación, presentamos el código que calcula la perplejidad para el modelo de bigramas entrenado en El Quijote de la Mancha, y que usa Fuente Ovejuna de Lope de Vega como texto de prueba (lo puedes encontrar aquí):

    import re
    import math
    from collections import Counter
    from typing import List, Tuple
    from pathlib import Path
    
    WORD_RE = re.compile(r"[^\W\d_]+(?:[-'][^\W\d_]+)*", re.UNICODE)
    
    def tokenize_words(text: str, lowercase: bool = True) -> List[str]:
        if lowercase:
            text = text.lower()
        return WORD_RE.findall(text)
    
    def bigrams(tokens: List[str], add_start_end: bool = True) -> List[Tuple[str, str]]:
        if add_start_end:
            tokens = ["<s>"] + tokens + ["</s>"]
        return list(zip(tokens[:-1], tokens[1:]))
    
    def train_bigram_counts(train_text: str, lowercase: bool = True):
        toks = tokenize_words(train_text, lowercase=lowercase)
        bi = bigrams(toks, add_start_end=True)
    
        context_counts = Counter([w1 for (w1, _) in bi])  # C(w_{i-1})
        bigram_counts  = Counter(bi)                      # C(w_{i-1}, w_i)
    
        # vocab del train (incluye tokens especiales)
        vocab = set(toks)
        vocab.update(["<s>", "</s>", "<unk>"])
    
        return context_counts, bigram_counts, vocab
    
    def perplexity_bigram_addk(
        test_text: str,
        context_counts: Counter,
        bigram_counts: Counter,
        vocab: set,
        k: float = 1.0,           # k=1.0 => Laplace; k=0.1 => add-0.1; k=0 => MLE puro (puede dar inf)
        lowercase: bool = True
    ) -> float:
        # Tokeniza test y mapea OOV a <unk>
        toks = tokenize_words(test_text, lowercase=lowercase)
        toks = [t if t in vocab else "<unk>" for t in toks]
    
        bi = bigrams(toks, add_start_end=True)
        V = len(vocab)
    
        # log-prob promedio
        log_sum = 0.0
        N = 0
    
        for w1, w2 in bi:
            c12 = bigram_counts.get((w1, w2), 0)
            c1  = context_counts.get(w1, 0)
    
            if k == 0.0:
                # MLE puro: si c12=0 => prob=0 => perplexity infinita
                if c12 == 0 or c1 == 0:
                    return float("inf")
                p = c12 / c1
            else:
                # add-k smoothing:
                # P(w2|w1) = (C(w1,w2)+k) / (C(w1)+k*V)
                p = (c12 + k) / (c1 + k * V)
    
            log_sum += math.log(p)
            N += 1
    
        # perplexity = exp(- (1/N) * sum log p)
        return math.exp(-log_sum / N)
    
    if __name__ == "__main__":
        quijote_path = Path(r"H:\quijote.txt")
        fuente_path  = quijote_path.with_name("fuenteovejuna.txt")
    
        train_text = quijote_path.read_text(encoding="utf-8")
        test_text  = fuente_path.read_text(encoding="utf-8")
    
        context_counts, bigram_counts, vocab = train_bigram_counts(train_text)
    
        ppl = perplexity_bigram_addk(
            test_text,
            context_counts,
            bigram_counts,
            vocab,
            k=1.0  # prueba también k=0.1 o k=0.01
        )
    
        print(f"Vocab size (train): {len(vocab)}")
        print(f"Perplexity (Fuenteovejuna | trained on Quijote) with add-k={1.0}: {ppl:.4f}")

    En términos generales, este código empieza ignorando puntuación y números, pero permitiendo apóstrofes y guiones. Luego, la función tokenize_words() pasa todo a minúsculas y devuelve una lista de tokens (que en este caso son palabras completas). bigrams() construye las secuencias de dos palabras, para luego crear pares consecutivos, es decir, si en la variable tokens entra una lista similar a esta:

    tokens = ["don", "quijote", "vive"]

    el código devuelve pares consecutivos con el siguiente aspecto:

    [("don", "quijote"), ("quijote", "vive")]

    train_bigram_counts() lleva a cabo el conteo de esos pares para que sea posible calcular

    P(w2|w1)^=C(w1,w2)C(w1).\hat{P(w_{2}|w_{1})} = \frac{C(w_{1},w_{2})}{C(w_{1})}.

    Luego, al vocabulario visto en el texto de entrenamiento se le añade <unk> para posteriormente representar palabras desconocidas en el texto de prueba.

    Finalmente, para calcular la perplejidad, primero, detectamos las palabras desconocidas, es decir, palabras que están en el texto de prueba y que no se encuentran en el vocabulario generado por el texto de entrenamiento:

    toks = tokenize_words(test_text)
    toks = [t if t in vocab else "<unk>" for t in toks]

    para posteriormente, hacer el conteo:

    c12 = bigram_counts.get((w1, w2), 0)
    c1 = context_counts.get(w1, 0) 

    que específicamente, calcula C(w1,w2)C(w_{1},w_{2}) y C(w1)C(w_{1}) del texto de entrenamiento, para w1w_{1} y w2w_{2} del texto de prueba.

    En este punto, a pesar de tener el conteo del numerador y denominador, aún no podemos realizar la división. Pese a que la fracción está blindada de la división por cero (puesto que al incluir <unk>, C(w1)C(w_{1}) nunca será cero), la fracción

    P(w2|w1)^=C(w1,w2)C(w1)\hat{P(w_{2}|w_{1})} = \frac{C(w_{1},w_{2})}{C(w_{1})}

    puede ser efectivamente cero, si C(w1,w2)C(w_{1},w_{2}) es cero, es decir, si no se encontró un bigrama similar en el texto de prueba. Esto es un problema por dos motivos. El primero, es que la perplejidad no se puede computar, puesto que es el inverso de la probabilidad, y debido a que es cero, estaríamos ante una división por cero. El segundo, es que, que no aparezca el bigrama en el texto de entrenamiento, implica directamente que tiene probabilidad 0, lo que lo convierte en un evento imposible, pero realmente no es imposible, muy probablemente es sólo una manifestación de un corpus de entrenamiento limitado, lo que en la practica, puede socavar muchas aplicaciones. Principalmente por estos motivos, es necesario llevar a cabo un smoothing. Existen múltiples tipos, pero nosotros sólo veremos el add-k, cuyo principio es añadir k unidades al numerador, y kV unidades al denominador, para evitar la división por cero:

    P(w2|w1)^=C(w1,w2)+kC(w1)+kV.\hat{P(w_{2}|w_{1})} = \frac{C(w_{1},w_{2}) + k}{C(w_{1}) + kV}.

    Por simplicidad, asumiremos k=1 (conocido como Laplace smoothing), y posteriormente calcularemos la perplejidad en logs:

    log_sum += math.log(p)
    N += 1
    ...
    ...
    return math.exp(-log_sum / N)

    cuyo número viene dado por

    Perplexity (Fuenteovejuna | trained on Quijote) with add-k=1.0: 7196.6119

    Notas finales, y próxima entrada…

    En esta entrada vimos una estrategia llamada n-grams para predecir la siguiente palabra (o token) en un texto, condicionada a un contexto. Ese contexto puede ser la palabra inmediatamente anterior o, en general, las últimas n1n-1 palabras. Dicha predicción se lleva a cabo con probabilidades condicionales que pueden ser estimadas mediante Máxima Verosimilitud (MLE) a partir de un corpus de entrenamiento. Con fines didácticos, fijamos n=2n=2, entrenamos un modelo de bigramas usando El Quijote de la Mancha y, finalmente, calculamos la perplejidad, un estadístico que permite realizar una evaluación intrínseca del modelo.

    Esta estrategia es una base importante para el aprendizaje, porque captura uno de los principios centrales detrás del funcionamiento de los LLMs: modelar el lenguaje como una distribución de probabilidad sobre el siguiente token.

    En la próxima entrega veremos cómo clasificar texto, un tema clave en procesamiento del lenguaje natural. Stay tuned!

    José Miguel Muñoz Urra – jmunozu@pulki.es

  • Large Language Model (LLM) from scratch. Algoritmo BPE: Ejemplo práctico.

    En la entrada anterior vimos cómo funciona el algoritmo BPE. En esta entrega veremos cómo los modelos LLM a gran escala tokenizan en la práctica, y luego tokenizaremos un corpus artesanalmente utilizando Python.

    El tokenizador de OpenAI para el modelo gpt4o

    Como referencia práctica, veremos cómo tokeniza GPT-4o con tiktokenizer; su tokenizador usa un vocabulario grande (~200K tokens), típico de sistemas comerciales. Por ejemplo, para el siguiente extracto de Altazor, de Vicente Huidobro:

    Todas las lenguas están muertas
    Muertas en manos del vecino trágico
    Hay que resucitar las lenguas
    Con sonoras risas

    El número total de tokens viene dado por 32:

    y cada token tiene un id único, que en este caso vienen dados por:

    Un ejercicio interesante es ver qué ocurre cuando traducimos este texto al inglés, y calculamos sus tokens:

    Con un número similar de palabras, la lengua inglesa suele generar menos tokens. A eso se le llama sobre-segmentación: entre otras razones, el tokenizador “rompe” más el texto cuando el idioma está menos favorecido por las frecuencias del corpus de entrenamiento (en este caso, el español). El resultado es más tokens para decir lo mismo, lo que encarece contexto y cómputo (Jurafsky y Martin, 2025). Moraleja pragmática: si quieres ahorrar dinero, escribe instrucciones en inglés!.

    Creando nuestros propios tokens en Python!

    Para construir nuestro tokenizador usaremos la librería tokenizers de Python, que implementa Byte-Level BPE. Hasta aquí ya entendemos qué es BPE; lo que falta es el componente byte-level. A continuación lo explicamos brevemente.

    Byte-level BPE

    En la entrada anterior describimos BPE partiendo desde caracteres. En byte-level BPE el punto de partida es distinto: antes de aplicar BPE, el texto se convierte a una secuencia de bytes (normalmente en UTF-8). Esto tiene una consecuencia clave: como cualquier texto en UTF-8 siempre es una secuencia de bytes entre 0x00 y 0xFF, el tokenizador dispone de un “alfabeto” base de 256 símbolos (uno por byte). A partir de ahí, BPE aprende a fusionar pares frecuentes de símbolos para formar tokens más largos. Para verlo, nota cómo se codifican algunos caracteres en UTF-8:

    CarácterUTF-8 (Hex)Unicode
    a0x61 (1 byte)U+0061
    ñ0xC3 0xB1 (2 bytes)U+00F1

    Si ahora tomamos el texto ñ😅, su representación UTF-8 es:

    Texto UTF-8 (Hex)Unicode
    ñ😅0xC3 0xB1 0xF0 0x9F 0x98 0x85 (6 bytes)U+00F1 U+1F605

    La pregunta natural es: ¿cuántos tokens produce?
    En byte-level el “peor caso” (sin fusiones útiles) es directo: 1 token por byte, así que ñ😅 se representaría con 6 tokens. Sin embargo, en la práctica BPE aprende merges, y puede ocurrir que el tokenizador tenga un token que corresponda exactamente a la secuencia 0xC3 0xB1 (es decir, “ñ”), o incluso un token para el emoji completo. En ese caso, el mismo texto podría bajar a 5 tokens (si “ñ” es un token) o aún menos (si el emoji también se fusiona), es decir 2 tokens.

    Esto explica por qué con byte-level BPE casi no existen “palabras desconocidas”: siempre se puede “caer” al nivel byte y representar cualquier texto. El problema ahora es la sobre-segmentación: si tu corpus no contiene suficientes ejemplos de ciertos patrones (jerga, nombres propios, emojis, mezclas de idiomas), esos fragmentos se partirán en más tokens, haciendo las secuencias más largas y aumentando el coste.

    Dada esta breve introducción, ahora estamos en condiciones de crear tokens.

    Generando tokens con Cervantes

    Para este ejemplo, generaremos los tokens utilizando El Quijote de la Mancha de Miguel de Cervantes como input (lo puedes encontrar aquí en texto plano!). Para esto, emplearemos el siguiente código en Python:

    from tokenizers import ByteLevelBPETokenizer
    
    tokenizer = ByteLevelBPETokenizer()
    tokenizer.train(
        files=["quijote.txt"],
        vocab_size=25000,    
        min_frequency=2,    
        special_tokens=["<s>", "</s>", "<pad>", "<unk>"]
    )
    
    vocab = tokenizer.get_vocab()
    print("Tamaño del vocabulario:", len(vocab))

    donde files, es una lista que contiene la ruta de los archivos que servirán como corpus, vocab_size es el tamaño objetivo del diccionario de tokens que va a tener el tokenizador, min_frequency es el umbral mínimo de apariciones que debe tener un patrón en nuestro corpus para ser fusionado, y por último, special_tokens, es una lista de tokens que se deben incluir sí o sí en el vocabulario.

    La ejecución de este código da un tamaño total de vocabulario de 22.349 tokens. Para el mini-LLM que tenemos en mente, este número es más que aceptable. Sin embargo, en LLM’s grandes, necesitaríamos entre 50k-200k tokens para tener un modelo relativamente robusto.

    Ahora que ya contamos con un tokenizador entrenado, el siguiente paso es evaluar su desempeño. Para ello, ejecutamos las siguientes líneas de código:

    TEXT = "¡A ti lo agradezco, Dios, que cielo y tierra guías! ¡Válganme tus virtudes, gloriosa santa María! Desde aquí dejo a Castilla pues que al Rey he en ira"
    enc = tokenizer.encode(TEXT)
    print("Texto:", TEXT)
    print("Nº tokens:", len(enc.ids))
    print("IDs:", enc.ids)
    print("Tokens:", enc.tokens)

    Donde la variable TEXT contiene un texto que no está en el corpus original (es un fragmento del Cantar del Mío Cid modernizado). Habiendo dicho esto, el presente texto entrega un total de 38 tokens.

    En este sentido, con la finalidad de ilustrar el problema de la sobre-segmentación, entrenaremos un tokenizador sobre un corpus deliberadamente pobre y observaremos cómo esto incrementa el número de tokens necesarios para codificar la misma frase de prueba. Para ello, utilizaremos el siguiente corpus:

    En el 238 a. C. la tomaron los romanos, como consecuencia de la derrota cartaginesa en la Primera Guerra Púnica. Cerdeña se convirtió en provincia romana, y las ciudades costeras que existían fueron engrandecidas y embellecidas, mientras que se fundaron colonias como Turris Lybissonis y Feronia, que fueron pobladas por inmigrantes romanos.

    Dejando todo lo demás constante, el número de tokens de la frase de prueba es ahora 127, considerablemente más que cuando teníamos un corpus más rico.

    Por último, vale la pena recalcar que este proyecto consiste en construir nuestro propio LLM (pequeño) desde cero. Para esto, antes de entrenar el modelo, necesitamos dos piezas: un corpus (los textos con los que aprenderá) y un tokenizador entrenado sobre ese corpus. A escala industrial, lo caro no es solo entrenar: es reunir, limpiar, filtrar y deduplicar cantidades enormes de texto. Para ponerlo en perspectiva, en GPT-2 OpenAI entrenó con WebText: un dataset de algo más de 8 millones de documentos, que suman aproximadamente 40 GB de texto.

    En esta serie no vamos a perseguir esa escala: trabajaremos con un corpus acotado, suficiente para entender el flujo completo.

    Notas finales, y próxima entrada…

    En esta entrada vimos un pequeño ejemplo de como funciona el algoritmo BPE, que es clave en la construcción de un LLM. En la próxima entrega introduciremos los modelos de lenguaje n-gram: modelos probabilísticos que asignan probabilidad a la próxima palabra y a oraciones completas. Stay tuned!

    José Miguel Muñoz Urra – jmunozu@pulki.es