微调一个小模型做多标签情感识别
摘要
把 Mistral Small 这样的生成式小模型,改造成能同时打多个情感标签的分类器。真正的工作量不在微调本身,而在 GoEmotions 那份严重倾斜的标注数据上。
最近读到 Towards Data Science 的一篇实操文 How to Fine-Tune an SLM for Emotion Recognition,作者把 Mistral Small 3.1-24B 微调成了一个情感识别模型,开源在 Hugging Face 上,叫 MistralSmall-3.1.GoEmotions,Apache 2.0 许可。这类「拿一个通用小模型,微调成专用分类器」的活儿,最容易让人以为重点在 LoRA 和那几个超参,但读完会发现,真正花力气的地方完全不在那里。
我把这篇英文文章读完了,想跟大家聊聊这个工作是怎么做出来的 - 情感识别和情感分析差在哪、为什么数据不平衡才是整件事的命门、怎么把一个生成式模型改造成多标签分类器,还有最后那个作者自己也承认的空白。期望对大家有所帮助。
情感识别不是情感分析
很多人会把这两件事当成一回事,其实差得挺远。
情感分析(sentiment analysis)通常是个二元或三元问题:正面、负面、中性。它回答的是「这段话的态度好不好」。而情感识别(emotion recognition)要细得多 - 它把笼统的「态度」拆成具体的情绪类别,而且一段话可以同时挂好几个标签。
作者用的是 GoEmotions 数据集:58000 条人工标注的 Reddit 评论,可用的有 54263 条,是个多标签任务 - 一条评论可以既是 annoyance 又是 disapproval。最终锁定 15 类目标情绪:
fear、sadness、disgust、disapproval、annoyance、anger、disappointment、optimism、amusement、surprise、admiration、excitement、confusion、joy、love
从建模角度,这个差别很关键:二元情感分析是单标签分类(single-label classification),而这里是 15 个标签各自独立的二分类(binary classification)问题叠在一起。这直接决定了后面损失函数(loss function)怎么设计、评估指标(evaluation metric)怎么算。
真正的难点是类别不平衡
GoEmotions 有个突出的问题,就是类别不平衡(class imbalance):neutral(中性)类别占了压倒性的多数。如果直接拿原始分布去训,模型会学到一个很省事的策略 - 大部分时候预测中性,少数情绪几乎不碰,照样能拿到不错的整体准确率,但对那些真正想识别的情绪毫无用处。
作者用了三种手段来校正这个分布:
flowchart LR
A[原始 GoEmotions<br/>neutral 压倒性多数] --> B[欠采样<br/>随机砍掉部分 neutral]
B --> C[ISMOTE 合成<br/>少数类各扩到 4000 条]
C --> D[Focal Loss + alpha 加权<br/>目标情绪 0.75 / 其余 0.25]
D --> E[相对均衡的训练集]
- 欠采样(undersampling):随机过滤掉一部分中性样本,先把头部压下来。
- 合成扩充:对少数类用 ISMOTE(Improved SMOTE,出自 2025 年 Nature 的一篇论文)生成合成样本,把每个少数类扩到 4000 条左右。
- 损失加权:用 focal loss,并给每个标签配不同的 alpha 权重 - 关注的目标情绪设 0.75,其余设 0.25,让模型在算梯度时更在意稀有但重要的类别。
数据按 60% 训练 / 20% 验证 / 20% 测试切分。
这一段才是整个项目最耗工的部分。微调脚本写一遍就能复用,但「怎么把一份倾斜的标注数据整理到模型学得动」是每个数据集都要重新面对的问题。如果你打算用自己的标签体系做类似的事,这三种手段的组合比那套 LoRA 超参更值得借鉴。
把生成模型改造成分类器
这是技术上最有意思的一处。Mistral Small 本来是个生成式的 instruct 模型 - 你给它 prompt,它一个 token 一个 token 往外输出。但情感识别不需要它「生成」,需要它对 15 个标签各给一个概率。
作者的做法是给骨干模型外面包一层,自定义了一个 MistralForMultiLabel 类,结构大致是:
class MistralForMultiLabel(nn.Module):
def __init__(self, backbone, hidden, num_labels=15):
super().__init__()
self.backbone = backbone # Mistral Small 主干
self.proj = nn.Linear(embed_dim, hidden) # 嵌入 -> 隐藏维度
self.dropout = nn.Dropout(0.1)
self.classifier = nn.Linear(hidden, num_labels) # 分类头
def forward(self, input_embeds, labels=None):
h = self.backbone(inputs_embeds=input_embeds)
h = self.dropout(self.proj(h))
logits = self.classifier(h) # [batch, 15]
loss = focal_loss(logits, labels) if labels is not None else None
return logits, loss
也就是说,主干负责把文本编码成表示,后面接一个投影层(projection layer)、一个 dropout、一个分类头(classification head),把语言模型的输出引到 15 维的 logits 上,每一维对应一个情绪的存在概率。损失用前面提到的 FocalLossWithAlpha,本质是带 per-label alpha 权重的 focal binary cross-entropy。
还有个工程细节:作者自定义了 MultiLabelTrainer,在存 checkpoint 时把 LoRA adapter 和投影层 / 分类头的权重分开保存。这是必要的 - LoRA 只改了主干里那几个矩阵,但分类头是全新加的参数,不在 LoRA 的覆盖范围内,如果只存 adapter,加载回来的模型就缺了分类头。
训练配置
微调本身走的是 LoRA + 4-bit 量化(quantization)的标准路线,框架用了 Unsloth(比直接上 Transformers 快),具体配置:
| 项 | 值 |
|---|---|
| 基座模型 | Mistral Small 3.1-24B-Instruct-2503 |
| 量化 | 4-bit(load_in_4bit),bfloat16 |
| LoRA rank / alpha | 16 / 32,dropout 0 |
| 目标模块 | q/k/v/o_proj、gate/up/down_proj |
| 有效 batch | 8 × 4 梯度累积 = 32 |
| 学习率 | 1e-4,cosine 衰减,5% warmup |
| 优化器 | 8-bit AdamW,weight decay 0.01 |
| 训练轮数 | 15 epochs |
| 评估指标 | Macro F1 |
| 硬件 | NVIDIA RTX 6000,192 GB 显存 |
| 训练时长 | 9 小时 30 分 |
这里有个小小的反讽:文章把这套方案归为「在适度硬件上跑」,但 192 GB 显存的 RTX 6000 并不是大多数人桌上那块卡。LoRA 确实把可训练参数压下来了,可基座是 24B,4-bit 装下来加上激活和优化器状态,门槛还是不低。如果你想复现,这块成本要先算清楚。
结果,以及一个要诚实交代的空白
测试集上的整体表现:
| 指标 | 值 |
|---|---|
| Macro F1 | 0.8215 |
| Micro F1 | 0.7527 |
| Macro Precision | 0.8295 |
| Macro Recall | 0.8184 |
分情绪看,差异不小:
| 情绪 | F1 |
|---|---|
| fear | 0.9390 |
| disgust | 0.8856 |
| sadness | 0.8713 |
| admiration | 0.6844 |
| annoyance | 0.6148 |
像 fear、disgust、sadness 这种情绪信号强、用词特征明显的类别,F1 能上 0.87;而 annoyance、admiration 这类边界模糊、容易和相邻情绪混淆的,就掉到 0.6 出头。大部分目标情绪的 F1 在 0.7 以上。
但这里有个必须点出来的空白:整篇没有任何 baseline 对比。没有跟更大的模型比,没有跟 zero-shot 比,也没有跟现成的情感分类模型比。所以 0.82 的 Macro F1 到底算好还是一般,其实缺一个参照系。对于一篇展示「怎么做」的文章,这不致命;但如果你要拿这套结果去说服别人「微调比直接调大模型更划算」,这个对比是绕不过去的。要补的话,最省事的就是先跑一遍 GPT 类模型的 zero-shot 在同一个测试集上的 Macro F1,立刻就有了基准线。
这套方法能迁移到哪
抛开情感识别这个具体任务,这份工作真正可复用的是一套「把通用小模型改造成多标签分类器」的模板:
- 数据严重倾斜时,欠采样 + 合成过采样(oversampling)+ focal loss 加权三种手段配合用,比单靠任何一种都稳;
- 不要让生成式模型去「生成」分类结果,直接在主干上接分类头,输出维度对齐你的标签数;
- LoRA + 新增分类头的组合,存 checkpoint 时记得把两部分权重分开存,否则加载回来会缺一块。
如果你手上有一份自己标注的多标签数据 - 工单分类、邮件归类、品牌舆情监测 - 把上面的 15 类情绪换成你的标签,这套流程基本可以照搬。
- 模型:MistralSmall-3.1.GoEmotions(Apache 2.0)
相关文章
2026年5月6日
GPT-5.5 Instant 发布解读:更准、更短、悄悄跨过的一条红线
OpenAI 把 ChatGPT 的默认模型从 GPT-5.3 Instant 升到了 GPT-5.5 Instant。发布页那篇博客只讲了三件事 - 幻觉减半、回答更短、记忆更聪明;但如果再翻一下官方的 System Card,会发现还有第四件 - 这是 OpenAI 第一次让默认 Instant 模型跨过 High capability 门槛。
2026年4月16日
Claude Opus 4.7 发布:编程、视觉、指令遵循的三重升级
Anthropic 今天发布 Opus 4.7。价格没变、上下文没变,但 SWE-bench Pro 涨了差不多 11 个百分点,第一次支持高分辨率看图,指令遵循也更严格。对开发者来说,这是一次值得立刻换的免费升级。
2026年3月26日
DeepSeek vs ChatGPT vs Claude:2026 年怎么选
三大 AI 模型各有什么强项?中文能力谁最好?写代码谁最强?这篇给你一个实用的选择框架。
最近一封 · Sample
【AI早读 0608】Agent 生态加速成熟,多智能体与平台战并进
“过去 24 小时 AI 圈关键词是 Agent:Towards Data Science 把 Python 多智能体教程推成中级实践;AI Engineer 频道两场分享指向 Agent 从原型走向规模化 - MCP 管道与 LLM 可观测性;OpenAI 据 FT 报道要把 ChatGPT 重构成集成 Codex 的“超级应用”,内部一句“Chat is dead”;Ramp 数据显示 DeepSeek 登顶增长最快的软件供应商,价格驱动的“Token 经济”成形;Notion 因 Anthropic Opus 4.7/4.8 抖动一度禁用全部 Anthropic 模型;The Algorithmic Bridge 深扒 Anthropic 如何用安全叙事影响特朗普政府的 AI 政策。”
—— william
来信
里面装的是
- 新文章 — 写完一篇就寄一封,不攒货
- 这周读到的、看到的、好用的工具
- 正在折腾的实验,附带翻车记录
约莫 1–2 周一封 · 随时退订
合作伙伴
CompeteMap — 英国及爱尔兰学生竞赛一站式搜索
数学、编程、科学、写作等各类竞赛信息汇总,支持按年龄和科目筛选,再也不错过报名截止日。