ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

BERT微调与WeiboSenti100k中文情感分析最小可行系统

BERT微调与WeiboSenti100k中文情感分析最小可行系统 简介一套面向计算机科学与技术、人工智能专业学生的中文情感分析系统实现方案基于WeiboSenti100k微博评论语料与BERT预训练模型微调而成系统设计在课设与毕设场景中具有较强参考性。内容涵盖完整模型微调源代码、训练与推理脚本、依赖清单、基准数据集及操作说明文档能够支撑从环境搭建、数据预处理到模型训练、效果评估的全流程实践便于使用者系统掌握基于深度学习的文本情感分类技术。压缩包为zip格式共7个文件以Python脚本、CSV数据集、Markdown文档和TXT配置文件为主整体约19.44MB模块划分清晰可按需单独查看训练、推理或说明部分。方案在校内学术评审中获98分并得到教师认可可作为课程设计或毕业设计高质量范本。目前已有28人学习适合希望提升NLP实战能力、理解BERT微调机制的中高级学习者。1. 标题在讲什么BERT微调 WeiboSenti100k 为什么是中文情感分析的“最小可行方案”很多人拿到中文情感分析需求第一反应是直接上一个 qwen 类大模型做微调等看到显存账单和推理延迟才反应过来单条微博的情感判断并不需要那么大的底座。基于BERT微调与WeiboSenti100k数据集的中文情感分析系统恰恰是在效果、成本、可复现性之间最平衡的一条路。我顺着“源码及实现方案”这几个字往下拆数据清洗、标签切分、模型结构、微调参数、推理接口以及最容易翻车的那几个坑。这套方案适合刚入门的算法新人也适合预算有限但想上线模型的业务团队。代码可以当成骨架去改参数按你的数据量再调下面每一章都能让你拿着直接跑。2. 把 WeiboSenti100k 整理成可训练格式标签口径、清洗与切分动手写模型之前先把数据当成“黑匣子”打开看一眼。很多情感分析项目效果差问题不在 BERT 微调本身而是喂进去的 WeiboSenti100k 根本没有清洗干净标签口径也没对齐模型学到的全是噪声。2.1 先搞清 WeiboSenti100k 的标签口径再谈训练WeiboSenti100k 名字里的 100k指大约 10 万条带情感标注的中文微博文本。它最常见的组织方式是“一行文本 一个情感标签”标签体系一般有三种正面、负面、中性。也有一些镜像或二次整理版本只保留正负二分类把中性样本直接丢掉。这不是问题问题是你要先确认自己手里是哪一版再去定模型的输出维度。我一般会在项目最开始做一次标签分布统计。You get something like this:import pandas as pd df pd.read_csv(weibo_senti100k.tsv, sep\t, names[label, text]) print(df[label].value_counts(normalizeTrue))如果看到正、负、中占比接近 1:1:1训练起来会省很多事如果发现正样本占了 70%后面就要考虑类别权重或者采样策略。另一个容易踩的坑是标签噪声微博文本里“哈哈”有时标正面有时标中性这不是数据集的错而是标注者主观判断差异。遇到这种情况我一般不会花时间去“清洗标签”而是靠损失函数的鲁棒性去容忍它具体做法放在第 3 章。# 执行完上面代码注意输出里最小类别的占比 # 如果小于 15%后续 train/val 划分必须做分层抽样2.2 一次只干一件事清洗 URL、用户、emoji 的预处理函数微博文本和干净新闻语料不一样里面塞满了 URL、用户名、话题标签、emoji、HTML 实体。这些噪声对 BERT 来说不是“无用”而是“有害”——它会把注意力权重分到没有语义的 token 上尤其当 emoji 本身被拆成多个连续 token 时输入长度很快就会被撑满。我常用的是一个很朴素的清洗函数每一步只干一件事import re emoji_pattern re.compile( [\U0001F600-\U0001F64F \U0001F300-\U0001F5FF \U00002600-\U000027BF \U0001F900-\U0001F9FF \U00002000-\U0000207F \uFE00-\uFE4F ], flagsre.UNICODE, ) def clean_weibo(text: str) - str: text re.sub(rhttps?://\S, , text) # 去掉链接 text re.sub(r\w, , text) # 去掉用户昵称 text re.sub(r#\w#, , text) # 去掉话题标签 text re.sub(r\[\w\], , text) # 去掉 [拜拜] 这类占位颜文字 text re.sub(r[a-zA-Z];, , text) # 去掉 amp; 等 HTML 实体 text text.replace( , ) # 全角空格转半角 text re.sub(r\s, , text).strip() return text df[text_clean] df[text].map(clean_weibo)这段代码有三个关键点。第一是顺序先去掉链接和用户昵称再做空白归一化否则\s可能把去掉链接后留下的换行符和空格挤成某个位置影响后续 tokenizer 对位置的感知。第二是刻意不在这里做 emoji 全量删除只处理了常见的 emoji 区块如果你确定业务场景里 emoji 对情感判断有正向作用保留反而更好。第三是“每步只干一件事”不要把正则合并成一行炫技否则后面排查脏数据时会很痛苦。2.3 按 label 分层切分 train/dev/test别让同一条文本“串通”切分看起来是三行代码的事但这里有两个容易翻车的地方。第一是数据集中可能存在完全重复或高度相似的文本比如同一条微博被不同用户转发如果这些相同文本同时落在训练集和验证集验证指标会虚高上线后立刻打回原形。第二是标签分布不能只在大盘上看切分后的每一份里都要尽量保持三类比例一致。常见做法是先用“文本去重”拿到唯一样本再做分层抽样from sklearn.model_selection import train_test_split df df.drop_duplicates(subset[text_clean], keepfirst) X df[text_clean] y df[label] # 第一次切分分出测试集比例固定为 10% X_train, X_test, y_train, y_test train_test_split( X, y, test_size0.1, stratifyy, random_state42, ) # 第二次切分从剩余数据里再分出验证集比例是 10%/(90%) * 100 ≈ 11.1% X_train, X_dev, y_train, y_dev train_test_split( X_train, y_train, test_size0.111, stratifyy_train, random_state42, ) print(len(X_train), len(X_dev), len(X_test))stratifyy的意思是让每一份子集里 labels 的分布尽量接近原始分布。随机划分很容易让占比很低的某个类别在验证集里只出现十几条导致验证 F1 波动很大。random_state 如果固定成 42实验结果可以被重复换随机种子跑一遍如果 F1 差得特别多就要怀疑是数据里有离群样本而不是模型不稳定。注意如果业务数据里存在“同一条新闻由不同账号发布”的情况单纯对 text 去重还不够最好再加上“按归一化后的文本长度和关键词做去重”。这里 10 万条微博规模不大靠subset去重已经够用。3. 基于BERT微调的中文情感分析模型选型、分类头与损失函数数据准备好之后接下来就是把 WeiboSenti100k 喂给 BERT 做微调。这一章看起来在讲代码其实真正决定效果的是“为什么选 BERT”以及“分类头怎么设计”。3.1 为什么是 BERT 而不是更大的模型一张表看懂显存、收益和成本我见过不少团队一上来就提 qwen2.5-7b 微调甚至要 lora微调 7B 模型做情感分类。等环境配完、训练跑起来才发现单卡根本放不下只能回到小模型。其实对中文情感分析这种短文本任务BERT-base 是比大模型更“经济”的选择。方案参数量量级单卡 16G 训练单条推理延迟适合场景bert-base-chinese 全参数微调约 1.1 亿可以batch 约 16–32毫秒级微博/评论情感要求上线快、成本低lora微调 qwen2.5-7b约 70 亿只更新低秩参数勉强通常要 4bit 量化百毫秒级复杂语义、隐式情感、情感原因抽取qwen3 0.6b 级小模型微调约 6 亿可以但要先做 tokenizer 适配中等想要更强语义理解又能接受部署体积BERT 用的是 encoder-only 双向结构输入序列里每个 token 都能看到前后文。情感分类本质上是“综合全文判断倾向”双向编码天然占优。生成式模型虽然理解能力更强但要做分类还得额外设计 prompt 和解析输出在 10 万条数据规模下性价比明显偏低。lora微调 也不是不能用但那是另一个工作量和部署成本如果你手里已经是 WeiboSenti100k 这种带明确标签的数据集优先把 BERT 微调跑通再考虑是否扩大模型。3.2 自定义分类头BertModel 的三分类 forward 路径transformers里直接提供了BertForSequenceClassification但我更常自己包一层BertModel 分类头。原因有两个一是可以控制 dropout 位置二是方便以后加多任务头。下面是这个系统里最核心的模型定义import torch from torch import nn from transformers import BertModel class BertSenti(nn.Module): def __init__(self, pretrained: str bert-base-chinese, num_labels: int 3, dropout: float 0.2): super().__init__() self.bert BertModel.from_pretrained(pretrained) self.dropout nn.Dropout(pdropout) self.classifier nn.Linear(self.bert.config.hidden_size, num_labels) def forward(self, input_ids, attention_mask, token_type_idsNone): outputs self.bert( input_idsinput_ids, attention_maskattention_mask, token_type_idstoken_type_ids, ) # pooler_output 是句子维度的语义向量 pooled outputs.pooler_output logits self.classifier(self.dropout(pooled)) return logits这段代码里最关键的是pooler_output。.bert的 base 模型会返回 12 层编码结果pooler_output是在[CLS]token 表示之后再接一层 tanh 得到的向量专门用来代表整个句子。直接把它交给一个线性分类头就是最常见的 BERT 微调结构。在这里num_labels3对应 2.1 里说的正、负、中三类。如果你手里的 WeiboSenti100k 是二分类版本把num_labels改成 2 即可。dropout 设 0.2 是经验值对 10 万条数据来说不会太强也不会欠拟合。提示不要用outputs.last_hidden_state[:, 0]替代pooler_output。虽然[CLS]位置也能用但对于情感分类这种句子级任务pooler_output经过了一层非线性投影实践里表现更稳。3.3 损失函数与类别权重防止预测结果一边倒很多分类项目默认用CrossEntropyLoss完事但中文情感分析数据通常不是均分的。你拿到的 WeiboSenti100k 可能正样本多、负样本少也可能反着来。平衡类别最简单也最有效的方法是给损失函数按类别频率加权import numpy as np from sklearn.utils.class_weight import compute_class_weight classes np.array([0, 1, 2]) weights compute_class_weight(class_weightbalanced, classesclasses, yy_train.tolist()) criterion nn.CrossEntropyLoss( weighttorch.tensor(weights, dtypetorch.float32) ) # 大致含义样本数越少的类别weight 越大总和会归一化 print(weights)compute_class_weight的计算逻辑是N / (K * N_class)比如总数 9 万、三个类别如果正样本 6 万那么正样本权重就是9e4 / (3 * 6e4) 0.5负样本 1.5 万就得到 2.0。这样模型在更新参数时每条负样本带来的 loss 贡献更大预测就不会明显偏向多数类。值得强调的是类别权重只解决“整体偏置”不解决“单个样本噪声”。如果你发现训练 loss 一直在降但验证 F1 卡在某个值不动很大概率是某些标注本身有歧义而不是权重没调好。这时可以用标签平滑替代 hard label比如把CrossEntropyLoss里的 target 改成(1 - epsilon) * one_hot epsilon / num_classes让模型不要过分自信。我一般只在验证 F1 已经稳定但预测结果过于极端时才去加这一层。4. 训练脚本与微调参数详解跑通循环并保住最优 checkpointB站上关于“环境配置 模型微调”的教程很多但真正让工程师卡住的总是一些细参数。这一章把训练流程拆成三段讲数据封装、训练循环、断点保存。4.1 最小可运行的数据集封装与训练循环先写一个 PyTorch Dataset。这里要处理两件事一是把文本转成 token IDs二是控制每个样本的长度。微博文本长短差异很大短的只有 5 个字长的可能超过 300 字如果全部按“不截断”去做一个 batch 里的 padding 会极大浪费显存。from torch.utils.data import Dataset from transformers import BertTokenizer tokenizer BertTokenizer.from_pretrained(bert-base-chinese) class WeiboSentiDataset(Dataset): def __init__(self, texts, labels, tokenizer, max_len128): self.texts texts self.labels labels self.tokenizer tokenizer self.max_len max_len def __len__(self): return len(self.texts) def __getitem__(self, idx): enc self.tokenizer( self.texts[idx], max_lengthself.max_len, truncationTrue, paddingmax_length, return_tensorspt, ) return ( enc[input_ids].squeeze(0), enc[attention_mask].squeeze(0), torch.tensor(self.labels[idx], dtypetorch.long), )这里paddingmax_length是直接 pad 到 128虽然会增加 tokenizer 耗时但对 10 万条数据规模来说完全可接受。如果你后面把数据量扩到百万级可以改成paddinglongest或者pytorch DataLoader里动态 padding能省 20% 左右的显存。truncationTrue表示超过 max_len 的部分直接截掉具体怎么截断更合理我在第 5 章的坑位里会专门讲。再写训练循环from transformers import AdamW from torch.utils.data import DataLoader from tqdm import tqdm train_loader DataLoader( WeiboSentiDataset(X_train.tolist(), y_train.tolist(), tokenizer), batch_size32, shuffleTrue, ) model BertSenti(num_labels3).cuda() optimizer AdamW(model.parameters(), lr2e-5) for epoch in range(10): model.train() loop tqdm(train_loader, descfepoch {epoch}) for batch in loop: input_ids, attention_mask, labels [x.cuda() for x in batch] outputs model(input_ids, attention_mask) loss criterion(outputs, labels) optimizer.zero_grad() loss.backward() # 梯度裁剪防止个别长文本样本造成梯度爆炸 torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) optimizer.step() loop.set_postfix(lossloss.item())lr2e-5是 BERT 微调最常用的起点。对中文情感分析这种任务学习率超过 5e-5 之后很容易出现“训不收敛、验证集飘”的情况。clip_grad_norm_设成 1.0能让长文本样本的极端梯度不至于把分类头带偏。如果你用的是新版transformersAdamW可以直接从torch.optim引入并使用schedule...但上面这种写法兼容性最好。4.2 微调参数怎么配lr、warmup、batch_size、max_len 的一个稳妥组合下面这组参数不是标准答案但它是跑 WeiboSenti100k 这类 10 万短文本样本的稳妥起点参数取值说明learning_rate2e-5BERT 全参数微调最常见区间batch_size32单卡 16G 可跑动态 padding 可尝试 48max_len128覆盖 90% 以上微博长度又不会太耗显存warmup_ratio0.1前 10% steps 线性升温稳定训练num_epochs4–610 万条数据通常 4 个 epoch 就够weight_decay0.01对分类头效果明显对 BERT 主体影响不大warmup 这件事值得多说一句。BERT 的预训练分布已经很平滑直接用一个较大的学习率去更新前几个 step 容易把预训练权重冲偏。常见做法是前 10% 的 step 让学习率从 0 线性涨到目标值后面再线性衰减。上面训练循环里没写 scheduler实际项目里我会加一行from transformers import get_linear_schedule_with_warmup total_steps len(train_loader) * num_epochs scheduler get_linear_schedule_with_warmup( optimizer, num_warmup_stepsint(0.1 * total_steps), num_training_stepstotal_steps, ) # 每个 optimizer.step() 之后调用 scheduler.step()如果你只跑 2–3 个 epochwarmup 不设也问题不大但如果跑 6 个 epoch 以上不设 warmup 的验证曲线会明显多出前期震荡期。4.3 用早停和验证集挑选 checkpoint不要迷信“最后一轮”训练情感模型最容易犯的错是把最后一个 epoch 产生的权重当成最优模型。实际上下坡路上最后一轮可能已经开始过拟合。最好的办法是每个 epoch 结束都跑一遍验证集记录 F1然后只保留验证集上最好的那一次。best_score 0.0 best_state None patience 2 # 连续 2 个 epoch 没有提升就停 for epoch in range(num_epochs): train_one_epoch(...) val_f1 evaluate(model, val_loader) if val_f1 best_score: best_score val_f1 best_state {k: v.cpu().clone() for k, v in model.state_dict().items()} patience 2 else: patience - 1 if patience 0: break上面这段写的是“检查 best_state”实际工程上我会直接做torch.save(model.state_dict(), best.pt)然后把best_score和对应的 epoch 等信息打印到日志里。等到推理阶段统一加载best.pt不要用最后 epoch 的模型。这里的踩坑血泪经验是有时候验证 F1 最高只出现在训练中段之后两三个 epoch 模型就开始“飘”所以 patience 给 2而不是死守满跑 10 个 epoch。5. 中文情感分析微调的常见问题跑挂之后这样排查下面四条问题是我做 BERT 微调情感分析时遇到过的真实翻车场景。每一条都按“现象 → 原因 → 解决”写方便你直接对照。5.1 现象训练 loss 掉到 0.02验证 F1 却一直上不去训练集 loss 一路下行甚至在验证集上预测时所有样本都给了同一个标签。我先看到这个结果的第一反应是“模型学到了一个简单粗暴的捷径”。原因通常是数据切分里出现了泄漏比如相同文本既进了训练集又进了验证集或者标签分布严重失衡导致模型只学多数类。解决方法是先回到第 2.3 步确认去重和分层抽样是否真的生效。然后把验证集单独跑一次分类打印classification_report如果某一类的 precision 是 0就说明这一类的样本数量太少。我的习惯是把少数类做最简单的复制采样只在训练集做不在验证集和测试集做这样验证指标不会被污染。如果仍然不行就在损失函数上叠加类别权重见 3.3。5.2 现象测试集长文本全部被分到“中性”测试时看到超过 100 字的样本几乎都预测成中性短文本反而交替出现正负。这常常是max_len太小惹的祸。BERT 的 tokenizer 从第一个 token 开始截断微博情感词如果出现在句子后半段截断后模型根本看不到。解决方法是先统计一下语料长度分布比如用df[text_clean].map(lambda x: len(x)).quantile(0.95)把 95% 分位长度作为max_len。对微博文本来说 128 基本够但如果你用的是评论或长帖数据建议提到 192 或 256。另一个更稳的做法是截断策略改为truncationonly_second但单文本输入没有第二段这时可以考虑截掉句子开头部分保留结尾。不过一般还是优先提高max_len不要为了省显存牺牲长样本。5.3 现象同一句话改一下标点就翻盘预测不稳定“你可以这样但不能那样”和“你可以这样。但不能那样”差一个标点模型从一个类跳到另一个类。这其实是 BERT 对位置鲁棒性的老问题。最容易导致这种事情的原因有两个一是训练时候开了shuffleTrue且没有固定随机种子dropout 的随机性让同一个样本在不同前向里输出不同二是句子本身情感信号本来就弱分类边界处模型是在“夹缝中赌”。我的做法是在推理阶段设置确定性import torch torch.manual_seed(42) torch.cuda.manual_seed_all(42) torch.backends.cudnn.deterministic True然后把模型切成eval()保证 dropout 关闭。做完这一步仍然轻微不稳可以在多次预测上做投票但对情感分析来说更实用的策略是训练时保留一部分“带争议”的样本作为中性类让模型不要被迫在边界处做非此即彼的判断。业务上如果一定要稳定就用“soft label”把标注置信度存下来。5.4 现象batch_size 降到 8 仍然显存溢出很多同学以为是模型太大实际大部分显存都被“长样本 padding”吃掉了。BERT 的显存占用和序列长度平方成正比把一个 300 字的样本 padding 到 512再和一堆短样本塞进同一个 batch注意力矩阵会直接爆开。解决方法有三个。第一是用动态 padding把每个 batch 的长度单独适配而不是全局固定到 max_len 的长度。第二是梯度累积保持较大的“等效 batch”但一次只算一个小 batch 的梯度# 每 4 个小 batch 做一次参数更新调低显存峰值 # 对应代码是 loss.backward() 之后不要立刻 optimizer.step() # 等累积到 accumulation_steps 再统一更新第三是看一眼 tokenizer 的pad_token_id设置如果 pad token 没有设成[PAD]模型同样会把 padding 位置当成真实 token 做注意力计算显存和语义都会受影响。正常做法是tokenizer.pad_token tokenizer.eos_token或直接使用中文 BERT 对应 tokenizer 里的[PAD]。6. 进阶把分类概率校准成可用的情感评分接口模型在最后一层输出 logitssoftmax 之后得到一个 0 到 1 之间的概率。但你不能直接把“正例概率 0.72”当成用户情感强度因为 BERT 微调后的概率分布往往过度自信。常见做法是把概率映射成 1 到 5 星的情感分让运营和产品都看得懂。我习惯先在验证集上算一次概率分位数而不是自己拍脑袋定阈值import numpy as np # val_pred_probs 是模型在验证集上的正例概率 quantiles np.quantile(val_pred_probs, [0.2, 0.4, 0.6, 0.8]) def prob_to_star(prob, q1, q2, q3, q4): if prob q1: return 1 if prob q2: return 2 if prob q3: return 3 if prob q4: return 4 return 5这样选阈值的好处是四个档位分别对应验证集里 20% 的样本能保证评分分布更接近真实业务。如果业务方只关心“负面检测率”阈值可以再往右调把 1 星的区间放宽如果关心召回就反向调。每次调完阈值都回到验证集上看新评分和 label 的 Spearman 相关而不要只看分类准确率。最后一个技巧是给推理接口加一个temperature参数用于校准置信度。常见做法是训练完后在验证集上学一个 0–1 之间的温度值用它除 logits 再 softmax但只有 logits 分布特别极端时才值得做。多数情感分析项目用到分位数映射已经够用。我的习惯是把“概率阈值”和“情绪强度”这两件事彻底拆开分类器负责给出概率业务接口负责决定怎么用概率。这样模型升级时阈值脚本不用跟着模型一起改。希望帮到你也祝你手上的中文情感分析系统少踩几个坑。本文还有配套的精品资源点击获取
返回列表