Archivo del Autor: ADRIAN MALDONADO ROBLES

Multi-Task Learning en LLMs: entrenamiento separado, Hard Sharing y Soft Sharing para tareas de NLP

1. INTRODUCCIÓN

En este trabajo se analiza el Multi-Task Learning aplicado a varias tareas de NLP, con el fin de comparar el rendimiento cuando cada una se entrena por separado frente a cuando comparten parte del modelo. Para ello se estudian tres enfoques: entrenamiento independiente, Hard Parameter Sharing y Soft Parameter Sharing.

Las tareas elegidas son NLI, NER y detección de paráfrasis, tres problemas distintos pero lo bastante relacionados como para estudiar posibles efectos de transferencia. NLI consiste en decidir la relación entre dos frases; NER identifica entidades dentro de un texto; y PAWS determina si dos oraciones expresan o no la misma idea.

A partir de este planteamiento, el objetivo es comprobar qué estrategia resulta más eficaz y en qué casos compartir conocimiento entre tareas aporta valor.

El código y los resultados completos están disponibles en el repositorio de GitHub del proyecto: https://github.com/RoblesAdrian/PASD-Blog_Gonzalez-Maldonado-Mao

2. ESTADO DEL ARTE

El Multi-Task Learning se ha utilizado ampliamente en NLP como una forma de aprovechar relaciones entre tareas y compartir representaciones internas cuando existe cierto grado de afinidad entre ellas [1][2]. En este contexto, DistilBERT resulta una base especialmente adecuada por su tamaño reducido y su buen equilibrio entre eficiencia y rendimiento [3]. La literatura también apunta a que compartir parámetros no garantiza mejoras automáticas, ya que el resultado final depende del grado de compatibilidad entre tareas y del tipo de compartición empleado [4]. Sobre esa base, este trabajo compara entrenamiento independiente, Hard Sharing y Soft Sharing para analizar cómo cambia el comportamiento del modelo según la estrategia elegida.

3. MATERIALES

Para este trabajo se han utilizado tres conjuntos de datos públicos y de uso habitual en NLP: MNLI del corpus GLUE, WikiANN en inglés y PAWS-Wiki labeled final.

MNLI se ha empleado para la tarea de Natural Language Inference (NLI), donde el modelo debe decidir la relación entre dos frases. Un ejemplo sería una premisa como “A man is playing a guitar on stage” y una hipótesis como “Someone is performing music”, que el sistema debe clasificar según si la primera frase implica, contradice o simplemente no se relaciona con la segunda [5].

WikiANN se ha usado para Named Entity Recognition (NER), en el que cada palabra del texto recibe una etiqueta. Por ejemplo, en una frase como “Barack Obama visited Paris”, el modelo debe identificar Barack Obama como persona y Paris como lugar [6].

PAWS se ha utilizado para la detección de paráfrasis, es decir, decidir si dos oraciones expresan la misma idea. Un ejemplo sería comparar “The company released a new product” con “A new product was launched by the company”, donde el modelo debe determinar si ambas frases son equivalentes en significado [7].

En cuanto al tamaño de los datos, se ha trabajado con estos subconjuntos: NLI: 31.416 ejemplos de entrenamiento y 7.854 de evaluación, NER: 16.000 de entrenamiento y 4.000 de evaluación, y PAWS: 39.520 de entrenamiento y 9.881 de evaluación. Esta elección permite mantener los tres problemas en un orden de magnitud parecido y, al mismo tiempo, hacer viable la ejecución de todos los experimentos dentro de un tiempo razonable.

Como modelo base se ha empleado DistilBERT-base-uncased, junto con las librerías transformers, datasets y evaluate de Hugging Face. Para NER fue necesario alinear correctamente las etiquetas con los tokens generados por el tokenizador, ya que cada palabra tiene una etiqueta, pero no tiene por qué corresponder a un único token. En NLI y PAWS se trató como un problema de clasificación para la frase completa.

Además del código de entrenamiento, también se han utilizado scripts propios para guardar métricas, registrar el historial de entrenamiento y generar las gráficas comparativas finales.

4. ENTRENAMIENTO POR SEPARADO

