IMDB 影评情感分析实战:从 TF-IDF 基线到 DistilBERT + LoRA 微调

AI教程22小时前更新 程序员阿超
126 0 0

情感分析是 NLP 的”Hello World”,但把 demo 做成可靠结论需要一整条工程链:数据审计、经典基线、可解释参照、参数高效微调、多维度评估、概率校准、鲁棒性检查。本文以斯坦福 IMDB 影评数据集(5 万条电影评论,正负二分类)为战场,带你从 TF-IDF + 逻辑回归基线一路打到 DistilBERT + LoRA 微调,外加校准、可解释和半监督伪标签,全流程可复现。

一、背景:为什么不直接上大模型

新手常犯的错误是跳过基线直接微调,结果模型 92% 准确率都不知道算好算坏、钱花得值不值。经典基线的意义有三层:第一,它是参照系——TF-IDF 基线在 IMDB 上通常能到 87~89%,微调 transformer 必须显著超过它才算数;第二,它是可解释锚点——逻辑回归的权重直接告诉你哪些 n-gram 最正向/最负向,排查数据问题全靠它;第三,它是成本锚点——CPU 几分钟训完的基线 vs 需要 GPU 的微调,涨点幅度必须配得上算力账单。

而选择 DistilBERT + LoRA 而不是全量微调 BERT,是参数效率的考量:DistilBERT 本来就只有 BERT 约 60% 的参数,LoRA 再把可训练参数压到百分之几,单卡消费级 GPU(甚至 Colab 免费卡)就能跑,还能用 PEFT 生态随时合并、切换适配器。

二、原理:用到的四个关键技术

TF-IDF + 逻辑回归。 TF-IDF 把文档变成稀疏向量:词频(TF)抓”这篇说了什么”,逆文档频率(IDF)压制”到处都有的词”。逻辑回归在高维稀疏特征上又快又稳,权重天然可解释:系数最大的正负 n-gram 就是模型眼里的”好评词/差评词”。

DistilBERT。 通过知识蒸馏压缩的 BERT:层数减半、推理快 60%,GLUE 上保留约 97% 的性能。影评这种句子级分类任务,DistilBERT 的容量完全够用,还省显存。

