Закажи экспресс-аудит своего дела онлайн всего за 199 ₽
и получи рекомендации по улучшению - Жми сюда !

Как оптимизировать SLM для распознавания эмоций

Учебное пособие по Python для тонкой настройки модели Mistral Small 3.1 на несбалансированном обучающем наборе данных для классификации 15 эмоций в общении в социальных сетях.

Делиться

b0fe01f57d31d5df6ab3d5723cbb426d
Изображение создано программой GPT 5.3

Введение

Современные модели обработки малых языков (SLM), доработанные для классификации настроений, определяют настроение как единый показатель, отражающий общий эмоциональный тон текста. Во многих случаях классификация на положительные и отрицательные эмоции не дает компании полной картины. Модели распознавания эмоций идут дальше, разлагая настроение на классы эмоций («гнев», «одобрение», «разочарование» и т. д.) и присваивая вероятности набору эмоций в тексте. Затем становится возможным моделировать эмоциональное содержание в наборах данных, получаемых компанией (заявки клиентов, электронные письма, обсуждения, связанные с брендом), и быстро реагировать на меняющиеся условия.

Для одного из наших недавних проектов, посвященного моделированию эмоций в онлайн-медиа, нам потребовалась модель распознавания эмоций с открытыми весами и гибкой лицензией, обеспечивающая высокие стандарты прозрачности и, конечно же, более низкие затраты, связанные с открытыми моделями. Субъективно мы предпочитаем европейские модели, но Hugging Face не предложил альтернативу Mistral с разработанной картой модели. Одна из возможных причин заключается в том, что наиболее подробный обучающий набор данных для распознавания эмоций, набор данных GoEmotions, содержащий 28 эмоций, сильно несбалансирован по классам. Тонкая настройка модели распознавания эмоций на наборе данных с высоким уровнем несбалансированности классов, которая показывает неплохие результаты на тесте, требует более глубокого подхода.

Мы решили проблему дисбаланса классов, используя комбинацию трех методов: (1) уменьшение выборки наиболее представленной эмоциональной категории, (2) синтетическое расширение миноритарных классов с помощью алгоритма ISMOTE из журнала Nature 2025 и (3) взвешивание функции потерь. Благодаря этой комбинации методов, MistralSmall-3.1.GoEmotions, теперь доступный на Hugging Face, определяет большинство целевых эмоций, имеющих отношение к нашему проекту, с показателем F1 > 0,7.

В этой статье подробно объясняется, как выполнить тонкую настройку SLM-системы с открытым весом. Мы также разберемся:

  • Как выполнить предварительную обработку данных с несбалансированным классом для тонкой настройки LLM с помощью алгоритма ISMOTE 2025.
  • Как разложить эмоциональный тон на категории путем тонкой настройки небольшой языковой модели для распознавания эмоций в текстовых данных.

2. Данные

GoEmotions — это набор данных, аннотированный людьми, содержащий 58 000 комментариев Reddit из англоязычных сабреддитов, помеченных 27 категориями эмоций и меткой «нейтральный». Это многоклассовый набор данных для классификации, в котором каждый комментарий может быть помечен несколькими значениями TRUE для эмоций (например, «Ударить меня. Это добавило еще одну забавную динамику, хотя я на самом деле не пытался ее ударить» — это True для «веселья» и «раздражения»).

Набор данных был опубликован на TensorFlow Datasets под лицензией Apache 2.0 и содержит 54 263 размеченных текста. Вот как он выглядит:

a1032a9dfb73918c63d2faab45dad271
Изображение 1. Набор данных GoEmotions. Изображение предоставлено автором.

После беглого анализа мы видим дисбаланс в данных, характеризующийся преобладанием нейтральной категории:

b3c2970c42b4cda148c6e1c3222e8d56
Изображение 2. Дисбаланс классов в наборе данных GoEmotions. Изображение предоставлено автором.

3. Предварительная обработка обучающего набора данных

Наша цель — разработать классификатор для распознавания 15 эмоций в текстах на общеупотребительном языке. Обучение на данных с несбалансированным распределением классов может привести к смещению результатов, поскольку дообученная модель, как правило, отдает предпочтение мажоритарному классу и хуже работает с миноритарными, поэтому предварительная обработка данных крайне важна.

Для обучающей выборки мы использовали комбинацию методов; валидационная и тестовая выборки остались неизменными, чтобы устранить дисбаланс классов и максимизировать производительность по целевым эмоциям (страх, печаль, отвращение, неодобрение, раздражение, гнев, разочарование, оптимизм, веселье, удивление, восхищение, возбуждение, замешательство, радость, любовь):

  • Мы уменьшили объем данных, случайным образом отфильтровав «нейтральные» строки.
  • Мы сгенерировали синтетические выборки для наименее представленных эмоциональных категорий, используя метод ISMOTE (Improved Synthetic Minority Over-sampling Technique).

Алгоритм ISMOTE расширяет распространенный метод SMOTE за счет (1) расширения пространства генерации выборок и (2) улучшения распределения выборок. Синтетически сгенерированные выборки имеют более реалистичное распределение данных, чем те, которые получены с помощью исходного метода.

fd7c3a99eacf4bfe4b6820dc9fc42bb2
Изображение 3. Блок-схема алгоритма ISMOTE. Источник: Scientific Reports.