La primera referencia fue el entrenamiento separado, donde hicimos fine-tuning del modelo base distilbert-base-uncased para cada una de las tareas y sin compartir parámetros por las demás. De este modo, obtuvimos un modelo de clasificación independiente para cada problema, con su propio conjunto de datos, su propio ajuste y sus propias métricas.

5. HARD PARAMETER SHARING

La primera aproximación al Multi-Task Learning fue mediante Hard Parameter Sharing, en la que todas las tareas comparten el mismo modelo base y solo cambia la capa final de salida. En nuestro caso, el backbone fue distilbert-base-uncased, y sobre él se añadieron tres cabezas distintas, una para NLI, otra para NER y otra para PAWS.

self.backbone = AutoModel.from_pretrained(backbone_name)
self.nli_head = nn.Linear(hidden, nli_labels)
self.paws_head = nn.Linear(hidden, paws_labels)
self.ner_head = nn.Linear(hidden, ner_labels)

El entrenamiento se realizó de forma alterna, pasando por ejemplos de cada tarea dentro de una misma época. La idea se ve en este bloque, donde se va cambiando de tarea en cada paso:

for step in range(steps_per_epoch):
    for task in task_order:
        batch = next(task_loaders[task])
        outputs = model(task=task, ...)

Primero se entrenó la versión con las tres tareas a la vez y, después, varias configuraciones de dos tareas: NLI + NER, NLI + PAWS y NER + PAWS.

6. SOFT PARAMETER SHARING

La segunda aproximación al Multi-Task Learning fue mediante Soft Parameter Sharing, donde cada tarea mantiene su propio modelo base, pero se penaliza que sus parámetros se alejen demasiado entre sí. En nuestro caso, se usaron tres backbones independientes de distilbert-base-uncased, uno para NLI, otro para NER y otro para PAWS, junto con sus respectivas cabezas de salida.

A diferencia del Hard Sharing, aquí no se comparte una única representación interna, sino que se añade una pérdida de regularización que empuja a que los tres modelos aprendan representaciones parecidas. Esta parte se implementó comparando los pesos de los backbones dos a dos y sumando la distancia cuadrática entre ellos:

def regularization_loss(self):
    reg = 0.0
    pairs = [
        (self.backbone_nli, self.backbone_ner),
        (self.backbone_nli, self.backbone_paws),
        (self.backbone_ner, self.backbone_paws),
    ]
    for b1, b2 in pairs:
        for p1, p2 in zip(b1.parameters(), b2.parameters()):
            reg += torch.sum((p1 - p2) ** 2)
    return self.lambda_reg * reg

El entrenamiento también se realizó de forma alterna, pasando por batches de cada tarea dentro de una misma época. La diferencia es que, en este caso, cada tarea actualiza su propio backbone, pero siempre bajo la restricción impuesta por la regularización compartida:

for step in range(steps_per_epoch):
    for task in task_order:
        batch = next(task_loaders[task])
        outputs = model(task=task, ...)

Esta vez, para acotar la complejidad y el número de experimentos, consideramos suficiente implementarlo únicamente para la configuración de 3 tareas simultáneas (NER + NLI + PAWS).

7. RESULTADOS

6.1. Entrenamiento por separado

En primer lugar, analizamos el entrenamiento base para comprobar que se obtienen buenos resultados: el entrenamiento es estable, converge y el modelo es capaz de aprender a resolver cada una de las tres tareas. Esto servirá como referencia para el resto de experimentos.

Evolución de la función de pérdida (Train y Test) y métricas de evaluación (Accuracy y Macro F1) en el entrenamiento de cada una de las tareas, por separado.

6.2. Resultados globales

Ahora pasamos a analizar los resultados globales, empezando por Hard Parameter Sharing. Como cabría esperar, el entrenamiento de cada tarea por separado obtiene mejores resultados. Sin embargo, los resultados de Hard Sharing para cada una de las tareas son aceptables y bastante consistentes. A primera vista, no hay dos tareas más cercanas que otras, y únicamente hay una combinación que resulta especialmente negativa en el aprendizaje: NER + PAWS para PAWS (tampoco indica necesariamente que estas tareas sean lejanas, pues NER + PAWS funciona bien para NER).