LoRA(低秩适配)。 冻结 backbone,只在注意力矩阵旁挂两个小低秩矩阵 A、B 训练(W' = W + BA)。秩 r 通常取 8~16,可训练参数不到 1%,效果接近全量微调。PEFT 库实现,训完可把适配器合并回主干做推理。

校准(Calibration)与 ECE。 准确率高不代表概率可信:模型说 90% 把握,100 次里只对 70 次,就是”过度自信”。期望校准误差(ECE)把预测按置信度分桶,比较每桶的平均置信度和实际准确率,差值加权平均即 ECE。需要”概率当风险分用”(如差评预警阈值)的场景必须看它。配套技术还有阈值调优(不一定用 0.5 当分界)、温度缩放(temperature scaling)事后校准。

三、环境准备

建议 Colab(免费 T4)或本地 NVIDIA 显卡 + Python 3.10。安装:

pip install -U transformers datasets peft accelerate scikit-learn pandas matplotlib seaborn scipy

注意 PEFT 与 torchao 的版本兼容坑(见第五节),出问题先对齐版本。固定随机种子保证可复现:

import random, numpy as np, torch
SEED = 42
random.seed(SEED); np.random.seed(SEED)
torch.manual_seed(SEED)
torch.cuda.manual_seed_all(SEED)

数据用 HuggingFace 上的 stanfordnlp/imdb,含 25k 训练 / 25k 测试,外加无标签池可做半监督。为省时间可先抽子集(如各 5k)调通全流程,再放全量。

四、分步实战

步骤 1:数据审计(训之前先看病)

from datasets import load_dataset
ds = load_dataset("stanfordnlp/imdb")
train, test = ds["train"], ds["test"]
print(train, test)

# 类别平衡
import pandas as pd
print(pd.Series(train["label"]).value_counts(normalize=True))
# IMDB 设计是 50/50,若偏了说明加载或切分有问题

# 评论长度分布
lens = [len(t.split()) for t in train["text"]]
print(pd.Series(lens).describe())
# 典型现象:长尾分布,少数超长评论;决定分词截断长度(256/512)

# 重复泄露检查
print("train dup:", len(train["text"]) - len(set(train["text"])))
overlap = set(train["text"]) & set(test["text"])
print("train/test overlap:", len(overlap))  # 非零就要警惕分数虚高

# HTML 残留检查:IMDB 原始评论含 <br /> 之类标签
print(train[0]["text"][:300])

同时画长度直方图和标签柱状图。审计结论直接决定后面两件事:截断策略(head 还是 tail,后面会做对比实验)和要不要清洗 HTML 标签。

步骤 2:TF-IDF + 逻辑回归基线

import re
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import accuracy_score, f1_score, roc_auc_score

def clean(t):
    t = re.sub(r"<br\s*/?>", " ", t)
    return re.sub(r"\s+", " ", t).strip()

Xtr = [clean(t) for t in train["text"]]
Xte = [clean(t) for t in test["text"]]

vec = TfidfVectorizer(max_features=30000, ngram_range=(1, 2),
                      min_df=3, sublinear_tf=True)
Ztr, Zte = vec.fit_transform(Xtr), vec.transform(Xte)

clf = LogisticRegression(max_iter=1000, C=4.0)
clf.fit(Ztr, train["label"])
p = clf.predict(Zte)
print("acc:", accuracy_score(test["label"], p))
print("macro-F1:", f1_score(test["label"], p, average="macro"))
print("AUC:", roc_auc_score(test["label"], clf.decision_function(Zte)))
# 预期:acc ~0.87-0.89,这就是你的参照线

可解释性红利:直接看权重最大的正负 n-gram。

import numpy as np
names = np.array(vec.get_feature_names_out())
w = clf.coef_[0]
print("最正向:", names[np.argsort(w)[-15:]])
print("最负向:", names[np.argsort(w)[:15]])

你会看到 “masterpiece/ excellent” 一边,”waste/ awful/ boring” 一边。如果榜单里混进奇怪 token(如演员名、HTML 碎片),说明数据有泄露特征,先处理再往下走。

步骤 3:DistilBERT + LoRA 微调

from transformers import AutoTokenizer, AutoModelForSequenceClassification
from peft import LoraConfig, get_peft_model, TaskType

MODEL = "distilbert-base-uncased"
tok = AutoTokenizer.from_pretrained(MODEL)

def tokenize(b):
    return tok(b["text"], truncation=True, max_length=256)

ttr = train.map(tokenize, batched=True)
tte = test.map(tokenize, batched=True)

base = AutoModelForSequenceClassification.from_pretrained(MODEL, num_labels=2)
lora_cfg = LoraConfig(task_type=TaskType.SEQ_CLS, r=16,
                      lora_alpha=32, lora_dropout=0.1,
                      target_modules=["q_lin", "v_lin"])
model = get_peft_model(base, lora_cfg)
model.print_trainable_parameters()  # 可训练参数应 <1%

训练用 HuggingFace Trainer,配动态 padding、早停、混合精度:

from transformers import (TrainingArguments, Trainer,
    DataCollatorWithPadding, EarlyStoppingCallback)
import numpy as np
from sklearn.metrics import accuracy_score, f1_score, roc_auc_score

def metrics(e):
    y, s = e.label_ids, e.predictions
    pred = s.argmax(-1)
    prob = np.exp(s) / np.exp(s).sum(-1, keepdims=True)
    return {"accuracy": accuracy_score(y, pred),
            "macro_f1": f1_score(y, pred, average="macro"),
            "roc_auc": roc_auc_score(y, prob[:, 1])}

args = TrainingArguments(
    output_dir="imdb-distilbert-lora",
    per_device_train_batch_size=32, per_device_eval_batch_size=64,
    num_train_epochs=5, learning_rate=2e-4,
    eval_strategy="epoch", save_strategy="epoch",
    load_best_model_at_end=True, metric_for_best_model="macro_f1",
    fp16=torch.cuda.is_available(), seed=SEED,
    report_to="none",
)
trainer = Trainer(model=model, args=args,
                  train_dataset=ttr, eval_dataset=tte,
                  data_collator=DataCollatorWithPadding(tok),
                  compute_metrics=metrics,
                  callbacks=[EarlyStoppingCallback(early_stopping_patience=2)])
trainer.train()
print(trainer.evaluate())
# 预期:acc ~0.91-0.93,macro-F1 同步涨,AUC ~0.97+

同时画混淆矩阵和 ROC 曲线:混淆矩阵看正负类是否对称犯错,ROC 看整体排序能力。

步骤 4:阈值选择与校准

默认 0.5 阈值不一定最优。在验证集上扫阈值看 F1 曲线,选峰值点。校准检查:

def expected_calibration_error(y_true, y_prob, n_bins=10):
    edges = np.linspace(0, 1, n_bins + 1)
    ece = 0.0
    for i in range(n_bins):
        m = (y_prob > edges[i]) & (y_prob <= edges[i + 1])
        if m.sum() == 0: continue
        ece += m.mean() * abs(y_prob[m].mean() - y_true[m].mean())
    return ece

print("ECE:", expected_calibration_error(y, prob_pos))

再画可靠性曲线(reliability diagram):横轴置信度、纵轴准确率,偏离对角线就是 miscalibration。ECE 偏高就上温度缩放:在验证集上拟合一个温度 T,除 logits 后再 softmax,通常不掉准确率、白赚校准。

步骤 5:可解释与鲁棒性:错误分析 + 长度切片 + 遮挡显著性

# 自信的错误:概率>0.9但判错的样本,人工读20条
# 长度切片:按评论长度分桶看准确率,长评论是否掉点
# head vs tail 截断对比:同样 max_length 下只留头部 vs 只留尾部

词级遮挡显著性:逐个 mask 掉词,看预测概率掉多少,掉得多的就是关键词。实现简单但很说明问题——如果显著性最高的词是”电影院 popcorn”这种无关词,模型就是在走捷径。长评论实验通常显示:超长评论截断会丢信息,tail 截断(留结尾,影评结论常在结尾)有时优于 head,值得亲手验证。

步骤 6:半监督:无标签池 + 置信度伪标签

IMDB 自带无标签评论池。用当前最优模型打伪标签,只保留高置信度(如 >0.95)的样本并入训练集,再训一版对比:

# 伪代码
probs = predict(unlabeled_pool)
keep = probs.max(-1) > 0.95
aug_train = train + pseudo_labeled(keep)
retrain_and_compare(aug_train)

小数据场景下这招常有惊喜;数据充足时提升有限,权衡复杂度再决定是否保留。最后合并 LoRA 适配器存盘:

merged = model.merge_and_unload()
merged.save_pretrained("imdb-sentiment-final")
tok.save_pretrained("imdb-sentiment-final")

五、常见坑

坑 1:PEFT/torchao 版本打架。 装完先跑通最小 LoRA 前向,报错优先对齐这两个包的版本,而不是怀疑代码。

坑 2:HTML 标签没洗。 <br /> 进 TF-IDF 会变成高权重”特征”,进 BERT 会浪费 token,清洗函数要在基线和微调两边共用。

坑 3:训练测试重复泄露视而不见。 先做 overlap 检查再报分数,否则 93% 里可能有水分。

坑 4:只报准确率。 类别稍不平衡准确率就骗人,macro-F1 + AUC + 混淆矩阵是标配。

坑 5:截断策略不做对比。 长评论 head/tail 效果可能差 1 个点,花 10 分钟跑个对比再定 max_length 和截断方向。

坑 6:伪标签阈值太低。 低置信度伪标签等于把噪声喂回去,阈值宁高勿低,并对比基线确认真有提升。

进阶:温度缩放完整实现与可靠性曲线

校准一节值得展开成可跑代码。温度缩放(temperature scaling)是最常用的事后校准:在验证集上拟合一个标量 T,用 softmax(logits / T) 重算概率,不改变预测类别(准确率不动),只修置信度。

import torch
import torch.nn.functional as F
from scipy.optimize import minimize_scalar

logits = torch.tensor(val_logits)          # 验证集 logits
labels = torch.tensor(val_labels)

def nll(T):
    return F.cross_entropy(logits / T, labels).item()

res = minimize_scalar(nll, bounds=(0.05, 10.0), method="bounded")
T = res.x
print("最优温度 T =", round(T, 3))
calibrated = F.softmax(torch.tensor(test_logits) / T, dim=-1).numpy()

T > 1 说明模型原本过度自信(常见),T < 1 说明过于保守。校准前后各算一次 ECE、各画一条可靠性曲线:横轴置信度分桶、纵轴桶内准确率,完美校准就是对角线。验收标准:准确率不变、ECE 明显下降、高置信桶(0.9~1.0)的准确率与置信度对齐。

可靠性曲线的读法:曲线长期在对角线上方=过度自信(说的比做的夸张),长期在下方=保守。只看 ECE 平均数会掩盖”高置信区崩了、低置信区拉回平均”的结构问题,所以曲线和数字要一起看。

半监督部分补一个工程细节:伪标签要分层采样——正负类按同等置信阈值各取 Top-K,避免多数类滚雪球。迭代超过 2 轮收益通常归零,还放大噪声,见好就收。

附:超参对照表与实验记录模板

LoRA 微调超参建议起点(DistilBERT + IMDB 级别任务):秩 r 取 8~16,alpha 设为 r 的 2 倍,dropout 0.05~0.1,目标模块为注意力 q_lin/v_lin,学习率 1e-4~2e-4,batch 16~32,epoch 3~5 配早停 patience 2,max_length 256 起步、长文本对比 512。学习率是最敏感的旋钮:loss 震荡先降学习率,收敛太慢再升;r 翻倍通常只换零点几个点,优先调学习率和数据。

每次实验记一行记录:日期、数据规模、r/alpha/学习率/batch、验证集准确率/macro-F1/AUC/ECE、备注(如”tail 截断””加伪标签 2k”)。没有实验记录的调参等于没调——三天后你完全想不起哪个组合最好。最终报告按”基线→微调→校准→鲁棒→半监督”的顺序贴五张表:总体指标表、混淆矩阵、ROC、可靠性曲线、长度切片表,结论写三句话:涨了多少、为什么涨、什么时候会崩。能回答”什么时候会崩”的模型,才是敢上线的模型。

常见问答

问:基线比微调还高怎么办? 先恭喜,你省了 GPU 钱。检查三件事:微调是否欠拟合(学习率太小、epoch 不够)、数据是否有泄露特征被基线利用、评测划分是否合理。小数据、短文本、领域词明显的任务,TF-IDF 本来就很强,微调的增益天然有限。

问:单卡显存不够怎么办? 四档降级:减小 batch 配梯度累积、max_length 从 512 降到 256、先抽子集调通再放全量、r 从 16 降到 8。LoRA 本来就很省,DistilBERT 级别模型在 8G 显存卡上通常能跑 batch 16。

问:训练集准确率 99% 但测试不行? 典型过拟合或泄露。看早停是否触发、验证曲线是否先升后降;再查训练测试 overlap 和奇怪的高权重词。IMDB 这种干净数据集上,真过拟合多半是 epoch 太多或学习率太大。

问:伪标签没提升正常吗? 正常。数据充足时半监督增益本就微弱,它的甜点是”标注少、无标签多”。没提升就砍掉这个环节,复杂度也是成本。保留的前提永远是:对比实验证明它赢了基线。

问:怎么证明我的结论可复现? 固定种子、记录 transformers/peft/datasets 精确版本、存下数据抽样子集的索引、超参记实验表。四样齐了,别人按你的记录能跑出 ±0.3% 以内的结果,才算数。

问:DistilBERT 和 BERT/RoBERTa 怎么选? 数据 5 万条以下、分类任务单一,DistilBERT 足够,训练快一半。数据上十万、长难句多、类别细,再上 RoBERTa 或 DeBERTa。选型逻辑和基线一样:先便宜后昂贵,涨点配得上账单才换。

问:中文情感分析照搬这套行吗? 流程照搬,模型换中文 backbone(如 bert-base-chinese 或中文蒸馏版),分词器对应换,TF-IDF 改用 jieba 或字符级 n-gram。校准、可解释、伪标签环节与语言无关,直接复用。

问:类别不平衡(如好评远多于差评)怎么办? 三招按顺序试:评估先换 macro-F1 别看准确率;训练加 class_weight 或过采样少数类;阈值按 F1 峰值重选而不是 0.5。IMDB 是平衡集所以教程没展开,真实业务数据第一步永远是先看类别分布。

问:训练要多久、花多少钱? 参考量级:子集 5k 调通全流程约十几分钟(T4);全量 25k、5 epoch、LoRA r=16,T4 约 1~2 小时。先子集后全量,先短序列后长序列,钱和时间都花在刀刃上。

问:测试集也要做数据审计吗? 要,而且和训练集同等严格。测试集泄露、标签错误会直接污染你所有的结论。发布任何分数前,先回答三个问题:测试集类别平衡吗?和训练集有重叠吗?抽查 20 条标签都对吗?答不上来,分数先别往外发。

六、总结

这条链的完整顺序是:审计(平衡/长度/重复/HTML)→ TF-IDF 基线(定参照线 + 看关键词)→ DistilBERT+LoRA 微调(r=16上下,Trainer+早停+混合精度)→ 评估(准确率/macro-F1/AUC/混淆矩阵/ROC)→ 阈值与校准(ECE+可靠性曲线+温度缩放)→ 可解释与鲁棒(自信错误/长度切片/遮挡显著性/截断对比)→ 半监督伪标签 → 合并存盘。基线告诉你”值不值得”,校准告诉你”敢不敢信”,可解释告诉你”它到底学了什么”。按这个顺序走一遍,你交出的就不只是一个准确率数字,而是一份站得住脚的实验报告。 点击阅读原文

参考资料:https://www.marktechpost.com/2026/08/09/imdb-sentiment-analysis-with-distilbert-lora-tf-idf-baselines-calibration-interp 点击阅读原文

© 版权声明

相关文章

暂无评论

暂无评论...