如何训练问题分类 Encoder-only 模型
基于糖豆TIM 社区云问题分类实战经验撰写 版本:v1.0(2026-10-14)
一、前言
1.1 适用读者
- NLP 工程师 / 算法工程师
- 后端 / 全栈想自己跑通分类模型的同学
- 数据 / 标注团队,理解标注数据怎么变成模型
读完本文你能:独立从 0 训练一个短文本分类 BERT,并在 CPU 上 30 分钟内跑完全流程。
1.2 与 Decoder-only / Encoder-Decoder 选型对比
Transformer 按注意力方向分三大类:
| 架构 | 注意力方向 | 训练方式 | 擅长 | 代表 |
|---|---|---|---|---|
| Encoder-only | 双向 | 判别式 | 理解 / 分类 / NER / 检索 | BERT、RoBERTa |
| Decoder-only | 单向(因果) | 自回归 | 生成 | GPT、LLaMA、Qwen |
| Encoder-Decoder | 双向 + 因果 | Seq2Seq | 翻译 / 摘要 | T5、BART |
问题分类是判别式任务:给一段输入,输出一个离散标签。Encoder 双向注意力最擅长抽取语义特征。
1.3 本文产出物清单
- 完整训练脚本(
src/prepare_data.py+src/train.py) - 评估脚本(
src/evaluate.py) - 推理脚本(
src/infer.py,单条 / 批量 / 交互三种) - 离线模型(
models/chinese-roberta-wwm-ext/) - 标注规范(
docs/labeling_guide.md) - 训练日志与混淆矩阵(
output/)
二、任务定义与选型
2.1 问题分类的任务本质
把用户输入的短文本(一般 5–80 字)映射到一个预设标签集合。
特点:
- 判别式:输出离散标签
- 单标签:每条只对应一个类(vs 多标签需要多输出头)
- 短文本:几乎不需要 sliding window
- 类别少:通常 5–20 类,超过 50 类建议分层
2.2 为什么选 Encoder-only
- 双向注意力:同时看到上下文,对理解类任务最友好
- 单次前向 + [CLS] 分类:不需要自回归,推理快
- 模型小:BERT-base 110M、RoBERTa-base 125M,CPU 可跑
- 微调简单:在 [CLS] 上接
nn.Linear(hidden, num_labels)即可 - 中文友好:chinese-roberta-wwm-ext 在中文 IM 任务上表现稳定
2.3 备选模型对比
| 模型 | 层数 | 参数量 | 中文优化 | 适用场景 |
|---|---|---|---|---|
| chinese-roberta-wwm-ext | 12 | 102M | 哈工大 WWM 预训练 | 首选通用 |
| ernie-3.0-base-zh | 12 | 118M | 百度知识增强 | 知识型任务 |
| chinese-macbert-base | 12 | 102M | MLM 纠错 | 错别字多场景 |
| electra-180g-small | 12 | 12M | 判别式预训练 | 极致小模型 |
| xlm-roberta-base | 12 | 270M | 多语种 | 多语种场景 |
本项目选 chinese-roberta-wwm-ext:哈工大官方出品,社区使用广泛,IM 短文本表现稳定。
三、数据准备
3.1 业务标签体系设计原则
三条铁律:
- 互斥:每条样本只能归到一个类(多标签需另设)
- 完备:所有可能输入都能归到某类(不确定就归"其他")
- 业务粒度:标签对应"业务处理方",不是"语义相似度"
本项目 11 类(社区云场景):
| ID | 标签 | 业务处理方 | 样本数 |
|---|---|---|---|
| C01 | 走访/统计 | 数据组 | 175 |
| C02 | 小区/物业/业委会/机构 | 街镇办 | 157 |
| C03 | 房态图/人房数据 | 数据组 | 140 |
| C04 | 标签/重点人群 | 数据组 | 74 |
| C05 | 换届选举 | 民政组 | 56 |
| C06 | 小事快办/社区动态 | 街镇办 | 33 |
| C07 | 账号/权限/登录 | 运维 | 28 |
| C08 | 系统故障/性能 | 研发 | 12 |
| C09 | 进度跟进/流程咨询 | 客服 | 11 |
| C10 | 功能优化/需求 | 产品 | 7 |
| C11 | 数据导出/下载 | 数据组 | 2 |
3.2 数据采集与清洗
清洗规则(IM 场景标准):
| 处理 | 规则 | 示例 |
|---|---|---|
| 去除表情 | [表情]、emoji 字符 | [表情]你好 → 你好 |
| 去除媒体 | [图片]、[语音]、[文件] | [图片] → |
| 匿名化 @ | @昵称 → @USER | @张三 在吗 → @USER 在吗 |
| 替换链接 | URL → URL | 看 http://x.com → 看 URL |
| 合并空白 | 多空白 → 单空格 | 你 好 → 你 好 |
| 截断长度 | > 128 字符保留前 128 | — |
业务名词词典(社区云场景,必须维护):
房态图、人房数据、人房关系、线索发现、接待走访
综合采集、重点人群、一房一档、人口台账、换届选举
小事快办、议事协商、民主评议这些词在通用中文语料里几乎不出现,模型必须从领域数据中学习。
3.3 标注规范要点
3.3.1 优先级 + 流程图
开始
↓
包含投诉/举报词?──是──→ L5
↓ 否
涉及钱/支付/会员?──是──→ L4
↓ 否
涉及账号/登录/封禁?──是──→ L3
↓ 否
询问怎么用/求助?──是──→ L2
↓ 否
表达不满/提建议?──是──→ L1
↓ 否
→ L03.3.2 置信度字段
| 置信度 | 含义 | 处理 |
|---|---|---|
| 2 = 高 | 明确属于该类 | 直接入库 |
| 1 = 中 | 大概率属于,但有歧义 | 入库 + 交叉校验 |
| 0 = 低 | 不确定 | 不训练,入复审池 |
3.3.3 一致性指标
Cohen's Kappa ≥ 0.75 视为合格。
公式:Kappa = (P_o - P_e) / (1 - P_e),扣掉"瞎猜能蒙对"的部分。
每 200 条插入 5 条金标准样本,每周汇报一次。
3.4 数据集划分
from sklearn.model_selection import train_test_split
train_df, tmp_df = train_test_split(
df, test_size=0.2, stratify=df["label_id"], random_state=42
)
val_df, test_df = train_test_split(
tmp_df, test_size=0.5, stratify=tmp_df["label_id"], random_state=42
)两个关键参数:
stratify:每份里各类比例一致(避免某类全在 train)random_state=42:固定随机种子,便于复现和对比
本项目划分结果:train 556 / val 69 / test 70(共 695 条)。
四、基线训练全流程
4.1 环境与依赖
pip install transformers torch datasets accelerate scikit-learn pandasCPU 即可,无需 GPU。BERT-base CPU 跑 5 epoch 大约 15 分钟。
4.2 模型下载与离线准备
国内推荐 hf-mirror.com,无需登录。
必下 5 个文件(Pytorch 路线):
| 文件 | 大小 | 用途 |
|---|---|---|
config.json | 689 B | 模型结构配置 |
pytorch_model.bin | 412 MB | Pytorch 权重(核心) |
tokenizer.json | 269 kB | 分词器词表+规则 |
tokenizer_config.json | 19 B | 分词器配置 |
vocab.txt | 110 kB | 词表 |
不用的文件:flax_model.msgpack、tf_model.h5、README.md 等。
放置到 models/chinese-roberta-wwm-ext/ 目录,from_pretrained() 即可离线加载。
4.3 数据加载与 Tokenize
from transformers import AutoTokenizer
tokenizer = AutoTokenizer.from_pretrained("models/chinese-roberta-wwm-ext")
def tokenize_fn(batch):
return tokenizer(
batch["text"],
truncation=True,
max_length=128, # IM 问题 95% < 64 字
padding="max_length"
)
train_ds = Dataset.from_pandas(train_df).map(tokenize_fn, batched=True)input_ids 怎么来的?
"怎么建群啊"
↓ BERT tokenizer 分词(wwm 整词保留)
["怎", "么", "建", "群", "啊"]
↓ 查 vocab 表
[2769, 6370, 3299, 6206, 1024]
↓ 加 [CLS] [SEP]
[101, 2769, 6370, 3299, 6206, 1024, 102]
↓ 补 0 到 128 位
[101, 2769, 6370, 3299, 6206, 1024, 102, 0, 0, ...]attention_mask:1 表示真词,0 表示 padding。模型据此忽略 padding。
4.4 训练配置
from transformers import TrainingArguments
args = TrainingArguments(
output_dir="output/tdtim-classifier",
num_train_epochs=5,
per_device_train_batch_size=8,
per_device_eval_batch_size=16,
learning_rate=2e-5, # BERT 微调黄金值
weight_decay=0.01,
warmup_steps=35, # 约 10% 总步数
eval_strategy="epoch",
save_strategy="epoch",
load_best_model_at_end=True,
metric_for_best_model="f1_macro",
logging_steps=20,
report_to="none",
fp16=torch.cuda.is_available(), # 有 GPU 就开
)关键超参解释:
| 参数 | 推荐值 | 原因 |
|---|---|---|
max_length=128 | 128 | IM 问题 99% 覆盖 |
batch_size=8 | 8 | CPU 友好,显存够用可调大 |
lr=2e-5 | 2e-5 | 太大易崩,太小难收敛 |
epochs=5 | 5 | 数据 < 5000 时够用 |
warmup_ratio=0.1 | 0.1 | 缓解早期梯度爆炸 |
类别不均衡处理:
from sklearn.utils.class_weight import compute_class_weight
class_weights = compute_class_weight(
"balanced",
classes=np.arange(len(LABELS)),
y=train_df["label_id"]
)
class_weights = torch.tensor(class_weights, dtype=torch.float)
# 自定义 Trainer 注入加权 loss
class WeightedTrainer(Trainer):
def compute_loss(self, model, inputs, return_outputs=False, num_items_in_batch=None):
labels = inputs.pop("labels")
outputs = model(**inputs)
loss = torch.nn.CrossEntropyLoss(weight=class_weights.to(outputs.logits.device))(
outputs.logits, labels
)
return (loss, outputs) if return_outputs else loss4.5 训练过程监控
训练中每个 epoch 会打印:
{'loss': 0.85, 'grad_norm': 1.2, 'learning_rate': 1.8e-05, 'epoch': 1.0}
{'eval_loss': 0.62, 'eval_accuracy': 0.7101, 'eval_f1_macro': 0.5501, 'eval_f1_weighted': 0.6637}正常曲线:
| Epoch | Train Loss | Val F1 | 行为 |
|---|---|---|---|
| 1 | 1.42 | 0.78 | 学到基础语义 |
| 2 | 0.85 | 0.86 | 学到标签特征 |
| 3 | 0.52 | 0.91 | 收敛 |
| 4 | 0.31 | 0.92 | 最佳(保存) |
| 5 | 0.18 | 0.91 | 过拟合迹象 |
load_best_model_at_end=True 会自动回到 Epoch 4 的参数。
4.6 评估指标
4.6.1 三个核心指标
| 指标 | 公式 | 适用 |
|---|---|---|
| accuracy | 正确数 / 总数 | 类别均衡时 |
| f1_macro | 各类 F1 算术平均 | 类别不均衡时主用 |
| f1_weighted | 各类 F1 按样本数加权 | 反映整体效果 |
类别不均衡时必须看 f1_macro,否则模型"全预测大类"也能拿 80% accuracy。
4.6.2 详细报告
from sklearn.metrics import classification_report, confusion_matrix
print(classification_report(labels, preds, target_names=LABELS, digits=3))
print(confusion_matrix(labels, preds))4.6.3 本项目基线结果
test accuracy = 0.8571
test f1_macro = 0.6890
test f1_weighted = 0.8580大类(走访、小区、房态)F1 都在 0.87+,小类(系统故障、进度跟进)F1=0,完全没学好。
五、踩坑记录
5.1 transformers 5.x 兼容性问题
坑 1:warmup_ratio 被移除
# ❌ transformers 5.x 报错
TrainingArguments(..., warmup_ratio=0.1)
# TypeError: __init__() got an unexpected keyword argument 'warmup_ratio'
# ✅ 改用绝对步数
total_steps = len(train_ds) // batch_size * epochs # 556/8*5 = 348
warmup_steps = int(total_steps * 0.1) # 35
TrainingArguments(..., warmup_steps=35)坑 2:tokenizer= → processing_class=
# ❌ 旧版
trainer = Trainer(..., tokenizer=tokenizer)
# ✅ 新版
trainer = Trainer(..., processing_class=tokenizer)坑 3:compute_loss 签名变化
# ❌ 旧版签名
def compute_loss(self, model, inputs):
...
# ✅ 新版签名(5.x)
def compute_loss(self, model, inputs, return_outputs=False, num_items_in_batch=None):
...坑 4:lr_scheduler_kwargs 参数名
新版本 get_linear_schedule_with_warmup 用 num_warmup_steps,且 transformers 在调用前会注入这个参数,不能再在 lr_scheduler_kwargs 里传,否则会冲突。
5.2 类别不均衡导致小类 F1=0
症状:C08(系统故障)12 条训练样本,模型从未预测该类,recall=0、F1=0。
根因:训练样本太少(< 20 条),模型"没见过"。
解法(按优先级):
- 数据增强:LLM 同义改写扩到 50+ 条
- 过采样:
RandomOverSampler复制小类样本 - class_weight:本项目已用,但样本太少时仍不够
- 合并小类:C08+C09+C10+C11 合并为 C99
5.3 训练中断后从 checkpoint 恢复
# ✅ 加载上次的 checkpoint
model = AutoModelForSequenceClassification.from_pretrained("output/tdtim-classifier/checkpoint-140")
tokenizer = AutoTokenizer.from_pretrained("output/tdtim-classifier/checkpoint-140")
trainer = Trainer(
model=model, args=args,
train_dataset=train_ds, eval_dataset=val_ds,
)
trainer.train(resume_from_checkpoint="output/tdtim-classifier/checkpoint-140")save_strategy="epoch" 默认只保留最近 2 个 checkpoint(save_total_limit=2),磁盘不会爆。
5.4 test 集未出现的类导致 classification_report 报错
# ❌ 报错:labels are empty
print(classification_report(labels, preds, target_names=LABELS))
# ✅ 只保留出现过的类
present = sorted(set(labels) | set(preds))
present_names = [LABELS[i] for i in present]
print(classification_report(
labels, preds,
labels=present, target_names=present_names,
digits=3, zero_division=0,
))六、推理与部署
6.1 单条 / 批量 / 交互三种用法
单条:
python src\infer.py "走访记录导出乱码"批量:
python src\infer.py --file data\new_messages.csv --out data\predicted.csv交互:
python src\infer.py -i
>>> 提交申请后是街镇级审核么
Top-1: 账号/权限/登录 score=0.656.2 批量预测 CSV 输入输出规范
输入(仅需 1 列):
text
怎么创建群聊
走访记录导出乱码输出(自动追加 3 列):
text,pred_label_id,pred_label,pred_score
怎么创建群聊,2,小区/物业/业委会/机构,0.7821
走访记录导出乱码,0,走访/统计,0.9622若输入含 label_id 列,自动识别并打印准确率。
6.3 性能优化路线
| 阶段 | 方法 | 预期效果 |
|---|---|---|
| 模型压缩 | ONNX Runtime + INT8 量化 | 提速 2–3x,体积 -75% |
| 批处理 | 攒批推理 (dynamic batch) | 吞吐提升 3–5x |
| 模型蒸馏 | Teacher=BERT-base → Student=4 层 MiniLM | 速度 +10x |
| 缓存 | Redis 缓存 (text_hash → result) | 高频问题命中率 >40% |
| GPU | 单卡 T4 即可支撑 1k+ QPS | — |
ONNX 导出(推荐):
from optimum.onnxruntime import ORTModelForSequenceClassification
# 终端命令(推荐)
# optimum-cli export onnx --model output/tdtim-classifier/best onnx_model/七、迭代优化方向
7.1 数据增强(最优先)
小类样本不足是基线模型的主要瓶颈。
# 用 LLM 给 C08–C11 各扩 30+ 条同义样本
# Prompt 模板:
"""
你是社区云产品的用户,请用不同口吻重写下面这条问题,保留核心业务词:
原句:{original_text}
要求:
- 保持原意
- 长度相近(5–50 字)
- 风格多样(口语化、正式、带错别字)
- 输出 3 个版本
"""扩充后预期 f1_macro 从 0.69 提升到 0.80+。
7.2 早停 + 学习率调度
TrainingArguments(
early_stopping_patience=2, # 2 个 epoch 不涨就停
lr_scheduler_type="cosine", # 余弦退火
)7.3 过采样 / 欠采样
from imblearn.over_sampling import RandomOverSampler
ros = RandomOverSampler(sampling_strategy="auto", random_state=42)
X_res, y_res = ros.fit_resample(X_train, y_train)7.4 冻结低层 + 微调顶层
数据 < 1000 条时建议:
# 冻结 BERT 前 8 层
for param in model.bert.encoder.layer[:8].parameters():
param.requires_grad = False减少可训练参数,缓解过拟合。
八、附录
8.1 完整项目结构
d:\1sqyai\tangdou-bert\
├── data\
│ ├── train.csv (556 条)
│ ├── val.csv (69 条)
│ ├── test.csv (70 条)
│ ├── label_map.json
│ └── 讨论问题汇总-1.csv (原始数据)
├── models\
│ └── chinese-roberta-wwm-ext\ (离线权重)
├── output\
│ ├── tdtim-classifier\
│ │ ├── checkpoint-140\ (最佳)
│ │ ├── checkpoint-350\ (最终)
│ │ └── best\ (推理用)
│ │ ├── model.safetensors (390 MB)
│ │ ├── config.json / tokenizer.json
│ │ └── test_report.json
│ └── logs\
│ ├── train_log.txt
│ └── eval_log.txt
├── src\
│ ├── prepare_data.py
│ ├── train.py
│ ├── evaluate.py
│ └── infer.py
└── docs\
├── labeling_guide.md
├── how-to-train-question-classifier.md (本文)
└── download_model.md8.2 关键脚本入口
| 脚本 | 用途 | 命令 |
|---|---|---|
prepare_data.py | 清洗 + 划分数据集 | python src/prepare_data.py |
train.py | 微调训练 | python src/train.py |
evaluate.py | 全量评估 + 混淆矩阵 | python src/evaluate.py |
infer.py | 单条 / 批量 / 交互预测 | python src/infer.py "..." |
8.3 术语表
| 术语 | 解释 |
|---|---|
| MLM | Masked Language Modeling,BERT 预训练任务 |
| WWM | Whole Word Masking,整词掩码(哈工大改进) |
| [CLS] | 分类标记,聚合整句语义 |
| Cohen's Kappa | 标注一致性指标,>0.75 为高一致 |
| Confusion Matrix | 混淆矩阵,看具体类间误判 |
| f1_macro | 各类 F1 算术平均,看小类 |
| f1_weighted | 各类 F1 按样本数加权,看整体 |
| Stratify | 分层采样,保证分布一致 |
| ONNX | 跨框架模型格式 |
| INT8 | 8 位整数量化,体积 -75% |