El aspecto más interesante está en Soft Parameter Sharing: no sólo iguala los resultados del entrenamiento por separado, sino que en dos de las tareas los mejora ligeramente. Una posible hipótesis es que la información de las otras tareas da robustez al modelo, y en este caso sin perder riqueza como sí había sucedido con Hard Sharing.

Si nos fijamos en el Accuracy, la visión que obtenemos es similar, con unos resultados más ajustados si cabe:

6.3. Evolución del entrenamiento

Uno de los aspectos más interesantes a analizar es la evolución de las curvas de entrenamiento. Para ello, esta vez nos fijamos en la función de pérdida (Train) y la métrica Macro F1 Score. Inmediatamente observamos que el entrenamiento por separado es más estable que ambos tipos de compartición, ya que no hay otras tareas interfiriendo en el mismo.

Conviene tener en cuenta que los steps no son directamente comparables entre entrenamientos y épocas: en Multi-Task Learning, el modelo se actualiza más veces porque alterna entre tareas, aunque el número de épocas sea el mismo. Por eso, al comparar las curvas de entrenamiento, normalizamos el progreso al intervalo [0,1]. En este contexto, el entrenamiento separado puede parecer más lento en converger, probablemente porque en el entrenamiento compartido el modelo también recibe actualizaciones procedentes de las otras tareas, algo que no queda reflejado en esa normalización.

Finalmente, Soft Sharing converge y mejora las métricas antes que Hard Sharing, algo esperable siendo una configuración con mayor riqueza y menor rigidez.

Evolución de la función de pérdida (Train) y Macro F1 en el entrenamiento de cada una de las tareas, comparando por separado, Hard Sharing 3-way y Soft Sharing 3-way.

8. DISCUSIÓN

El primer aspecto que podemos plantear es por qué ha funcionado este enfoque. Una razón necesaria es que las tareas de NLP abordadas son lo suficientemente cercanas entre sí como para que lo que aprende una de ellas ayude, o al menos no perjudique, a las demás. Esto puede parecer obvio en tareas de clasificación de texto basadas en un modelo de lenguaje, pero no tiene por qué cumplirse siempre.

En esa misma línea, tan importante como analizar los posibles beneficios de compartir información entre tareas es considerar los posibles efectos negativos de forzarlas a convivir. Aunque apenas hemos conseguido una mejora marginal con los entrenamientos conjuntos, sí hemos observado que una de las combinaciones de Hard Sharing empeoraba de forma muy notable los resultados. Es fácil deteriorar el entrenamiento cuando se agrupan tareas que, en alguna de sus combinaciones, no encajan bien entre sí.

Otro aspecto relevante es el equilibrio entre coste computacional, complejidad del modelo y capacidad de representación. Hard Sharing es la opción más eficiente desde el punto de vista computacional, ya que implica entrenar un único modelo, incluyendo sus capas de salida. Sin embargo, esa simplicidad también limita la riqueza del aprendizaje, y en nuestros experimentos ha sido la configuración que peores métricas ha devuelto. En el extremo opuesto, el entrenamiento por separado ofrece la mayor flexibilidad, porque cada tarea se modela de forma independiente, pero a costa de multiplicar el número de modelos y, con ello, el gasto computacional.

Precisamente el interés de estas técnicas radica en conseguir mejorar ambas dimensiones al mismo tiempo. En nuestro caso, lo hemos conseguido con Soft Sharing: se mantiene una estructura eficiente al introducir relaciones entre parámetros, y el resultado no solo no empeora respecto al entrenamiento por separado, sino que mejora ligeramente el baseline en algunas configuraciones.

Para terminar, nos quedamos con la idea de que no existe una receta universal para obtener buenos resultados en Multi-Task Learning. Más bien, hay que combinar distintas estrategias según las características de los datos, las tareas y el objetivo final, y entender bien cuándo compartir información entre tareas aporta valor y cuándo, por el contrario, introduce ruido o interferencia.

9. REFERENCIAS

Sergio González Girones · Adrián Maldonado Robles · Alicia Mao Zhu

Explorando espacios de representación: transferencia y aprendizaje en modelos de lenguaje

1. INTRODUCCIÓN Y ESTADO DEL ARTE

Para realizar tareas de procesamiento de lenguaje natural (NLP), los grandes modelos
de lenguaje (LLMs) deben convertir la información de entrada, texto, en vectores de alta
dimensión que capturan información sintáctica y semántica. El aprendizaje de estas representaciones o embeddings (representation learning) permite al modelo generalizar su conocimiento a nuevas tareas (transfer learning).

