
Comment affiner un SLM pour la reconnaissance des émotions
Introduction
modèles (SLM) optimisés pour classification des sentiments déduire le sentiment comme une partition unique, capturant le ton émotionnel global du texte. Pour de nombreux cas d’utilisation, la classification positive-négative ne raconte pas toute l’histoire dont une entreprise a besoin. Les modèles de reconnaissance des émotions vont plus loin, décomposant les sentiments en classes d’émotions (« colère », « approbation », « déception »etc.) et attribuer des probabilités à un ensemble d’émotions dans le texte. Il est alors possible de modéliser le contenu émotionnel dans les ensembles de données qu’une entreprise reçoit (tickets clients, e-mails, discussions liées à la marque) et de réagir rapidement à l’évolution des conditions.
Pour l’un de nos projets récents, la modélisation des émotions dans les médias en ligne, nous avions besoin d’un modèle de reconnaissance des émotions avec des pondérations ouvertes et une licence flexible, maintenant des normes de transparence élevées et, bien sûr, bénéficiant des coûts inférieurs associés aux modèles ouverts. Nous préférons subjectivement les modèles européens, mais Hugging Face ne proposait pas de Alternative au Mistral avec une carte modèle développée. Une des raisons possibles est que l’ensemble d’entraînement le plus détaillé pour la reconnaissance des émotions, le système de 28 émotions. Base de données GoEmotionst, est fortement déséquilibré de classe. Affiner un SLM sur un ensemble de données de déséquilibre de classe élevée qui fonctionne correctement lors du test nécessite une concentration plus approfondie.
Nous avons traité le problème du déséquilibre des classes par une combinaison de trois techniques : (1) sous-échantillonnage la catégorie émotionnelle la plus représentée, (2) expansion synthétique le classes minoritaires avec ISMOTE 2025 de la nature algorithme, et (3) pondération le fonction de perte. Avec cette combinaison de techniques, MistralSmall-3.1.GoEmotionsmaintenant publié sur Hugging Face, déduit la plupart des émotions cibles pertinentes pour notre projet avec F1 > 0,7.
Cet article explique en détail comment affiner un SLM à poids ouvert. Nous comprendrons également :
- Comment prétraiter les données déséquilibrées de classe pour un réglage fin du LLM avec le ISMOTE 2025 algorithme.
- Comment décomposer les sentiments en catégories d’émotions en affinant un petit modèle linguistique pour la reconnaissance des émotions dans les données textuelles.
2. Données
Aller aux émotions est un ensemble de données annoté par l’homme de 58 000 commentaires Reddit extraits de subreddits en anglais et étiquetés avec 27 catégories d’émotions et un « neutre » étiquette. C’est un ensemble de données de classification multi-étiquettes dans lequel chaque commentaire peut être étiqueté avec plusieurs VRAI pour les émotions (par exemple, » Me frapper. Cela a juste ajouté une autre dynamique amusante même si je n’essayais pas réellement de la frapper. » est vrai pour « amusement »et « ennui »).
L’ensemble de données a été publié le Ensembles de données TensorFlow sous la licence Apache 2.0 et contient 54 263 textes étiquetés. Voici à quoi cela ressemble :

Après une vérification rapide, nous pouvons constater un déséquilibre important dans les données. neutre la catégorie prévaut :

3. Prétraitement des ensembles de formation
Notre objectif est de développer un classificateur permettant d’identifier 15 émotions dans des textes de langue générale. La formation sur des données déséquilibrées en matière de classe peut introduire des biais, car le modèle affiné a tendance à favoriser la classe majoritaire et à avoir de moins bons résultats avec les classes minoritaires. Le prétraitement est donc essentiel.
Nous avons utilisé une combinaison de méthodes pour ensemble de formation; les ensembles de validation et de test sont restés inchangés pour remédier au déséquilibre des classes et maximiser les performances sur les émotions cibles (peur, tristesse, dégoût, désapprobation, contrariété, colère, déception, optimisme, amusement, surprise, admiration, excitation, confusion, joie, amour) :
- Nous avons affiné les données en filtrant aléatoirement les « neutre » rangées.
- Nous avons généré des échantillons synthétiques pour les catégories émotionnelles les moins représentées en utilisant ISMOTE (Technique améliorée de suréchantillonnage des minorités synthétiques).
Le ISMOTE L’algorithme étend la technique SMOTE commune en (1) élargissant l’espace de génération d’échantillons et (2) améliorant la distribution d’échantillonnage. Les échantillons générés synthétiquement ont alors des distributions de données plus réalistes que celles produites par la méthode originale.

