文本分类的三种解法:接口调用、Bert微调与LLM微调实战对比
1. 文本分类的应用场景
文本分类是自然语言处理中最基础也最广泛落地的任务之一。简单来说,它的目标就是:给定一段文本,让模型判断它属于哪个类别。
内容平台
- 今日头条把新闻自动归入"科技"“娱乐”"体育"等频道,靠的就是文本分类
- 知乎、小红书对帖子打标签,推荐给可能感兴趣的用户
电商与客服
- 淘宝评论自动识别好评/差评(情感分析本质也是文本分类)
- 客服系统把工单分派给对应部门:"我要退货"→售后组,"怎么用"→技术支持组
风控与合规
- 社交媒体自动检测违规内容(涉黄、涉暴、广告引流)
- 企业内审系统扫描邮件,标记敏感信息
信息处理
- 邮件客户端的垃圾邮件过滤(二分类的经典应用)
- 法律文书、医疗报告的自动归档
这些场景有一个共同点:文本量大、类别相对固定、需要快速准确地处理。这也正是文本分类任务的价值所在——用模型替代人工,实现规模化、自动化。
2. 数据准备与探索分析
2.1 数据集获取与构建
这三种方法都使用CLUE/TNEWS这个数据集。
from datasets import load_dataset
ds = load_dataset("clue", "tnews")
下载下来的数据文件有四个:
label_map.json:类别映射表,ID ↔ 类别码 ↔ 中文名
train.json:训练集,53K 条,带标签,模型学习用
val.json:验证集,10K 条,带标签,调参和最终评测用
test.json:测试集,10K 条,标签全为 -1,不可用,忽略
train.json,val.json 里面的数据类似这样,label带的数字表示类型,7是“教育”:
{
"idx": 0,
"sentence": "上课时学生手机响个不停,老师一怒之下把手机摔了,家长拿发票让老师赔,大家怎么看待这种事?",
"label": 7
}
2.2 数据探索