Cada palabra o secuencia de palabras es transformada, en distintas etapas del modelo,
en vectores que resumen su significado en contexto. Entender cómo se construyen estas representaciones y cómo pueden manipularse es clave para interpretar y mejorar el rendimiento de los modelos. [1]

En esta entrada exploraremos paso a paso cómo se procesan los textos en un modelo
preentrenado, desde la tokenización hasta las primeras capas del modelo. A partir de ahí, experimentaremos con transformaciones del espacio latente a otros de menor dimensionalidad mediante autoencoders —como PCA o VAEs— para obtener representaciones más compactas. Finalmente, mediremos si es posible adaptar el modelo a trabajar con esta nueva representación sin perder demasiada precisión. [2]

2. MATERIALES

El objetivo principal de esta experimentación es observar cómo se representan las secuencias de texto en un modelo preentrenado y cómo afectan distintas transformaciones de estas representaciones al rendimiento en tareas de clasificación.

DATASET Y TAREA

Para este estudio utilizamos el conjunto de datos TREC (Question Classification), una
colección clásica en procesamiento del lenguaje natural compuesta por preguntas cortas etiquetadas según su tipo. Las categorías incluyen seis clases generales: ABBR
(abreviaciones), DESC (descripciones), ENTY (entidades), HUM (personas), LOC
(localizaciones) y NUM (números). El objetivo del modelo es predecir correctamente la
categoría de una pregunta a partir de su texto. El conjunto incluye aproximadamente
5.000 ejemplos de entrenamiento y 500 de test [3]. Es una tarea sencilla, que nos facilita probar diversas aproximaciones, y que evaluaremos con la métrica F-score.

MODELO

Se ha empleado el modelo distilbert-base-uncased, una versión ligera de BERT que
mantiene su arquitectura principal pero con menos capas [4]. Esto facilita la inspección
de las representaciones internas y reduce el coste computacional.

3. DEL LENGUAJE NATURAL AL ESPACIO LATENTE: PROCESADO PASO A PASO EN UN MODELO DE CLASIFICACIÓN

El primer paso en el recorrido de una secuencia textual por un modelo como DistilBERT es la tokenización. Esto transforma el texto en fragmentos más pequeños (tokens), que luego son convertidos en números mediante un vocabulario entrenado.

from transformers import AutoTokenizer

tokenizer = AutoTokenizer.from_pretrained("distilbert-base-uncased")

texto = "Where is the Eiffel Tower?"
tokens = tokenizer.tokenize(texto)

print(tokens)
['where', 'is', 'the', 'e', '##iff', '##el', 'tower', '?']

Cada token es una subpalabra, generada según reglas específicas del vocabulario del modelo (WordPiece). Estos tokens se traducen a identificadores numéricos únicos que corresponden a posiciones concretas en el vocabulario de DistilBERT.

token_ids = tokenizer.convert_tokens_to_ids(tokens)
print(token_ids)
[2073, 2003, 1996, 1041, 13355, 2884, 3578, 1029]

En una aplicación real, el modelo espera una entrada que incluya tokens especiales como [CLS] (al inicio) y [SEP] (al final). También se aplica padding y truncamiento para ajustar las secuencias a una longitud fija.

encoded = tokenizer(
    texto,
    padding="max_length",
    truncation=True,
    max_length=12,
    return_tensors="pt"
)

print(encoded["input_ids"])
tensor([[  101,  2073,  2003,  1996,  1041, 13355,  2884,  3578,  1029,   102,
0, 0]])

Donde:

101 → [CLS]

102 → [SEP]

0 → padding

Una vez que los tokens están representados como IDs, se pasan por la capa de embeddings del modelo. Cada ID es convertido en un vector denso de dimensión fija (en DistilBERT, 768).

from transformers import AutoModel
import torch

model = AutoModel.from_pretrained("distilbert-base-uncased")
with torch.no_grad():
    embedding_output = model.embeddings(encoded["input_ids"])

print(embedding_output.shape)
torch.Size([1, 12, 768])