En réduisant la classe majoritaire et en élargissant synthétiquement les catégories minoritaires à 4 000 échantillons, nous avons construit un ensemble relativement équilibré pour un réglage fin. Le code du suréchantillonnage ISMOTE est ici.

4. Ajustement SLM
Parmi les modèles Mistral, nous avons choisi le Petit classe (Petit-3.1-24B-Instruct-2503), qui s’adapte à notre GPU et fournit les capacités multilingues dont nous avons besoin pour le classificateur. Le Insouciant framework rend les étapes de réglage plus simples et plus rapides qu’avec Transformateurs:
1. Chargement des données — chargement des ensembles de formation, de validation et de test prétraités. Nous utilisons une répartition 60:20:20.
2. Chargement le modèle de base— chargement du Small-3.1–24B-Instruct-2503 localement.
3. Appliquer LoRA —réduire les exigences matérielles.
4. Emballage multi-étiquettes avec fonction de perte focale — met à jour le formateur pour la classification multi-étiquettes. Ajoute également une perte focale pour pondérer la fonction de perte pour un ensemble sélectionné d’émotions, en donnant la priorité à leur performance.
5. Paramètres d’évaluation et arguments de formation— spécifier les métriques d’évaluation et les hyperparamètres pour la formation du modèle.
6. Modèle entraînement— formulation et lancement du formateur.
7. Évaluation — évaluer les meilleures performances du modèle sur l’ensemble de test.
4.1. Codage
Voici l’implémentation du code.
4.1.1. Chargement des données
# Loading augmented train, validation and test sets
BASE = r"augmented"
def load_split(path: str) -> Dataset:
with 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")
# Formulate embedding dimension
EMBED_DIM = len(train_dataset[0]["input_embeds"])
# Return Pytorch tensors
train_dataset.set_format("torch")
val_dataset.set_format("torch")
test_dataset.set_format("torch")
4.1.2. Chargement du modèle de base
# Load base model with 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. Appliquer LoRA
# Aply Low-rank adaptation (LoRA)
base_model = FastLanguageModel.get_peft_model(
base_model,
r=16,
lora_alpha=32,
lora_dropout=0,
bias="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. Emballage multi-étiquettes avec fonction de perte focale
# Focal loss weights for preffered labels
FOCAL_ALPHA_DEFAULT = 0.25
FOCAL_ALPHA_PREFERRED = 0.75
PREFERRED_LABELS = {
"fear", "sadness", "disgust", "disapproval", "annoyance",
"anger", "disappointment", "optimism", "amusement", "surprise",
"admiration", "excitement", "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
]
"Per-label weighted focal binary cross-entropy for multi-label problems"
class FocalLossWithAlpha(nn.Module):
def __init__(self, alpha: list[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, targets: torch.Tensor) -> torch.Tensor:
probs = torch.sigmoid(logits)
p_t = probs * targets + (1.0 - probs) * (1.0 - targets)
alpha_t = self.alpha * targets + (1.0 - self.alpha) * (1.0 - targets)
focal_w = alpha_t * (1.0 - p_t) ** self.gamma
bce = nn.functional.binary_cross_entropy_with_logits(
logits, targets, reduction="none"
)
return (focal_w * bce).mean()
# Multilabel classification wrapper with focal loss class weighting
class MistralForMultiLabel(nn.Module):
is_loaded_in_4bit = True
def __init__(self, backbone: nn.Module, num_labels: int,
hidden_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,
labels: torch.Tensor | None = None,
**kwargs,
):
B = input_embeds.size(0)
projected = self.projection(input_embeds).unsqueeze(1)
attn_mask = torch.ones(B, 1, device=input_embeds.device)
outputs = self.backbone.base_model.model.model(
inputs_embeds=projected,
attention_mask=attn_mask,
output_hidden_states=True,
)
pooled = outputs.hidden_states[-1][:, 0, :]
logits = self.classifier(self.dropout(pooled))
loss = self.focal_loss(logits, labels.float()) if labels is not None else None
return {"loss": loss, "logits": logits}
4.1.5. Métriques d’évaluation et arguments de formation
# Specifiy the evaluation function
def compute_metrics(eval_pred):
logits, labels = eval_pred
probs = torch.sigmoid(torch.tensor(logits)).numpy()
preds = (probs >= 0.5).astype(int)
labels = labels.astype(int)
from sklearn.metrics import accuracy_score
exact_accuracy = accuracy_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 = recall_score(labels, preds, average="macro", zero_division=0)
per_class_f1 = f1_score(labels, preds, average=None, zero_division=0)
per_class_recall = recall_score(labels, preds, average=None, zero_division=0)
per_class_precision = precision_score(labels, preds, average=None, zero_division=0)
per_class_accuracy = (preds == labels).mean(axis=0)
per_class_metrics = {}
for i, emotion in enumerate(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": exact_accuracy,
"macro_f1": macro_f1,
"micro_f1": micro_f1,
"macro_precision": macro_precision,
"macro_recall": macro_recall,
**per_class_metrics,
}
# Specify hyperparameters
training_args = TrainingArguments(
output_dir=OUTPUT_DIR, # where checkpoints and logs are written
eval_strategy="epoch", # run evaluation once per epoch
save_strategy="epoch", # save checkpoint once per epoch
per_device_train_batch_size=8, # samples per GPU per step
per_device_eval_batch_size=16, # larger batch is fine — no gradients
gradient_accumulation_steps=4, # effective batch = 8 × 4 = 32
num_train_epochs=15, # total passes over the training data
learning_rate=1e-4, # peak LR after warmup
bf16=True, # bfloat16 mixed precision
optim="adamw_8bit", # 8-bit AdamW
warmup_ratio=0.05, # first 5 % of steps ramp LR from 0 to peak
lr_scheduler_type="cosine", # cosine decay from peak LR to ~0
logging_steps=25, # print loss/LR to console every 25 steps
logging_first_step=True, # also log step 1 to catch early instability
load_best_model_at_end=True, # restore best checkpoint after training ends
metric_for_best_model="macro_f1", # criterion used to select the best checkpoint
greater_is_better=True, # higher macro_f1 is better in evaluation
gradient_checkpointing=False,
remove_unused_columns=False, # keep input_embeds column
save_total_limit=15, # keep all checkpoints on disk to load the best model
weight_decay=0.01, # L2 regularisation on all trainable parameters
)
4.1.6. Formation sur modèle
# Set-up the trainer for multilabel finetuning
class MultiLabelTrainer(Trainer):
def compute_loss(self, model, inputs, return_outputs=False, **kwargs):
labels = inputs.pop("labels")
outputs = model(**inputs, labels=labels)
loss = outputs["loss"]
return (loss, outputs) if return_outputs else loss
def _save_checkpoint(self, model, trial, metrics=None):
super()._save_checkpoint(model, trial)
ckpt_dir = self._get_output_dir(trial)
# Save head
torch.save({
"projection": model.projection.state_dict(),
"classifier": model.classifier.state_dict(),
}, os.path.join(ckpt_dir, "head_weights.pt"))
# Save LoRA adapter explicitly (bypasses bitsandbytes serialization issues)
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
# Restore 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"Head restored from: {best_ckpt}")
else:
print(f"WARNING: head_weights.pt not found in {best_ckpt}")
# Restore LoRA adapter
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, adapter_name="default")
print(f"LoRA restored from: {best_ckpt}")
else:
print(f"WARNING: lora_adapter/ not found in {best_ckpt}")
# Launch the trainer
trainer = MultiLabelTrainer(
model=model,
args=training_args,
train_dataset=train_dataset,
eval_dataset=val_dataset,
compute_metrics=compute_metrics,
)
# Launch training
trainer.train()
Le réglage fin pour 15 époques a pris 9 heures et 30 minutes sur une machine dotée d’un GPU NVIDIA RTX 6000 et de 192 Go de VRAM, le meilleur modèle étant chargé à la fin.
4.1.7. Évaluation du modèle
Montrons les performances sur l’ensemble de données de test. Les statistiques standard pour l’évaluation du modèle par classe sont F1, Précisionet Rappel. On constate des performances relativement bonnes sur les émotions cibles, avec les scores F1 supérieur à 0,7, pour la plupart des catégories. La pleine performance est sur le carte modèle.
| Émotion | Précision | Rappel | F1 | N |
| admiration | 0,7415 | 0,6354 | 0,6844 | 993 |
| amusement | 0,7810 | 0,7422 | 0,7611 | 543 |
| colère | 0,7423 | 0,7367 | 0,7395 | 395 |
| ennui | 0,7049 | 0,5452 | 0,6148 | 609 |
| confusion | 0,7576 | 0,8251 | 0,7899 | 303 |
| déception | 0,8487 | 0,8459 | 0,8473 | 305 |
| désapprobation | 0,7208 | 0,5841 | 0,6453 | 517 |
| dégoût | 0,8396 | 0,9368 | 0,8856 | 190 |
| excitation | 0,8240 | 0,9366 | 0,8767 | 205 |
| peur | 0,9112 | 0,9686 | 0,9390 | 159 |
| joie | 0,7577 | 0,8024 | 0,7794 | 339 |
| amour | 0,7424 | 0,7903 | 0,7656 | 496 |
| optimisme | 0,8145 | 0,7636 | 0,7882 | 368 |
| tristesse | 0,8534 | 0,8899 | 0,8713 | 327 |
| surprendre | 0,8456 | 0,8555 | 0,8505 | 256 |
| Précision macro | 0,8295 | |||
| Rappel de macro | 0,8184 | |||
| Micro F1 | 0,7527 | |||
| MacroF1 | 0,8215 |
5. Résumé
Résumons maintenant les points clés de l’article. Les exigences et le code complet se trouvent ici dépôt.
- Reconnaissance des émotions la modélisation étend l’analyse des sentiments en décomposant le score de sentiment global en ses composantes émotionnelles.
- MistralSmall-3.1.GoEmotions est allumé Visage câlin sous licence Apache 2.0. Le dépôt comprend également la ligne directrice d’inférence.
- Cas d’utilisation de déploiement sont veille de marque et socialeet catégorisation des e-mails.
Petr Korab est le fondateur de Text Mining Stories, une société de développement et de conseil basée à Prague. Apprenez-en davantage sur la PNL de pointe sur notre blog.
Déclaration de l’IA. Certaines parties du code ont été révisées par Sonnet 4.6 (Curseur). Aucun texte n’a été généré à l’aide de l’IA.
Remerciements. La Fondation Banque nationale de Slovaquie a soutenu ce développement. Je remercie Martin Feldkircher, Václav Jež et Michala Moravcová pour leurs commentaires et suggestions.
Références
[1] Ying Li, Yali Yang, Peihua Song, Lian Duan, Rui Ren. 2025. Un algorithme SMOTE amélioré pour une meilleure classification des données déséquilibrées en élargissant l’espace de génération d’échantillons. Rapports scientifiques, 15 (23521).
[2] Yinhan LiuJiatao GuNaman Goyal, Xian Li, Sergueï Edunov
Marjan Ghazvininejad, Mike Lewis, Luke Zettlemoyer. 2020. Pré-formation au débruitage multilingue pour la traduction automatique neuronale. Transactions de l’Association pour la linguistique computationnelle, 8, pp. 726-742.



