Introducción
Los modelos (SLM) ajustados para la clasificación de sentimientos infieren el sentimiento como una puntuación única, capturando el tono emocional general del texto. Para muchos casos de uso, la clasificación positivo-negativo no cuenta la historia completa que una empresa necesita. Los modelos de reconocimiento de emociones van más allá, descomponiendo los sentimientos en clases de emociones (“ira”, “aprobación”, “decepción”, etc.) y asignando probabilidades a un conjunto de emociones en el texto. Entonces es posible modelar contenido emocional en conjuntos de datos que recibe una empresa (tickets de clientes, correos electrónicos, debates relacionados con la marca) y reaccionar rápidamente a las condiciones cambiantes.
Para uno de nuestros proyectos recientes, modelar emociones en medios en línea, necesitábamos un modelo de reconocimiento de emociones con pesos abiertos y una licencia flexible, manteniendo altos estándares de transparencia y, por supuesto, beneficiándonos de los costos más bajos asociados con los modelos abiertos. Preferimos subjetivamente los modelos europeos, pero Hugging Face no ofrecía una alternativa al Mistral con una tarjeta modelo desarrollada. Una posible razón es que el conjunto de entrenamiento más detallado para el reconocimiento de emociones, el conjunto de datos GoEmotions de 28 emociones, tiene un alto desequilibrio de clases. Ajustar un SLM en un conjunto de datos de alto desequilibrio de clases que tenga un rendimiento decente en la prueba requiere un enfoque más profundo.
Tratamos el problema del desequilibrio de clases mediante una combinación de tres técnicas: (1) submuestrear la categoría emocional más representada, (2) expandir sintéticamente las clases minoritarias con el algoritmo ISMOTE 2025 de Nature y (3) ponderar la función de pérdida. Con esta combinación de técnicas, MistralSmall-3.1.GoEmotions, ahora lanzado en Hugging Face, infiere la mayoría de las emociones objetivo relevantes para nuestro proyecto con F1 > 0.7.
Este artículo explica en detalle cómo ajustar un SLM de peso abierto. También descubriremos:
Cómo preprocesar datos con desequilibrio de clases para el ajuste fino de LLM con el algoritmo ISMOTE 2025. Cómo descomponer los sentimientos en categorías de emociones ajustando un modelo de lenguaje pequeño para el reconocimiento de emociones en datos de texto.
2. Datos
GoEmotions es un conjunto de datos anotado por humanos de 58.000 comentarios de Reddit extraídos de subreddits en inglés y etiquetados con 27 categorías de emociones y una etiqueta "neutral". Es un conjunto de datos de clasificación de etiquetas múltiples en el que cada comentario puede etiquetarse con múltiples VERDADEROS para emociones (por ejemplo, "Pegarme. Eso le agregó otra dinámica divertida a pesar de que en realidad no estaba tratando de golpearla" es Verdadero para "diversión" y "molestia").
El conjunto de datos se publicó en TensorFlow Datasets bajo la licencia Apache 2.0 y contiene 54,263 textos etiquetados. Así es como se ve:
Después de una revisión rápida, podemos ver un desequilibrio de clase alta en los datos donde prevalece la categoría neutral:
3. Preprocesamiento del conjunto de entrenamiento
Nuestro objetivo es desarrollar un clasificador para identificar 15 emociones en textos de lenguaje general. El entrenamiento con datos desequilibrados de clases puede introducir sesgos, ya que el modelo ajustado tiende a favorecer a la clase mayoritaria y a funcionar peor en las minoritarias, por lo que el preprocesamiento es esencial.
Usamos una combinación de métodos para el conjunto de entrenamiento; los conjuntos de validación y prueba se mantuvieron sin cambios para abordar el desequilibrio de clases y maximizar el desempeño en las emociones objetivo (miedo, tristeza, disgusto, desaprobación, molestia, ira, decepción, optimismo, diversión, sorpresa, admiración, entusiasmo, confusión, alegría, amor):
Redujimos los datos filtrando aleatoriamente las filas "neutrales". Generamos muestras sintéticas para las categorías emocionales menos representadas utilizando ISMOTE (Técnica mejorada de sobremuestreo de minorías sintéticas).
El algoritmo ISMOTE amplía la técnica SMOTE común al (1) expandir el espacio de generación de muestras y (2) mejorar la distribución de muestras. Las muestras generadas sintéticamente tienen distribuciones de datos más realistas que las producidas por el método original.
Al reducir la clase mayoritaria y expandir sintéticamente las categorías minoritarias a 4000 muestras, construimos un conjunto relativamente equilibrado para realizar ajustes. El código para el sobremuestreo de ISMOTE está aquí.
4. Ajuste fino de SLM
Entre los modelos de Mistral, elegimos la clase Small (Small-3.1-24B-Instruct-2503), que se adapta a nuestra GPU y proporciona las capacidades multilingües que necesitamos para el clasificador. El marco Unsloth hace que los pasos de ajuste sean sencillos y más rápidos que con Transformers:
1. Carga de datos: carga de conjuntos de entrenamiento, validación y pruebas preprocesados. Usamos una división 60:20:20.
2. Cargando el modelo base: cargando el Small-3.1–24B-Instruct-2503 localmente.
3. Aplicar LoRA: reducir los requisitos de hardware.
4. Envoltorio de etiquetas múltiples con función de pérdida focal: actualiza el entrenador para la clasificación de etiquetas múltiples. También agrega pérdida focal a la función de pérdida de peso para un conjunto seleccionado de emociones, priorizando su desempeño.
5. Métricas de evaluación y argumentos de entrenamiento: especificar las métricas de evaluación y los hiperparámetros para el entrenamiento del modelo.
6. Capacitación modelo: formulación y lanzamiento del capacitador.
7. Evaluación: evaluar el mejor rendimiento del modelo en el conjunto de prueba.
4.1. Codificación
Aquí está la implementación del código.
4.1.1. Carga de datos
# Cargando conjuntos de tren, validación y prueba aumentados BASE = r"augmented" def load_split(path: str) -> Conjunto de datos: con open(path, encoding="utf-8") as f: d = json.load(f) return Dataset.from_dict({"input_embeds": d["X"], "labels": d["y"]}) train_dataset = load_split(f"{BASE}/train.json") val_dataset = load_split(f"{BASE}/val.json") test_dataset = load_split(f"{BASE}/test.json") # Formular dimensión de incrustación EMBED_DIM = len(train_dataset[0]["input_embeds"]) # Devuelve tensores de Pytorch train_dataset.set_format("torch") val_dataset.set_format("torch") test_dataset.set_format("torch")
4.1.2. Cargando el modelo base
# Cargar el modelo base con Unsloth FastLanguageModel MODEL_NAME = "unsloth/Mistral-Small-3.1-24B-Instruct-2503" base_model, _ = FastLanguageModel.from_pretrained( model_name=MODEL_NAME, max_seq_length=2, load_in_4bit=True, dtype=torch.bfloat16,)
4.1.3. Aplicar LoRA
# Aplicar adaptación de rango bajo (LoRA) base_model = FastLanguageModel.get_peft_model( base_model, r=16, lora_alpha=32, lora_dropout=0, sesgo="none", target_modules=[ "q_proj", "k_proj", "v_proj", "o_proj", "gate_proj", "up_proj", "down_proj", ], use_gradient_checkpointing="unsloth", random_state = 3407, use_rslora = False,)
4.1.4. Envoltorio multietiqueta con función de pérdida focal
# Pesos de pérdida focal para etiquetas preferidas FOCAL_ALPHA_DEFAULT = 0.25 FOCAL_ALPHA_PREFERRED = 0.75 PREFERRED_LABELS = { "miedo", "tristeza", "disgusto", "desaprobación", "molestia", "ira", "decepción", "optimismo", "diversión", "sorpresa", "admiración", "emoción", "confusion","joy","love" } FOCAL_ALPHA_PER_LABEL: list[float] = [ FOCAL_ALPHA_PREFERRED if lbl in PREFERRED_LABELS else FOCAL_ALPHA_DEFAULT for lbl in EMOTION_LABELS ] "Entropía cruzada binaria focal ponderada por etiqueta para problemas de múltiples etiquetas" clase FocalLossWithAlpha(nn.Module): def __init__(self, alfa: lista[float], gamma: float = 2.0): super().__init__() self.register_buffer("alpha", torch.tensor(alpha, dtype=torch.float32)) self.gamma = gamma def forward(self, logits: torch.Tensor, objetivos: torch.Tensor) -> torch.Tensor: probs = torch.sigmoid(logits) p_t = problemas * objetivos + (1.0 – problemas) * (1.0 – objetivos) alpha_t = self.alpha * objetivos + (1.0 – self.alpha) * (1.0 – objetivos) focal_w = alpha_t * (1.0 – p_t) ** self.gamma bce = nn.functional.binary_cross_entropy_with_logits( logits, objetivos, reducción="none" ) return (focal_w * bce).mean() # Envoltorio de clasificación multietiqueta con clase de ponderación de clase de pérdida focal MistralForMultiLabel(nn.Module): is_loaded_in_4bit = True def __init__(self, backbone: nn.Module, num_labels: int, hide_size: int, embed_dim: int): super().__init__() self.backbone = backbone _device = torch.device("cuda" if torch.cuda.is_available() else "cpu") self.projection = nn.Sequential( nn.Linear(embed_dim, Hidden_size // 2), nn.GELU(), nn.Linear(hidden_size // 2, Hidden_size), ).to(_device) self.dropout = nn.Dropout(0.1).to(_device) self.classifier = nn.Linear(hidden_size, num_labels).to(_device) self.focal_loss = FocalLossWithAlpha(FOCAL_ALPHA_PER_LABEL).to(_device) def gradient_checkpointing_enable(self, gradient_checkpointing_kwargs=None): self.backbone.gradient_checkpointing_enable(gradient_checkpointing_kwargs) def gradient_checkpointing_disable(self): self.backbone.gradient_checkpointing_disable() def forward( self, input_embeds: torch.Tensor, etiquetas: torch.Tensor | Ninguno = Ninguno, **kwargs,): B = input_embeds.size(0) proyectado = self.projection(input_embeds).unsqueeze(1) attn_mask = torch.ones(B, 1, dispositivo=input_embeds.device) salidas = self.backbone.base_model.model.model( inputs_embeds=proyectado, atención_mask=attn_mask, salida_hidden_states=True,) agrupado = salidas.hidden_states[-1][:, 0, :] logits = self.classifier(self.dropout(pooled)) loss = self.focal_loss(logits, etiquetas.float()) si las etiquetas no son Ninguno más Ninguno return {"loss": loss, "logits": logits}
4.1.5. Métricas de evaluación y argumentos de entrenamiento
# Especifique la función de evaluación def Compute_metrics(eval_pred): logits, etiquetas = eval_pred probs = torch.sigmoid(torch.tensor(logits)).numpy() preds = (probs >= 0.5).astype(int) etiquetas = etiquetas.astype(int) de sklearn.metrics import precision_score exactitud_accuracy = precision_score(labels, preds) macro_f1 = f1_score(labels, preds, Average="macro", zero_division=0) micro_f1 = f1_score(labels, preds, Average="micro", zero_division=0) macro_precision = precision_score(labels, preds, Average="macro", zero_division=0) macro_recall = recordar_score(labels, preds, Average="macro", zero_division=0) per_class_f1 = f1_score(etiquetas, preds, promedio=Ninguno, zero_division=0) per_class_recall = recordar_score(labels, preds, promedio=Ninguno, zero_division=0) per_class_precision = precision_score(labels, preds, promedio=Ninguno, zero_division=0) per_class_accuracy = (preds == etiquetas).mean(axis=0) per_class_metrics = {} para i, emoción en enumerar(EMOTION_LABELS): per_class_metrics[f"f1_{emotion}"] = float(per_class_f1[i]) per_class_metrics[f"recall_{emotion}"] = float(per_class_recall[i]) per_class_metrics[f"precision_{emotion}"] = float(per_class_precision[i]) per_class_metrics[f"accuracy_{emotion}"] = float(per_class_accuracy[i]) return { "exact_accuracy": exactitud_exacta, "macro_f1": macro_f1, "micro_f1": micro_f1, "macro_precision": macro_precision, "macro_recall": macro_recall, **per_class_metrics, } # Especifique hiperparámetrostraining_args = TrainingArguments(output_dir=OUTPUT_DIR, # donde se escriben los puntos de control y los registros eval_strategy="epoch", # ejecute la evaluación una vez por época save_strategy="epoch", # guarde el punto de control una vez por época per_device_train_batch_size=8, # muestras por GPU por paso per_device_eval_batch_size=16, # un lote más grande está bien, no gradientes gradient_accumulation_steps=4, # lote efectivo = 8 × 4 = 32 num_train_epochs=15, # pases totales sobre los datos de entrenamiento learning_rate=1e-4, # LR máximo después del calentamiento bf16=True, # bfloat16 precisión mixta optim="adamw_8bit", # AdamW Warmup_ratio=0.05 de 8 bits, # primer 5 % de pasos rampa LR desde 0 al pico lr_scheduler_type="coseno", # caída del coseno desde el LR máximo a ~0 logging_steps=25, # pérdida de impresión/LR a la consola cada 25 pasos logging_first_step=True, # también registra el paso 1 para detectar la inestabilidad temprana load_best_model_at_end=True, # restaura el mejor punto de control después de que finaliza el entrenamiento metric_for_best_model="macro_f1", # criterio utilizado para seleccionar el mejor punto de control mayor_is_better=True, # un macro_f1 más alto es mejor en la evaluación gradient_checkpointing=False, remove_unused_columns=False, # mantiene la columna input_embeds save_total_limit=15, # mantiene todos los puntos de control en el disco para cargar el mejor modeloweight_decay=0.01, # regularización L2 en todos los parámetros entrenables)
4.1.6. Entrenamiento modelo
# Configurar el entrenador para la clase de ajuste fino de múltiples etiquetas MultiLabelTrainer(Trainer): def compute_loss(self, model, inputs, return_outputs=False, **kwargs): etiquetas = inputs.pop("labels") salidas = model(**inputs, etiquetas=labels) loss = salidas["loss"] return (loss, outputs) if return_outputs else loss def _save_checkpoint(self, modelo, prueba, métricas=Ninguno): super()._save_checkpoint(modelo, prueba) ckpt_dir = self._get_output_dir(prueba) # Guardar head torch.save({ "projection": model.projection.state_dict(), "classifier": model.classifier.state_dict(), }, os.path.join(ckpt_dir, "head_weights.pt")) # Guarde el adaptador LoRA explícitamente (evite los problemas de serialización de bitsandbytes) model.backbone.save_pretrained(os.path.join(ckpt_dir, "lora_adapter")) def _load_best_model(self): best_ckpt = self.state.best_model_checkpoint if not best_ckpt: return # Restaurar head head_path = os.path.join(best_ckpt, "head_weights.pt") if os.path.exists(head_path): head = torch.load(head_path, map_location="cpu") self.model.projection.load_state_dict(head["projection"]) self.model.classifier.load_state_dict(head["classifier"]) print(f"Cabezal restaurado desde: {best_ckpt}") else: print(f"ADVERTENCIA: head_weights.pt no encontrado en {best_ckpt}") # Restaurar Adaptador LoRA lora_path = os.path.join(best_ckpt, "lora_adapter") if os.path.exists(lora_path): from peft import PeftModel self.model.backbone.load_adapter(lora_path, adaptor_name="default") print(f"LoRA restaurado desde: {best_ckpt}") else: print(f"ADVERTENCIA: lora_adapter/ no encontrado en {best_ckpt}") # Inicie el entrenador trainer = MultiLabelTrainer( model=model, args=training_args, train_dataset=train_dataset, eval_dataset=val_dataset, Compute_metrics=compute_metrics, ) # Inicie el entrenamiento trainer.train()
El ajuste fino durante 15 épocas tomó 9 horas y 30 minutos en una máquina con una GPU NVIDIA RTX 6000 y 192 GB de VRAM, con el mejor modelo cargado al final.
4.1.7. Evaluación del modelo
Mostremos el rendimiento en el conjunto de datos de prueba. Las estadísticas estándar para la evaluación de modelos por clase son F1, Precisión y Recuperación. Podemos ver un rendimiento relativamente bueno en las emociones objetivo, con puntuaciones de F1 superiores a 0,7, para la mayoría de las categorías. El rendimiento completo está en la tarjeta del modelo.
5. Resumen
Resumamos ahora los puntos clave del artículo. Los requisitos y el código completo se encuentran en este repositorio.
El modelado de reconocimiento de emociones amplía el análisis de sentimientos al descomponer la puntuación general del sentimiento en sus componentes emocionales. MistralSmall-3.1.GoEmotions está en Hugging Face bajo la licencia Apache 2.0. El repositorio también incluye la pauta de inferencia. Los casos de uso de implementación son el monitoreo social y de marca, y la categorización de correo electrónico.
Petr Koráb es el fundador de Text Mining Stories, una empresa de consultoría y desarrollo con sede en Praga. Obtenga más información sobre la PNL de vanguardia en nuestro blog.
Declaración de IA. Algunas partes del código fueron revisadas por Sonnet 4.6 (Cursor). No se generó ningún texto utilizando IA.
Expresiones de gratitud. La Fundación del Banco Nacional de Eslovaquia apoyó este desarrollo. Agradezco a Martin Feldkircher, Václav Jež y Michala Moravcová por sus comentarios y sugerencias.
Referencias
[1]Ying Li, Yali Yang, Peihua Song, Lian Duan, Rui Ren. 2025. Un algoritmo SMOTE mejorado para una clasificación mejorada de datos desequilibrados mediante la ampliación del espacio de generación de muestras. Informes científicos, 15 (23521).
[2]Yinhan Liu, Jiatao Gu, Naman Goyal, Xian Li, Sergey Edunov
Marjan Ghazvininejad, Mike Lewis, Luke Zettlemoyer. 2020. Capacitación previa en eliminación de ruido multilingüe para traducción automática neuronal. Transacciones de la Asociación de Lingüística Computacional, 8, págs. 726–742.