Esto indica que hay una secuencia de 12 tokens, cada uno representado como un vector de 768 dimensiones. Estos vectores combinan embeddings de tokens, embeddings de posición y embeddings de tipo de segmento (para tareas específicas, por ejemplo la tarea de clasificación a continuación).

Una vez hemos analizado el proceso de transformación del texto hasta su representación en el espacio latente, vamos a explorar este espacio y ver qué transformaciones podemos hacer sobre el mismo.

4. AJUSTE DEL MODELO A EMBEDDINGS REDUCIDOS

El objetivo es que el modelo pueda trabajar directamente con representaciones comprimidas [5]. Exploramos dos enfoques para lograrlo:

  • Reducción de dimensionalidad con PCA
  • Compresión mediante un autoencoder variacional (VAE)

Ambos se implementan de dos formas:

  • Dentro del modelo, añadiendo una capa de expansión al principio que reconstituye los embeddings comprimidos a su dimensión original (768).
  • Fuera del modelo, reconstruyendo los embeddings antes de pasarlos a DistilBERT.

ENFOQUE 1: REDUCCIÓN DE DIMENSIONALIDAD CON PCA

REDUCCIÓN DE DIMENSIONALIDAD

Como primer enfoque, utilizamos Análisis de Componentes Principales (PCA) para reducir los embeddings generados por la capa de entrada de DistilBERT. Estos embeddings tienen una dimensión original de 768. En nuestro experimento, los comprimimos a 512 dimensiones.

pca = PCA(n_components=512)
reduced = pca.fit_transform(flat.numpy())

Primero tokenizamos el texto y extraemos los embeddings de entrada del modelo, que tienen forma [batch_size, seq_len, 768]. Luego aplicamos PCA, reduciendo la dimensión de 768 a 512. Para ello, reordenamos los datos como una matriz de vectores [batch_size × seq_len, 768], aplicamos la transformación, y reconstruimos el tensor original con la nueva dimensión reducida: [batch_size, seq_len, 512].

ADAPTACIÓN DEL MODELO

El siguiente reto fue adaptar el modelo para que pudiera trabajar con estos nuevos embeddings reducidos. Para ello, implementamos una clase que incorpora:

  • Una capa lineal de proyección: transforma los vectores de 512 dimensiones de vuelta a los 768 requeridos por DistilBERT.
  • El encoder original de DistilBERT, reutilizado tal cual.
  • Una capa de clasificación final para predecir la categoría de cada pregunta del dataset TREC.
class FineTunedDistilBERT(nn.Module):
    def __init__(self, model, num_labels):
        super(FineTunedDistilBERT, self).__init__()
        self.num_labels = num_labels
        self.distilbert = model.distilbert  # Get the base model
        self.embedding_projector = nn.Linear(num_components, 768)  # Project custom embeddings
        self.classifier = nn.Linear(768, num_labels)    # Your classifier head
        self.loss_fn = nn.CrossEntropyLoss()

    def forward(self, inputs_embeds, labels=None, attention_mask=None):
        # Project the reduced embedding to 768
        projected = self.embedding_projector(inputs_embeds)

        # Pass through DistilBERT encoder
        outputs = self.distilbert(inputs_embeds=projected, attention_mask=attention_mask)
        hidden_state = outputs.last_hidden_state  # shape: (batch_size, seq_len, hidden_size)

        # Use the [CLS]-like token (first token) for classification
        cls_representation = hidden_state[:, 0, :]  # (batch_size, hidden_size)
        logits = self.classifier(cls_representation)

        loss = None
        if labels is not None:
            loss = self.loss_fn(logits, labels)

        return {"loss": loss, "logits": logits}
RESULTADOS

Aunque la implementación era funcional, el modelo no aprendía y los resultados fueron muy malos. Especulamos sobre algunas posibles razones:

  • El modelo original está optimizado para trabajar con una distribución específica de entradas; al modificar la entrada, rompemos esa alineación.
  • La capa de expansión al inicio del modelo no recupera adecuadamente la información textual.

ENFOQUE 2: RECONSTRUCCIÓN CON PCA