对数据进行分析后可以得出以下结论:
- 类别不均衡比 23x(科技 5955 条 vs 证券 257 条)
- 文本极短(均值 22 字,P99 = 39 字),max_length=64 已完全覆盖
- Token/字符比值 ≈ 0.97,中文基本 1 字 = 1 token
3. 方案一:调LLM接口。真的划算吗?
3.1 提示词设计与调用流程
采用 deepseek-v4-pro 和 deepseek-v4-flash 的非思考模型先看预测的准确率。
提示词如下:
# src_llm/deepseek_llm.py
# 15 个类别名
LABEL_NAMES = [
"故事", "文化", "娱乐", "体育", "财经",
"房产", "汽车", "教育", "科技", "军事",
"旅游", "国际", "证券", "农业", "电竞",
]
SYSTEM_PROMPT = (
"你是一个新闻标题分类助手。请将给定的新闻标题分类到以下类别之一,"
"只输出类别名称,不要输出任何其他内容。\n"
"可选类别:" + "、".join(LABEL_NAMES)
)
def classify_one(text: str, client: OpenAI,
max_tokens: int = 16) -> str:
"""调用 DeepSeek-V4-pro,返回模型输出。"""
response = client.chat.completions.create(
model="deepseek-v4-pro",
messages=[
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": build_prompt(text)},
],
max_tokens=max_tokens,
temperature=0,
extra_body={"thinking": {"type": "disabled"}}
)
return response.choices[0].message.content.strip()
3.2 调用云端llm接口的测试效果
deepseek-v4-pro关闭思考,测试200条,输出:
============================================================
DeepSeek-V4-PRO (deepseek-v4-pro) Zero-Shot 分类结果
============================================================
样本数 : 200
准确率 : 106/200 = 0.5300
无法解析 : 3 条 (1.5%)
总耗时 : 168.2s, 均值 0.84s/条
deepseek-v4-flash关闭思考,测试200条,输出:
============================================================
DeepSeek-V4-Flash (deepseek-v4-flash) Zero-Shot 分类结果
============================================================
样本数 : 200
准确率 : 104/200 = 0.5200
无法解析 : 0 条 (0.0%)
总耗时 : 126.8s, 均值 0.63s/条
3.3 接口方案的局限性
虽然说调用云端接口这个方案很简单,但是从刚才的实验结果中能看出这个方案存在以下局限性:
- 准确率天花板明显。DeepSeek-V4-Pro 已经是市面顶尖模型,但在新闻标题分类上 zero-shot 准确率仅 53%。标题短、语义模糊确实增加了难度,但更关键的是:接口方案只能靠 prompt 调优,无法针对任务做任何模型层面的优化,天花板触手可及。
- 响应速度慢。V4-Flash 单条耗时 0.63 秒,200 条就跑了两分多钟。一旦业务量上到 10 万条,需要超过 16 个小时。
- 输出不可控。LLM 本质是逐 token 生成,提示词无法 100% 约束格式。V4-Pro 实验中就有 1.5% 的输出无法解析——在生产环境中,这就是故障点,需要额外写解析逻辑来兜底。
- 成本随规模线性增长。200 条的 API 费用看起来不起眼,但 10 万条就是 500 倍。而本地部署一次训练后,推理几乎零成本。对于长期、大批量的分类任务,接口方案的经济账很难算过来。
4. 方案二:Bert微调
4.1 Bert是什么?
这个实验用到的模型叫 bert-base-chinese。它是Google 用海量无标注文本(中文维基、新闻等)训练好一个通用模型,学会了中文的语法、语义、常识。然后使用者在通用模型的基础上,用自己的标注数据(如 TNEWS 新闻标题)再训练几轮,让它适配具体的任务。
Bert 只用了 Transformer 的 Encoder 部分。可以把它理解为一个"文本理解器"——输入一段文本,输出每个词的向量表示,这些向量已经融合了上下文信息。
- 参数规模:约 102M(1.02 亿)
- 输入:新闻标题(如"苹果发布了最新的 iPhone 17")
- 输出:一个 768 维的向量(取 CLS 位置或其他池化方式),然后接一个分类头输出 15 个类别的概率
4.2 微调的核心代码
使用bert的流程如下:
然后在训练的过程中,对bert模型是全量微调,并且对bert层和分类头采用的是不同的学习率,bert的学习率会小一些,尽量别影响已有的能力。
分词器核心代码:
# src/dataset.py
class TNEWSDataset(Dataset):
"""
每条样本经过 tokenizer 处理后返回:
input_ids : [max_length] — token id 序列,含 [CLS] 和 [SEP]
attention_mask : [max_length] — 1=真实 token,0=padding
token_type_ids : [max_length] — 单句分类全为 0
label : scalar — 类别 id,test 集为 -1
"""
def __init__(
self,
data_path: Path,
tokenizer: BertTokenizer,
max_length: int = 128,
):
with open(data_path, encoding="utf-8") as f:
self.data = json.load(f)
self.tokenizer = tokenizer
self.max_length = max_length
def __len__(self):
return len(self.data)
def __getitem__(self, idx):
item = self.data[idx]
encoding = self.tokenizer(
item["sentence"],
max_length=self.max_length,
truncation=True, # 超出 max_length 时截断
padding="max_length", # 不足 max_length 时用 [PAD] 填充
return_tensors="pt", # 直接返回 PyTorch tensor
)
return {
"input_ids": encoding["input_ids"].squeeze(0), # [max_length]
"attention_mask": encoding["attention_mask"].squeeze(0), # [max_length]
"token_type_ids": encoding["token_type_ids"].squeeze(0), # [max_length]
"label": torch.tensor(item["label"], dtype=torch.long),
}
train_ds = TNEWSDataset(data_dir / "train.json", tokenizer, max_length)
val_ds = TNEWSDataset(data_dir / "val.json", tokenizer, max_length)
train_loader = DataLoader(train_ds, batch_size=batch_size,
shuffle=True, num_workers=num_workers)
val_loader = DataLoader(val_ds, batch_size=batch_size,
shuffle=False, num_workers=num_workers)
模型的forward计算:
# src/model.py
def forward(
self,
input_ids: torch.Tensor, # [B, L]
attention_mask: torch.Tensor, # [B, L]
token_type_ids: torch.Tensor, # [B, L]
) -> torch.Tensor:
"""
返回 logits: [B, num_labels],未经 softmax(交叉熵 loss 内部做)
"""
# return_dict=True:transformers 5.x 默认返回 tuple,显式要求返回命名对象
outputs = self.bert(
input_ids=input_ids,
attention_mask=attention_mask,
token_type_ids=token_type_ids,
return_dict=True,
)
# last_hidden_state : [B, L, H]
# pooler_output : [B, H] — BERT 对 [CLS] 过了一层 tanh 的版本
last_hidden = outputs.last_hidden_state # [B, L, H]
vec = self._pool(last_hidden, attention_mask) # [B, H]
vec = self.dropout(vec)
logits = self.classifier(vec) # [B, num_labels]
return logits
训练的代码有几点关注下
- 加权 Loss:让小样本也被看见(科技 5955 条 vs 证券 257 条)
# src/train.py
if args.use_class_weight:
weights = compute_loss_weights(data_dir, num_labels, device)
criterion = nn.CrossEntropyLoss(weight=weights)
- 分层学习率:预训练部分稳着调,新加部分大步学
# src/train.py
bert_params = list(model.bert.parameters())
head_params = list(model.classifier.parameters()) + list(model.dropout.parameters())
optimizer = AdamW([
{"params": bert_params, "lr": 2e-5}, # Bert 层:小学习率
{"params": head_params, "lr": 2e-5 * 5.0}, # 分类头:大 5 倍
])
- Warmup:先热身再冲刺。训练刚开始时 loss 很大、梯度很陡,如果一上来就用满学习率,可能把预训练权重冲坏。warmup 让学习率从 0 线性增长到目标值,前 10% 步数用来"热身"。
# src/train.py
warmup_steps = int(total_steps * 0.1)
scheduler = get_linear_schedule_with_warmup(
optimizer, num_warmup_steps=warmup_steps, num_training_steps=total_steps
)
- 池化策略:怎么把 token 矩阵压缩成句向量。Bert 最后一层输出的是一个 [B, L, 768] 的矩阵——每条文本有 L 个 token,每个 token 是 768 维向量。分类头需要的是一个句向量,也就是把 L 个向量压缩成一个。本项目支持三种策略:
# src/train.py
"""
CLS:直接取 [CLS] 位置的输出,Bert 预训练时专门优化过,最经典
Mean:所有真实 token 取平均,每个词平等贡献,实践中往往更鲁棒
Max:每个维度取最大值,保留最显著信号,适合关键词驱动的场景
"""
if self.pool == "cls":
return last_hidden[:, 0, :] # 取 [CLS] 位置,最经典的做法
if self.pool == "mean":
sum_hidden = (last_hidden * mask).sum(dim=1) # 有效 token 求和
count = mask.sum(dim=1).clamp(min=1e-9) # 有效 token 数量
return sum_hidden / count # 求均值,信息利用更充分
if self.pool == "max":
masked = last_hidden + (1 - mask) * (-1e9) # padding 位置设为负无穷
return masked.max(dim=1).values # 取最大值,保留最显著特征
4.3 最终效果与混淆矩阵解读
接着进行四次脚本训练对比下。
python train.py --pool cls --epochs 3
python train.py --pool cls --epochs 3 --use_class_weight
python train.py --pool mean --epochs 3 --use_class_weight
python train.py --pool max --epochs 3 --use_class_weight
先用不带use_class_weight的验证让小样本也被看见,再对比下三种池化策略。
列名 含义 直白理解
precision 精确率 模型猜这个类别时,猜对的概率
recall 召回率 真实属于这个类别的样本,被找到的比例
f1-score F1 分数 precision 和 recall 的调和平均,综合打分
support 样本数 验证集中这个类别有多少条
cls不处理类别不均:
分类报告:
precision recall f1-score support
故事 0.51 0.45 0.48 215
文化 0.53 0.55 0.54 736
娱乐 0.56 0.60 0.58 910
体育 0.73 0.72 0.72 767
财经 0.49 0.49 0.49 956
房产 0.62 0.62 0.62 378
汽车 0.70 0.62 0.66 791
教育 0.57 0.58 0.57 646
科技 0.53 0.54 0.54 1089
军事 0.56 0.54 0.55 716
旅游 0.50 0.49 0.49 693
国际 0.50 0.54 0.52 905
证券 0.52 0.31 0.39 45
农业 0.51 0.54 0.52 494
电竞 0.68 0.63 0.66 659
accuracy 0.57 10000
macro avg 0.57 0.55 0.56 10000
weighted avg 0.57 0.57 0.57 10000

