Ver el código de Dani en GitHub · Revisión aa47b7b
Quería entender qué sucede dentro de un modelo de lenguaje, más allá de consumir una API. Así que decidí construir uno en C++ y enfrentarme a las piezas que normalmente quedan escondidas: representar palabras, calcular probabilidades, propagar gradientes y comprobar qué aprende realmente la red. Al principio, juntar todos esos conceptos en un programa que funcionara me resultó más difícil de lo que esperaba.
La versión publicada de este proyecto se llama Dani. Es un modelo neuronal educativo pequeño, no un LLM de producción. Una precisión importante respecto al planteamiento inicial del artículo: el código actual no implementa un Transformer, self-attention ni un optimizador Adam. A continuación explico la arquitectura que sí está en el repositorio, con fragmentos de una revisión concreta del código.
Qué modelo construí: embeddings y una red pequeña
Empecé convirtiendo palabras en identificadores con un tokenizador y asignándole a cada una un embedding de 12 dimensiones. Después, el modelo combina un resumen de la pregunta con las últimas ocho palabras de la respuesta, pasa esas características por una capa tanh de 48 unidades y calcula una distribución sobre el vocabulario. Una de las primeras complicaciones fue entender cómo conectar esas representaciones sin perder de vista qué información recibía la red en cada paso.
Es una red de tipo perceptrón multicapa con contexto. No utiliza pesos preentrenados ni atención. Esa escala permite seguir el recorrido completo de una predicción sin confundir el experimento con la arquitectura o la capacidad de un GPT moderno.
Ambas ramas comparten la misma tabla de embeddings. Las posiciones de respuesta vacías se rellenan con ceros.
Sin atención ni Transformer: un perceptrón multicapa con contexto.
Softmax estable: evitar desbordamientos
Cuando llegué a la salida de la red, tuve que convertir puntuaciones en probabilidades. Softmax aplica exponenciales y normaliza el resultado, pero ahí apareció un detalle que es fácil pasar por alto: calcular exp(x) directamente puede desbordar cuando una puntuación es grande. Para evitarlo, la implementación resta el máximo antes de aplicar la exponencial.
Restar la misma constante a todas las puntuaciones no cambia la distribución matemática, pero mantiene los exponentes en un rango más seguro. La función también rechaza entradas vacías o puntuaciones no finitas. Este es el método completo que utiliza Dani:
vector<double> Model::softmax(const vector<double>& scores) const {
if (scores.empty()) return {};
const double maximum = *max_element(scores.begin(), scores.end());
vector<double> probabilities(scores.size());
double total = 0.0;
for (size_t index = 0; index < scores.size(); ++index) {
if (!isfinite(scores[index])) return {};
probabilities[index] = exp(scores[index] - maximum);
total += probabilities[index];
}
for (double& value : probabilities) value /= total;
return probabilities;
}Cómo conserva la pregunta mientras genera texto
En cada predicción, el modelo mantiene disponible un promedio de los embeddings de la pregunta. El fragmento siguiente pertenece a context_embedding: identifica las palabras anteriores al marcador assistant y añade su contribución al resumen.
Esta solución me ayudó a mantener la pregunta disponible, pero también me obligó a aceptar una limitación: al promediar, se pierde el orden de sus palabras. No equivale a atención ni garantiza interpretar correctamente la sintaxis o una negación. Las palabras recientes de la respuesta, en cambio, se concatenan en orden en otra parte de la misma función.
if (separator >= 0) {
// Pool the complete question separately so it survives long replies and small word shifts.
const size_t prompt_size = static_cast<size_t>(marker - token_ids.begin() - 1);
for (auto token = token_ids.begin() + 1; token != marker; ++token) {
const auto& embedding = embeddings.at(*token);
for (int dimension = 0; dimension < embedding_size; ++dimension) {
features[dimension] += embedding[dimension] / static_cast<double>(prompt_size);
}
}
reply_start = static_cast<size_t>(marker - token_ids.begin() + 1);
}Retropropagación antes de actualizar los pesos
El entrenamiento usa entropía cruzada y descenso de gradiente. La retropropagación fue una de las partes que más me costó razonar: no bastaba con calcular derivadas, también tenía que respetar el orden de las operaciones. En train_step, el código calcula los gradientes que deben volver hacia la capa oculta y las características de entrada antes de modificar los pesos. Mezclar pesos nuevos con gradientes de la predicción anterior produciría una actualización incoherente.
La expresión 1.0 - hidden[unit] * hidden[unit] aplica la derivada de tanh a su salida. El fragmento muestra sólo esta etapa; la función completa, enlazada debajo, también actualiza las matrices, los sesgos y los embeddings.
// Compute all upstream gradients before updating any weights.
vector<double> gradient_hidden(hidden.size(), 0.0);
for (size_t unit = 0; unit < hidden.size(); ++unit) {
for (size_t token = 0; token < gradient_scores.size(); ++token) {
gradient_hidden[unit] += output_weights[unit][token] * gradient_scores[token];
}
gradient_hidden[unit] *= 1.0 - hidden[unit] * hidden[unit];
}
vector<double> gradient_features(features.size(), 0.0);
for (size_t input = 0; input < features.size(); ++input) {
for (size_t unit = 0; unit < hidden.size(); ++unit) {
gradient_features[input] += hidden_weights[input][unit] * gradient_hidden[unit];
}
}Cómo compilar y reproducir el entrenamiento
Después de clonar el repositorio, estos comandos se ejecutan desde su carpeta raíz con un compilador compatible con C++17. El primero compila el programa y el segundo fuerza un entrenamiento con semilla 42 y ejecuta la evaluación. Son instrucciones del repositorio, no una nueva ejecución de entrenamiento realizada para este artículo.
La documentación describe 284 intercambios de entrenamiento en español, con ejemplos sintéticos y una pequeña muestra revisada de OpenAssistant. Los archivos de validación y prueba están separados. La semilla ayuda a reproducir la ejecución, pero por sí sola no garantiza resultados idénticos entre todas las plataformas.
clang++ -std=c++17 -O2 -Wall -Wextra -pedantic -Iinclude/llm \
src/main.cpp src/model.cpp src/tokenizer.cpp src/dataset.cpp -o llm_test
./llm_test --train --epochs 180 --seed 42 --evaluateQué significan los resultados y qué no puede hacer Dani
Al revisar los resultados, tuve que aprender a no dejarme llevar por una cifra aislada. En la ejecución documentada en el README, el vocabulario tiene 868 tokens y la precisión del siguiente token en prueba es 86,29 %. Son resultados publicados por el proyecto sobre un conjunto local pequeño, no un benchmark independiente ni una medida de conversaciones correctas.
La evaluación usa teacher forcing: recibe las palabras anteriores de la respuesta de referencia. Generar una respuesta completa es más difícil, porque un error puede cambiar todos los pasos siguientes. El propio README muestra ese límite: ante una pregunta sobre un gato, el modelo llegó a responder sobre un perro.
Dani no conserva historial entre mensajes ni aprende durante el chat. Además, algunas preguntas personales conocidas se responden mediante un catálogo determinista, separado de la red. El modo --neural-only permite examinar la salida neuronal sin esa capa. Distinguir estas piezas es más útil que presentar cualquier respuesta acertada como comprensión del lenguaje.
Qué aprendí sobre AI engineering
Mi aprendizaje más importante fue conectar cada afirmación con algo que pudiera inspeccionar: una función, un conjunto de datos, una prueba o una salida del modelo. Hubo momentos en los que era tentador celebrar que el programa compilara o que la pérdida bajara, pero ninguna de esas cosas demuestra por sí sola que converse bien.
Para seguir estudiando Transformers y conexiones residuales, los papers enlazados al final son lecturas útiles. Son una dirección de aprendizaje, no una descripción de la arquitectura actual de Dani. El valor de construir algo pequeño desde cero está en comprender sus decisiones y reconocer sus límites.