En base a estos resultados, decidimos cambiar el enfoque: aplicamos reducción de dimensionalidad con PCA, pero esta vez reconstruyendo los embeddings antes de ingresarlos al modelo. Es decir:

  • Obtenemos los embeddings de entrada (768 dim).
  • Los comprimimos a un espacio latente de menor dimensión (ej. 512).
  • Reconstruimos los embeddings originales con la transformación inversa del PCA.
  • Pasamos estos vectores reconstruidos al modelo, sin modificar su arquitectura.

Realmente, al experimentar con esta arquitectura lo que estamos evaluando es cuánta información se pierde al codificar y decodificar la información con un autoencoder, en este caso PCA.

recovered = compressor.decode(compressor.encode(embeddings), embeddings.shape)

A diferencia del enfoque anterior, este método sí ofrece buenos resultados (que presentaremos más adelante), especialmente cuando el espacio latente no es demasiado reducido.

ENFOQUE 3: VAE PARA CODIFICACIÓN Y DECODIFICACIÓN

VAE INTEGRADO CON EL MODELO

En esta aproximación, de nuevo partimos de la arquitectura de DistilBERT, a la que añadimos un módulo de compresión VAE entrenado de forma conjunta. La arquitectura utilizada es simple y simétrica: un codificador con una capa oculta de 512 unidades que proyecta a un espacio latente de dimensión configurable, y un decodificador con la misma estructura en orden inverso. Se usa ReLU como activación intermedia. La pérdida combina MSE (error de reconstrucción) y divergencia KL (forzar distribución normal en el espacio latente).

class VAECompressor(nn.Module):
    def __init__(self, input_dim, latent_dim):
        super(VAECompressor, self).__init__()
        self.input_dim = input_dim
        self.latent_dim = latent_dim
        
        # Encoder: input -> hidden -> latent
        self.fc1 = nn.Linear(input_dim, 512)
        self.fc2_mu = nn.Linear(512, latent_dim)
        self.fc2_logvar = nn.Linear(512, latent_dim)
        
        # Decoder: latent -> hidden -> output
        self.fc3 = nn.Linear(latent_dim, 512)
        self.fc4 = nn.Linear(512, input_dim)
        
    def encode(self, x):
        h1 = torch.relu(self.fc1(x))
        mu = self.fc2_mu(h1)
        logvar = self.fc2_logvar(h1)
        return mu, logvar

    def reparameterize(self, mu, logvar):
        std = torch.exp(0.5 * logvar)
        eps = torch.randn_like(std)
        return mu + eps * std

    def decode(self, z):
        h3 = torch.relu(self.fc3(z))
        return self.fc4(h3)

    def forward(self, x):
        mu, logvar = self.encode(x)
        z = self.reparameterize(mu, logvar)
        reconstructed = self.decode(z)
        return reconstructed, mu, logvar

    def loss_function(self, recon_x, x, mu, logvar):
        # Binary cross entropy loss for reconstruction
        BCE = nn.functional.mse_loss(recon_x, x, reduction='sum')
        
        # KL divergence loss
        KL = -0.5 * torch.sum(1 + logvar - mu.pow(2) - logvar.exp())
        
        # Total loss is reconstruction + KL
        return BCE + KL

El modelo optimiza simultáneamente la pérdida de clasificación y la pérdida del VAE, definiendo como función de pérdida la suma de ambos.

class FineTunedDistilBERTVAE(nn.Module):
    def __init__(self, model, vae_compressor, num_labels):
        super(FineTunedDistilBERTVAE, self).__init__()
        self.num_labels = num_labels
        self.distilbert = model.distilbert
        self.classifier = nn.Linear(768, num_labels)
        self.loss_fn = nn.CrossEntropyLoss()
        self.vae_compressor = vae_compressor

    def forward(self, inputs_embeds, labels=None, attention_mask=None):
        # Compresión y reconstrucción de embeddings
        reconstructed, mu, logvar = self.vae_compressor(inputs_embeds)
        
        # Paso por DistilBERT
        outputs = self.distilbert(inputs_embeds=reconstructed, attention_mask=attention_mask)
        cls_representation = outputs.last_hidden_state[:, 0, :]
        logits = self.classifier(cls_representation)

        # Cálculo de pérdidas
        loss = self.loss_fn(logits, labels) if labels is not None else None
        vae_loss = self.vae_compressor.loss_function(reconstructed, inputs_embeds, mu, logvar)
        total_loss = loss + vae_loss if loss is not None else vae_loss

        return {"loss": total_loss, "logits": logits}