cls:
分类报告:
precision recall f1-score support
故事 0.43 0.69 0.53 215
文化 0.52 0.52 0.52 736
娱乐 0.62 0.54 0.58 910
体育 0.73 0.71 0.72 767
财经 0.50 0.46 0.48 956
房产 0.54 0.69 0.60 378
汽车 0.68 0.64 0.66 791
教育 0.55 0.61 0.58 646
科技 0.58 0.44 0.50 1089
军事 0.53 0.64 0.58 716
旅游 0.48 0.53 0.50 693
国际 0.52 0.42 0.46 905
证券 0.34 0.58 0.43 45
农业 0.48 0.62 0.54 494
电竞 0.66 0.65 0.65 659
accuracy 0.56 10000
macro avg 0.54 0.58 0.56 10000
weighted avg 0.57 0.56 0.56 10000
val accuracy : 0.5616
val macro F1 : 0.5560

mean:
分类报告:
precision recall f1-score support
故事 0.43 0.63 0.51 215
文化 0.53 0.54 0.54 736
娱乐 0.61 0.55 0.58 910
体育 0.74 0.71 0.72 767
财经 0.50 0.45 0.48 956
房产 0.54 0.70 0.61 378
汽车 0.68 0.64 0.65 791
教育 0.52 0.63 0.57 646
科技 0.57 0.44 0.50 1089
军事 0.53 0.59 0.56 716
旅游 0.50 0.50 0.50 693
国际 0.51 0.43 0.47 905
证券 0.36 0.71 0.48 45
农业 0.48 0.62 0.54 494
电竞 0.65 0.68 0.66 659
accuracy 0.56 10000
macro avg 0.54 0.59 0.56 10000
weighted avg 0.57 0.56 0.56 10000
val accuracy : 0.5606
val macro F1 : 0.5575