Сократив класс большинства и синтетически расширив категории меньшинства до 4000 выборок, мы создали относительно сбалансированный набор для тонкой настройки. Код для передискретизации ISMOTE находится здесь.

3036a3f7c3820d7d79386778d8e7426c
Изображение 4. Метки: относительная частота, обучающий (дополненный), проверочный и тестовый наборы данных. Изображение предоставлено автором.

4. Тонкая настройка SLM

Среди моделей Mistral мы выбрали класс Small (Small-3.1-24B-Instruct-2503), который подходит для нашего графического процессора и обеспечивает необходимые нам многоязычные возможности для классификатора. Фреймворк Unsloth упрощает этапы тонкой настройки и ускоряет их по сравнению с Transformers:

1. Загрузка данныхЗагрузка предварительно обработанных обучающего, валидационного и тестового наборов данных. Мы используем разделение 60:20:20.

2. Загрузка базовой модели — локальная загрузка инструкции Small-3.1–24B-Instruct-2503.

3. Применение LoRA — снижение требований к оборудованию.

4. Многоклассовая обертка с функцией потерь Focal Loss — обновляет обучающий модуль для многоклассовой классификации. Также добавляет функцию потерь Focal Loss для взвешивания функции потерь для выбранного набора эмоций, отдавая приоритет их эффективности.

5. Метрики оценки и аргументы обучения — указание метрик оценки и гиперпараметров для обучения модели.

6. Модель обучения — разработка методики обучения и запуск программы.

7. Оценка — оценка наилучшей производительности модели на тестовом наборе данных.

4.1. Кодирование

Вот реализация кода.

4.1.1. Загрузка данных

 # 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. Загрузка базовой модели

 # 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. Применение 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. Многометочная оболочка с функцией потери фокуса

 # 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. Метрики оценки и аргументы обучения

 # 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. Обучение модели

 # 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()

Тонкая настройка в течение 15 эпох заняла 9 часов 30 минут на компьютере с графическим процессором NVIDIA RTX 6000 и 192 ГБ видеопамяти, при этом в конце была загружена лучшая модель.

4.1.7. Оценка модели

Давайте покажем результаты на тестовом наборе данных. Стандартные статистические показатели для оценки модели по классам — это F1 , точность и полнота . Мы видим относительно хорошие результаты на целевом наборе данных, где F1-мера превышают 0,7 для большинства категорий. Полные результаты представлены на карточке модели.

Эмоции Точность Отзывать Ф1 Н
восхищение 0.7415 0,6354 0.6844 993
развлечение 0.7810 0.7422 0.7611 543
злость 0.7423 0.7367 0.7395 395
раздражение 0.7049 0,5452 0.6148 609
путаница 0.7576 0.8251 0.7899 303
разочарование 0.8487 0.8459 0.8473 305
неодобрение 0.7208 0.5841 0.6453 517
отвращение 0.8396 0.9368 0.8856 190
возбуждение 0.8240 0.9366 0.8767 205
страх 0.9112 0.9686 0.9390 159
радость 0.7577 0.8024 0.7794 339
любовь 0.7424 0.7903 0.7656 496
оптимизм 0.8145 0.7636 0.7882 368
грусть 0,8534 0.8899 0.8713 327
сюрприз 0.8456 0,8555 0.8505 256
Макроточность 0.8295
Макро-воспоминание 0.8184
Микро Ф1 0.7527
Макро F1 0.8215
Таблица 1: Результаты работы Mistral Small 3.1-GoEmotions на тестовом наборе данных.

5. Резюме

Теперь давайте подведем итоги основных моментов статьи. Требования и полный код находятся в этом репозитории.

  • Моделирование распознавания эмоций расширяет возможности анализа настроений, разлагая общую оценку настроения на ее эмоциональные компоненты.
  • MistralSmall-3.1.GoEmotions находится на платформе Hugging Face под лицензией Apache 2.0. В репозитории также содержится руководство по выводу данных.
  • Примеры использования системы включают мониторинг бренда и социальных сетей, а также категоризацию электронных писем.

Петр Кораб — основатель компании Text Mining Stories, занимающейся разработкой и консалтингом в Праге. Узнайте больше о передовых технологиях обработки естественного языка в нашем блоге.

Заявление об использовании ИИ . Некоторые части кода были проверены с помощью Sonnet 4.6 (Cursor). Текст не был сгенерирован с использованием ИИ.

Благодарности . Фонд Национального банка Словакии оказал поддержку этому проекту. Я благодарю Мартина Фельдкирхера, Вацлава Ежа и Михалу Моравцову за комментарии и предложения.

Ссылки

[1] Ин Ли, Яли Ян, Пейхуа Сонг, Лянь Дуань, Руи Рен. 2025. Усовершенствованный алгоритм SMOTE для улучшения классификации несбалансированных данных путем расширения пространства генерации образцов. Научные отчеты, 15 (23521).

[2] Лю Иньхан, Гу Цзятао, Наман Гоял, Сянь Ли, Сергей Эдунов
Марьян Газвининеджад, Майк Льюис, Люк Зеттлемойер. 2020. Многоязычное предварительное обучение шумоподавлению для нейронного машинного перевода. Труды Ассоциации вычислительной лингвистики, 8, стр. 726–742.

Петр Кораб Посмотреть все от Петра Кораба

Источник: towardsdatascience.com

✅ Найденные теги: Slm, Как, новости, Оптимизировать, Распознавания, Эмоций

Добавить комментарий