Sin embargo, los resultados obtenidos fueron muy malos. Es posible que la combinación de ambos entrenamientos dificultara la optimización, ya que ambos objetivos (compresión y clasificación) competían por los recursos del modelo. También probamos a ajustar la función de pérdida mediante un parámetro beta, que ponderaba la pérdida de clasificación y del VAE, aunque de nuevo sin éxito.

total_loss = loss + beta * vae_loss
ENTRENAMIENTO SEPARADO

Finalmente decidimos separar los entrenamientos: primero entrenamos el VAE para realizar la compresión de los embeddings, y luego utilizamos los embeddings reconstruidos en el modelo de clasificación. El código completo está disponible en este enlace.

Este punto fue el más interesante, puesto que experimentamos con la arquitectura del VAE de diversas formas. Para medir su rendimiento (calidad de la reconstrucción) implementamos una función que devuelve el error MSE de reconstrucción sobre un subconjunto de datos, y de esta forma ir ajustando el VAE. Con ello finalmente implementamos los siguientes cambios que mejoraban el rendimiento, entre otros:

  • Nº y tamaño de capas: añadir una segunda capa y ajustar el tamaño por capa. La arquitectura definitiva fue (con dimensión de entrada 768): 2 capas de 640 y 512 neuronas cada una para codificación, y decodificación simétrica.
  • Función de pérdida: es mejor ponderar el componente KL al mínimo (beta = 0.001) para priorizar la reconstrucción.
  • Duración del entrenamiento: aumentar el nº de épocas hasta 30.
  • Función de activación: GELU (en vez de RELU).
  • Estabilidad del entrenamiento:
    • Acotar la magnitud del gradiente para evitar “explosiones del gradiente”.
    • Establecer una condición de parada en caso de empeoramiento (3 épocas de paciencia).
    • Acotar el rango del logaritmo de la varianza (logvar) al computar KL para evitar error.

Esto son sólo algunos de los detalles que tuvimos que considerar, y que darían para una entrada completa de blog 🙂

Con esta configuración, conseguimos un entrenamiento estable, cuya evolución podemos visualizar para las distintas dimensiones del espacio latente. El valor de la función de pérdida es muy alto, ya que utilizar la suma MSE, en vez de la media, mejoraba la estabilidad. Es probable que hubiera margen para mejorar el rendimiento aumentando el número de épocas, aunque con su consiguiente coste computacional.

A continuación, presentamos los resultados obtenidos en cuanto a la calidad de la reconstrucción. Como cabría esperar, a mayor dimensionalidad, menor pérdida de información.

Y, finalmente, podemos evaluar el rendimiento en la tarea de clasificación, comparando los resultados obtenidos con PCA frente a VAE. Previsiblemente, nuestro VAE proyecta de forma más eficiente la información, pudiendo alcanzar una reducción mayor de dimensionalidad con un mejor rendimiento.

5. CONCLUSIONES

A lo largo del trabajo, hemos explorado el espacio latente en los LLMs, desde el proceso
para obtener representaciones numéricas de información textual hasta la exploración y
transformaciones de dichos espacios.

Hemos intentado aplicar diversas estrategias para reducir la dimensionalidad de estas
representaciones textuales sin perder rendimiento. Probamos dos enfoques principales: el uso de PCA y VAEs, tanto integrados directamente en el modelo como aplicados de manera separada, obteniendo las siguientes conclusiones:

  • Modificar la arquitectura de una red neuronal sin perder rendimiento no es trivial, así como diseñar un VAE funcional: hay que tener en cuenta y elegir muchos detalles (tamaño y nº de capas, función de pérdida, etc.), y no está garantizado que funcione.
  • Para tareas específicas, es posible obtener buenos resultados tras reducir la representación a un espacio de menor dimensionalidad, lo que sugiere que se pueden desarrollar modelos de lenguaje mucho más pequeños y eficientes para aplicaciones concretas.
  • La reducción significativa de la dimensionalidad de las representaciones textuales es viable, siempre que no se altere la arquitectura original del modelo.

Realizado por Adrián Maldonado Robles, Paula Martín Fernández y Elena Moyano González

6. REFERENCIAS