max:
分类报告:
precision recall f1-score support
故事 0.39 0.69 0.50 215
文化 0.54 0.52 0.53 736
娱乐 0.64 0.51 0.57 910
体育 0.72 0.72 0.72 767
财经 0.49 0.38 0.43 956
房产 0.52 0.72 0.60 378
汽车 0.68 0.66 0.67 791
教育 0.54 0.60 0.57 646
科技 0.55 0.46 0.50 1089
军事 0.52 0.63 0.57 716
旅游 0.49 0.51 0.50 693
国际 0.50 0.42 0.45 905
证券 0.31 0.71 0.43 45
农业 0.46 0.60 0.52 494
电竞 0.64 0.68 0.66 659
accuracy 0.55 10000
macro avg 0.53 0.59 0.55 10000
weighted avg 0.56 0.55 0.55 10000
val accuracy : 0.5549
val macro F1 : 0.5488

分析:
- 通过对比cls是否带加权。不加权时,证券类 Recall 只有 31%,加权后涨到 58%,几乎翻倍。故事类从 45% 涨到 69%,房产类从 62% 涨到 69%。代价是 Accuracy 降了 1 个百分点,以及证券类的 Precision 从 0.52 降到 0.34。Precision 下降恰恰说明加权 loss 在起作用:模型不再逃避少数类,遇到吃不准的也敢猜了。对于"每个类别都同等重要"的业务场景,这 1% 的 Accuracy 换少数类 Recall 的大幅提升,完全值得。
- 锁定加权 loss,对比 CLS、Mean、Max 三种池化。三者在整体指标上差距很小(Accuracy 相差不到 1 个百分点),说明对于新闻标题这种短文本分类任务,池化策略不是决定性因素。Mean 在 Macro F1 上略优,Max 在证券 Recall 上和 Mean 持平(0.71),但在财经类上表现最差(0.38)。CLS 作为默认策略,各方面表现均衡。
5. 方案三:LLM私有化微调
5.1 全量微调
5.1.1 训练
流程和我上一篇minigpt的SFT训练大体一样。构建大模型输出的格式,loss只计算回答部分。
喂给大模型的数据长这样:
<|im_start|>system
你是一个新闻标题分类助手。请将给定的新闻标题分类到以下类别之一,只输出类别名称,不要输出任何其他内容。
可选类别:故事、文化、娱乐、体育、财经、房产、汽车、教育、科技、军事、旅游、国际、证券、农业、电竞<|im_end|>
<|im_start|>user
新闻标题:上课时学生手机响个不停,老师一怒之下把手机摔了,家长拿发票让老师赔,大家怎么看待这种事?
类别:<|im_end|>
<|im_start|>assistant
教育<|im_end|>
参与计算loss的:
教育<|im_end|>
5.1.2 效果与资源消耗
用5090显卡全量微调Qwen2-0.5B模型,训练三轮
python train_sft.py --full_ft --lr 2e-5 --num_train -1
显存占用约12.5GB,平均一轮950s
============================================================
LLM SFT 分类结果
============================================================
样本数 : 10000
准确率 : 5656/10000 = 0.5656
无法解析 : 0 条 (0.0%)
总耗时 : 255.9s,均值 0.03s/条
5.2 LoRA微调
5.2.1 LoRA是什么?
LoRA(Low-Rank Adaptation,低秩适配)是一种参数高效微调技术。它的核心思想很简单:不修改原模型,只在旁边挂两个小矩阵来学习新任务。
全量微调要训练 d*k 的参数,lora只用训练 d*r + r*k 的参数。
公式是:h = W·x + (α/r) · B·A·x
LoRA的优势有以下几点:
- 实证发现:微调时权重更新量 ΔW 的有效秩极低,r=8 已能逼近全量效果
- 参数量大幅降低。7B 模型从 70 亿可训参数 → ~4M(仅 0.06%),显存从 80G 降到 16G
- 原始预训练权重 W 完全冻结,模型常识不会被任务数据破坏。
- 多任务零成本切换 — base 模型只需一份;不同任务存独立的参数,按需加载。
5.2.2 核心代码与关键参数
lora_config = LoraConfig(
task_type=TaskType.CAUSAL_LM,
r=8, # 秩,两个小矩阵的中间维度
lora_alpha=16, # 缩放因子,控制 LoRA 对输出的影响强度
target_modules=["q_proj", "k_proj", "v_proj", "o_proj"], # 在注意力层的四个投影矩阵上挂 LoRA
lora_dropout=0.05, # 防止小矩阵过拟合
bias="none", # 不训练偏置项
)
model = get_peft_model(model, lora_config) # 冻结原模型,注入 LoRA 小矩阵
5.2.3 效果
训练三轮
python train_sft.py --num_train -1
显存占用约12GB,平均一轮约1100s。
============================================================
LLM SFT 分类结果
============================================================
样本数 : 10000
准确率 : 5828/10000 = 0.5828
无法解析 : 3 条 (0.0%)
总耗时 : 248.9s,均值 0.02s/条
5.3 Zero-Shot 结果
==================================================
Zero-Shot LLM 分类结果(Qwen2-0.5B-Instruct)
==================================================
样本数 : 10000
准确率 : 3345/10000 = 0.3345
无法解析 : 2664 条 (26.6%)
总耗时 : 285.5s, 均值 0.03s/条
6. 综合对比与总结
6.1 三方案横向评测表
| 维度 | DeepSeek API | Bert 微调 | Qwen 0.5B Zero-Shot | Qwen 0.5B 全量 | Qwen 0.5B LoRA |
|---|---|---|---|---|---|
| 模型大小 | — | 102M | 495M | 495M | 495M(+1M) |
| 训练数据量 | 0 | 53K | 0 | 53K | 53K |
| 训练时间 | — | 90s/epoch | — | 950s/epoch | 1100s/epoch |
| 训练占用显存 | — | 3.3GB | — | 12.5GB | 12GB |
| 准确率 | 53.0% | 56.2% | 33% | 56.6% | 58.2% |
| 无法解析率 | 1.5% | 0% | 26.6% | 0% | 0.03% |
| 单条推理用时 | 840ms | 0.8ms | 29ms | 26ms | 25ms |
| 推理 1 万条总耗时 | 140min | 7.8s | 285.5s | 255.9s | 248.9s |
6.2 场景化选型建议
| 场景 | 推荐方案 | 原因 |
|---|---|---|
| 标注数据充足(500条以上) | Bert 微调 | 效果稳定,推理极快,0% 解析失败 |
| 标注数据极少或为零 | LLM 方案 | Bert 需要标注数据才能微调;LLM 可 zero-shot 或 few-shot |
| 追求输出稳定可控,不容许格式错误 | Bert 微调 | 输出 logits 直接取 argmax,100% 可控 |
| 批量推理量大,延迟要求高 | Bert 微调 | 单条 0.8ms,1 万条 7.8 秒 |
| 分类体系频繁变化 | API | 改 prompt 就行,不用重新训模型 |
| 多个分类任务共存,共享基座 | LoRA | 一个基座 + 多个几 MB 的 adapter |
| 分类结果需要附带解释 | LLM 方案 | 生成式模型天然支持,Bert 做不到 |
更多推荐

所有评论(0)