
简介面向中文微博短文本情感二分类任务基于WeiboSenti100k数据集的bert-base-chinese微调项目适合自然语言处理方向的毕业设计、课程设计以及入门级NLP实践。压缩包共5个文件大小9.73MB包含两个Python源码文件、一个依赖清单、一个Markdown说明文档和一个CSV格式的情感标注数据集整体结构紧凑便于快速下载与本地复现。源码中区分了训练与推理两个环节训练脚本负责读取微博语料、完成数据预处理并微调预训练模型推理脚本加载保存的模型权重实现对自定义句子的情感判别。依赖清单明确第三方库版本说明文档则给出整体目录结构、运行环境和实验步骤。结合附带的十万条带标签微博文本读者可完整走通数据清洗、分词、BERT中文模型微调、模型评估与预测的流程。目前已有103人学习浏览项目说明与代码组织清晰既便于快速复现情感分析实验也有利于针对数据增强、模型调参或跨域测试做进一步扩展。1. 为什么毕业设计选中情感分析微调选对模型比调参更重要中文情感分析在毕业设计里几乎是“常青树”每年都有一批人直接拿 pytorch 现成代码改一改就跑跑到最后发现训练过程像开盲盒。用 WeiboSenti100k 数据集聚类配合 bert-base-chinese 做微调本质上是把一件难的 NLP 任务拆成了三件相对独立的事微博短文本的清洗与标注理解、BERT 家族的中文分词与编码机制、以及微调时对学习率和训练轮数的掌控。对本科生或者刚上手 NLP 的开发者来说这个组合的好处是数据集规模适中、标签分布相对稳定、预训练权重可以直接从 HuggingFace 拉取不需要自己从零预训练。作为毕业设计题目它的技术深度足够答辩展示又不会让人卡在基础设施上就想放弃。很多同学一开始的误区是追求模型复杂度想去跑 中文大模型 或者多任务框架。其实在短文本情感分析这个任务上bert-base-chinese 微调已经能打到非常高的准确率再往上提升的性价比很低而 大模型微调 在这个任务上反而可能因为模型过大、数据量不够而产生过拟合。这篇笔记会从数据准备开始把整个微调链路拆开讲清楚包括每个参数的取值依据、训练过程中最常翻车的几个点以及最后导出模型时怎么避免被格式坑到。适合的目标读者是做课程设计、毕业设计的学生以及第一次接触 BERT 微调的工程初学者核心目的是让你照着做就能跑通而不是“看懂了但不会改”。2. 认识 WeiboSenti100k 与 bert-base-chinese数据集的坑和模型的边界2.1 WeiboSenti100k 长什么样为什么它适合作为微调起点WeiboSenti100k 是中文情感分析领域使用非常频繁的一个公开数据集整体规模在 10 万条左右样本来源于微博文本。每一条样本的标签是 0 和 1 两类0 代表负面情感1 代表正面情感不涉及中性类别这一点在做数据加载时非常关键直接决定了你构造分类头时输出维度是 2 而不是 3。数据集本身不是一个压缩包里的单一 CSV它通常以两个文件形式出现分别对应正向和负向样本也有整理好的合并版本。网上能搜到多种处理版本有的保持原始格式有的已经被前人转成了 HuggingFace 的 Dataset 格式但鉴于你手里这份项目说明写的是“源码项目说明”大概率是原始文本加加载脚本的结构因此在拿到文件后先做的事应该是确认文件编码和行格式而不是直接开始训练。数据结构上每条样本就是一个短文本字符串多数长度不超过几十个字这对 BERT 来说是相当友好的。因为 bert-base-chinese 的最大序列长度是 512而微博文本的绝大部分样本用 128 的截断长度已经能覆盖完整信息。使用 128 而不是 512 作为 max_length可以显著降低单次训练的计算开销同时不会损失有效信息。另外数据集的标签分布大致平衡正向和负向样本数量接近所以你不需要像处理长尾分类那样去设置复杂的类别权重。属性典型值对训练的影响样本总量约 10 万条适合单卡训练不需要分布式标签类型0/1 二分类分类头输出维度为 2平均文本长度30-60 字符max_length128 足够标签平衡度接近 1:1直接用 CrossEntropyLoss 即可2.2 bert-base-chinese 的分词机制为什么中文不能用空格切bert-base-chinese 是 Google 发布的针对中文的预训练模型其词表基于字级别构建也就是说它切分文本的最小单位是汉字而不是词。这与英文 BERT 的 WordPiece 方式不同中文用 WordPiece 时往往会切出很多单字碎片而 bert-base-chinese 直接在 vocab.txt 里包含了大量的汉字字符所以在使用时基本不需要显式分词直接把字符串喂给 tokenizer 就行。但这不等于说输入处理没有坑——你需要正确调用 tokenizer 的返回值并把 attention_mask 和 token_type_ids 都传到模型里。在 HuggingFace 的 transformers 库中bert-base-chinese 对应的 checkpoint 名称就是bert-base-chinese加载权重时可以直接通过AutoModelForSequenceClassification.from_pretrained来拉取。需要注意这个类在加载时要明确指定num_labels2否则默认值是 2但如果你从别人那拷贝的代码里写了别的数字会导致最后一层维度不匹配训练时直接报错。这种错误很常见而且报错信息藏在很后面的地方容易让人误以为是显卡显存不足。from transformers import BertTokenizer, BertForSequenceClassification model_name bert-base-chinese tokenizer BertTokenizer.from_pretrained(model_name) model BertForSequenceClassification.from_pretrained(model_name, num_labels2)这段代码的逻辑非常简单但有一个细节需要注意。BertForSequenceClassification在加载预训练权重时会自动在顶部替换为一个新的分类头分类头的输入维度对应 BERT 的 hidden_size也就是 768输出维度由num_labels控制。如果你在这里不显式传入num_labels可能默认值和你的数据标签数不一致后面计算 loss 时就会出现形状对不上的问题。另一个细节是from_pretrained第一次运行会从网络下载模型权重大概 400MB 左右国内网络环境不稳定时这一步很容易失败建议提前设好 HF_ENDPOINT 镜像或者手动下载后放到本地目录。2.3 微调区别于全量预训练只让模型适配微博语境这里要澄清一个概念微调不是重新训练 BERT 的语言模型能力而是保留它的语义编码能力只让最终分类结果适应当前任务的分布。在情感分析任务上BERT 底层的注意力机制已经能理解“这家店真难吃”这句话是负面的不需要再去教它语法和常识。微调需要做的是让顶部分类头学会从 768 维的 [CLS] 向量中提取情感线索。大模型微调 这个词最近热度很高但它的核心思想在 BERT 时代就已经存在。当你拿 10 万条微博去微调 bert-base-chinese 时你更新的是全部参数而不是像 adapter 微调那样只更新少量新增模块。全参数微调的好处是实现简单、效果上限高坏处是对显存有一定要求。如果是 1080Ti 或 3060 级别的显卡batch size 设置到 16 到 32 问题不大如果是在 Colab 上用免费的 Tesla T4建议把 batch size 降到 16 并开启梯度累积。从原理上讲训练过程中 loss 下降的曲线能直观反映模型是否在学习。情感分类任务在训练集上通常第一个 epoch 就能到 85% 以上的准确率这并不奇怪因为预训练模型本身已经很强。但这也是一个“陷阱”过早的高准确率可能让你误以为模型已经收敛实际它在验证集上可能还在缓慢提升需要跑够至少 3 个 epoch 才能得到稳定的结果。所以建议训练时不要频繁保存中间权重而是在每个 epoch 结束时记录验证集指标最后比较后再挑最优。常见的做法是保存最后一个 epoch 的权重但更靠谱的是用 early stopping 保存验证集上最好的一版。3. 从源码工程到可训练数据数据加载与预处理的两个必备脚本3.1 解压后先看目录结构把文本文件变成 Dataset拿到这份基于WeiboSenti100k 数据集bert-base-chinese微调的中文情感分析任务源码项目说明.zip解压之后不要急着打开训练脚本跑先看一下文件组织方式。常见的源码工程里会包含一个 data 目录存放原始文本或处理后的 CSV、一个 train.py 或 run.py、一个 evaluate.py 或 predict.py、以及一个 README 或项目说明文档。由于标题里特别带了“项目说明”说明作者有意识地补充了文档这对毕业设计答辩而言是加分项但对你复现来说最重要的是找到训练入口和数据路径变量。很多时候数据文件的格式并不理想原始微博文本里可能混有换行符、多余空格、URL 链接、用户 等噪声。虽然情感分析任务对这类噪声有一定容忍度但为了提高训练效率和稳定性还是建议做一次清洗。清洗的原则是去除 URL、去重、去除空行、统一标签与文本的对应关系。这里给一个可直接套用的预处理脚本骨架它读入两个文件正样本和负样本输出一个 CSV列名为 text 和 label。import pandas as pd def load_weibo_data(pos_path, neg_path): pos_df pd.read_csv(pos_path, headerNone, names[text], encodingutf-8) neg_df pd.read_csv(neg_path, headerNone, names[text], encodingutf-8) pos_df[label] 1 neg_df[label] 0 df pd.concat([pos_df, neg_df], axis0).sample(frac1, random_state42) df[text] df[text].str.replace(rhttp\S, , regexTrue) df[text] df[text].str.replace(r\w, , regexTrue) df[text] df[text].str.strip() df df[df[text].str.len() 0] return df df load_weibo_data(data/positive.txt, data/negative.txt) df.to_csv(data/all.csv, indexFalse, encodingutf-8)这段代码的逻辑解构如下。pd.read_csv指定headerNone因为原始微博数据通常是纯文本行没有表头。正负样本合并后做了一次sample(frac1)目的是打乱顺序避免训练时标签分布存在前后聚集。随机种子固定为 42方便复现。两个正则替换分别处理 URL 和 用户名这是微博文本最常见的噪声。最后一步过滤掉清洗后变空的文本防止后续 tokenizer 得到空字符串导致 batch 维度异常。encodingutf-8必须显式指定否则在 Windows 中文环境下可能以 GBK 编码读取直接抛出 UnicodeDecodeError。3.2 切分训练集和验证集用 sklearn 而不是手动掰数在准备好 CSV 之后需要把它切分成训练集和验证集。很多新手图省事直接按前 80% 后 20% 的顺序切遇到原始数据顺序混乱时训练集和验证集的分布可能差异很大。更稳的做法是用 sklearn 的train_test_split它内部实现了分层抽样保证训练集和验证集的正负比例与全量数据接近。这条经验在毕业设计里非常实用能让你的验证集指标更有说服力。from sklearn.model_selection import train_test_split df pd.read_csv(data/all.csv) train_df, valid_df train_test_split( df, test_size0.2, random_state42, stratifydf[label] ) print(train size:, len(train_df), valid size:, len(valid_df)) print(train_df[label].value_counts()) print(valid_df[label].value_counts())stratifydf[label]是这里的关键参数。它强制切分后的子集中正负样本的比例与原数据集一致。对于 WeiboSenti100k 这种二分类数据比例为 1:1不分层切分理论上问题不大但万一某个版本的数据分布略有倾斜分层切分能当作保险。打印value_counts的目的不是展示而是确认切分没有异常比如验证集只有几十条那说明传入的文本变量和标签变量没有对齐多半是索引重置没做。3.3 构造 PyTorch Dataset 与 DataLoader把 tokenizer 的输出缓存下来数据切分完毕后下一步是构造 Dataset。这里有一个容易被忽视的优化点tokenizer 的转换过程非常耗时尤其是序列化处理时10 万条数据逐个调用 tokenizer 会比较慢。如果显存不太紧张建议在构造 Dataset 时就完成文本到 input_ids 的转换并缓存到内存里而不是每次迭代时实时转换。下面是一个标准写法同时兼容验证集的评估需求。import torch from torch.utils.data import Dataset class WeiboDataset(Dataset): def __init__(self, texts, labels, tokenizer, max_len128): self.texts texts.reset_index(dropTrue) self.labels labels.reset_index(dropTrue) self.encodings [] for text in self.texts: enc tokenizer( text, truncationTrue, paddingmax_length, max_lengthmax_len, return_tensorspt, ) self.encodings.append(enc) def __len__(self): return len(self.labels) def __getitem__(self, idx): enc self.encodings[idx] return { input_ids: enc[input_ids].squeeze(0), attention_mask: enc[attention_mask].squeeze(0), labels: torch.tensor(self.labels[idx], dtypetorch.long), }这段代码有三个参数值得解释。truncationTrue表示超过 max_len 的部分被截断对微博短文本来说极少触发但设置了才安全。paddingmax_length表示把短的样本补齐到统一长度这会让每条样本统一变成 128 个 token便于拼成 batch。return_tensorspt让 tokenizer 返回 PyTorch 张量。构造时把所有样本的编码一次性算完并保存训练时 DataLoader 只需要做索引和聚合速度会快很多。代价是内存占用会随数据量线性增长如果你的机器只有 8GB 内存这个写法需要谨慎可以改成实时编码但对 10 万条级别的数据实时编码的开销通常也可以接受。4. 核心微调训练闭环参数设置、训练循环与 loss 判断4.1 训练参数的选择依据学习率、batch size、epoch 之间的平衡训练参数是整个微调过程中最玄学的部分很多人直接照搬别人代码里的数值跑出来效果不稳定就开始怀疑数据。bert-base-chinese 微调的常见学习率范围是 2e-5 到 5e-5这个量级比从头训练的 1e-3 要小得多原因是预训练权重已经处在比较平滑的损失曲面区域过大的学习率会让权重跳出原有语义分布导致灾难性遗忘。我在实际项目里一般会用 2e-5 作为起点它通常能保证收敛稳定。batch size 的选择主要受限于显存。bert-base-chinese 模型的参数量在一亿级别fp32 训练时一条长度为 128 的样本大约会占用十几 MB 的显存包括中间激活值。以 16 的 batch size 计算单次前向和反向过程大概需要 2GB 到 4GB 显存大多数消费级显卡都能承受。如果你的显存只有 4GB把 batch size 降到 8同时开启梯度累积模拟出 batch size 为 16 的效果。epoch 一般设置在 3 到 5 之间。微调的第一轮就已经能接近最终效果的 90%第二轮和第三轮是精调边界继续往上跑容易出现过拟合。参数名推荐值调整倾向learning_rate2e-5验证集波动大时降到 1e-5batch_size16显存不足时降到 8num_epochs3验证集仍在上升时加到 5warmup_ratio0.1数据量大时可调低weight_decay0.01不一定需要改动warmup 机制在微调里比较重要。它让学习率在前 10% 的迭代步数中从接近 0 慢慢上升到设定值可以避免训练开始时 loss 突然飙升。程序实现可以直接用transformers自带的get_linear_schedule_with_warmup函数不需要手写调度器。另一个容易被忽略的参数是weight_decay对 BERT 微调来说0.01 是常见默认值它只作用于模型权重不影响 bias 和 LayerNorm 参数这种精细化控制在 transformers 的 AdamW 实现里是默认行为。4.2 最小可跑的微调脚本直接复制的训练循环下面这段代码是微调的核心训练环节也是整篇笔记里可以直接拿走去用的部分。它省略了模型保存的细节聚焦在训练循环本身方便看清梯度更新和 loss 打印的节奏。from transformers import AdamW, get_linear_schedule_with_warmup from torch.utils.data import DataLoader from tqdm import tqdm train_loader DataLoader(train_dataset, batch_size16, shuffleTrue) valid_loader DataLoader(valid_dataset, batch_size32, shuffleFalse) optimizer AdamW(model.parameters(), lr2e-5, weight_decay0.01) total_steps len(train_loader) * num_epochs scheduler get_linear_schedule_with_warmup( optimizer, num_warmup_stepsint(total_steps * 0.1), num_training_stepstotal_steps ) device torch.device(cuda if torch.cuda.is_available() else cpu) model.to(device) for epoch in range(num_epochs): model.train() total_loss 0 for batch in tqdm(train_loader, descfepoch {epoch1}): batch {k: v.to(device) for k, v in batch.items()} outputs model(**batch) loss outputs.loss loss.backward() total_loss loss.item() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0) optimizer.step() scheduler.step() optimizer.zero_grad() avg_loss total_loss / len(train_loader) print(fepoch {epoch1}, avg loss: {avg_loss:.4f})这里的clip_grad_norm_是很多人会漏掉的一行。微调过程中偶尔会出现梯度异常大导致的 loss 爆炸这种爆炸通常发生在最后几层分类头上。把梯度的二范数限制在 1.0 以内可以防止单次更新覆盖掉预训练权重的大部分信息。model(**batch)这一步隐式完成了前向计算和 loss 计算因为BertForSequenceClassification内部已经包含了交叉熵损失传入labels后就会自动计算。如果你用AutoModel而不是AutoModelForSequenceClassification这里就需要手动接分类头和 loss代码复杂度直接上升这也是推荐用 ForSequenceClassification 类的原因。4.3 验证与保存取验证集最优模型还是最后一眼训练完成后需要在验证集上评估。常见做法是每个 epoch 结束时跑一遍验证集记录准确率或 F1。对二分类任务准确率已经能说明大部分问题但为了防止验证集上的偏差我建议同时输出混淆矩阵看看正负样本各自的误判率。下面这段代码是验证函数的标准写法可以直接复用。from sklearn.metrics import accuracy_score, f1_score, confusion_matrix model.eval() preds [] true_labels [] with torch.no_grad(): for batch in valid_loader: batch {k: v.to(device) for k, v in batch.items()} logits model(**batch).logits pred torch.argmax(logits, dim-1) preds.extend(pred.cpu().tolist()) true_labels.extend(batch[labels].cpu().tolist()) acc accuracy_score(true_labels, preds) f1 f1_score(true_labels, preds) cm confusion_matrix(true_labels, preds) print(faccuracy: {acc:.4f}, f1: {f1:.4f}) print(cm)验证阶段必须包在torch.no_grad()里面否则会保存整个计算图导致显存慢慢泄漏。preds.extend(pred.cpu().tolist())是为了把 GPU 上的张量转移到 CPU 并变成 Python 原生列表方便 sklearn 计算指标。混淆矩阵的维度是 2 乘 2左上角是真实负面预测负面右下角是真实正面预测正面如果两个数字差异过大说明模型对某一类存在偏见这种情况下直接看准确率容易被欺骗。模型保存的格式也值得注意。传统的做法是torch.save(model.state_dict(), model.pt)但这种方式只保存了权重不包含模型结构信息换环境后加载时容易出现 mismatch。更稳妥的是调用model.save_pretrained(saved_model)同时tokenizer.save_pretrained(saved_model)这样保存目录里会生成 config.json、pytorch_model.bin 和 vocab.txt后续加载只需要一行AutoModelForSequenceClassification.from_pretrained(saved_model)。5. 微调全程避坑指南数据、显存、训练效果的 5 个高频故障排查5.1 tokenizer 加载失败或模型权重下载断断续续现象运行BertTokenizer.from_pretrained(bert-base-chinese)时一直卡在下载阶段或者提示连接超时、SSL 错误。原因模型权重约 400MBHuggingFace 官方源在国内访问不稳定这个坑在初次跑环境时出现频率极高。解决设置环境变量export HF_ENDPOINThttps://hf-mirror.com或者从镜像站手动下载权重后把 checkpoint 路径改成你本地的目录名比如BertTokenizer.from_pretrained(./bert-base-chinese)。注意本地目录里必须包含 vocab.txt、config.json、pytorch_model.bin 三个文件缺一个都会报 loading 错误。5.2 训练到一半显存爆掉报 CUDA out of memory现象前几个 batch 很正常跑了几十步之后突然抛出显存不足损失函数出现 nan。原因可能是 batch size 设置过大也可能是在验证时忘了加torch.no_grad()导致验证过程中也建立计算图显存被前一步的训练激活值占满。解决先把 batch size 减小到 8 或 4解决当前显存不足问题。然后检查验证函数是否用了with torch.no_grad()。另外命令行用nvidia-smi -l 1观察显存占用曲线能快速定位是训练还是验证阶段导致的峰值。5.3 训练集准确率很高但验证集结果反而下降现象训练集准确率到第三个 epoch 已经接近 99%验证集准确率却从 91% 掉到 88%。原因典型的过拟合。微调小模型在 10 万条数据上通常到第二个 epoch 之后就开始出现轻微过拟合数据量更大时会更晚。解决不要机械地把 epoch 设成 5而是把学习率降到 1e-5 后再跑一轮看验证集是否回升。如果时间有限直接在训练过程中保存验证集准确率最高的权重而不是最后一个 epoch 的结果。torch.save每轮都写太占空间声明一个变量记录最佳指标只在超过时覆盖保存。5.4 标签维度对不上报 Expected input batch_size to match target现象loss计算时报错提示AssertionError或者形状不匹配。原因num_labels设置的不是 2或者 CSV 里的标签列不小心读成了 float 类型而模型的 target 期望是 long 类型。这类错误最隐蔽因为报错位置可能在 loss 函数内部而不是你脚本开头。解决打印train_dataset.labels.unique()看实际唯一值在__getitem__里构造labels张量时显式写torch.tensor(..., dtypetorch.long)不要用 Python 默认的 int 类型。另外如果 CSV 里标签是中文“正面”、“负面”需要先映射为 1 和 0。5.5 预测阶段与新数据的预处理方式与训练不一致现象训练时的准确率很高但用模型去预测一条全新的微博结果明显胡说八道。原因大概率是预测时没做文本清洗或 tokenizer 参数不一致或者把训练时的paddingmax_length换成了paddingTrue导致没有统一序列长度。解决预测代码要与训练代码保持一致尤其是 tokenizer 的max_length、truncation和padding参数。如果训练时用了正则去掉了 URL 和微博用户名预测时也要执行同样处理流程最靠谱的做法是把清洗逻辑封装成一个函数训练和预测都调用它。6. 微调之后的进阶用法从精确率到解释性分析让毕设答辩更有说服力模型在验证集上达到 90% 以上的准确率对毕业设计来讲已经足够但答辩时老师更关心的往往是你对结果的理解。单纯说“准确率 92%”缺乏解释力需要补充错误分析和模型行为验证。这里给出两个实用方向第一个是保存预测错误的样本按预测概率排序找出最自信的错误预测第二个是利用 attention 权重或者Integrated Gradients做简单的可解释性分析。二者不需要额外安装复杂的框架后者可以直接用captum库实现但安装成本稍高如果时间不够只做错误样本分析也足够。错误样本分析的操作并不复杂在验证集评估时把pred和true_labels不一致的样本连同原始文本和模型输出的置信度一起保存到 CSV。微博文本的特殊性会在这一步充分暴露比如反讽“真棒又加班到十点呢”、情绪词与否定词同时出现“一点都不好吃”都是最容易误判的类型。把这些案例整理成一个小表格放进毕设论文里会明显提升工作的完整度。另一个方向是固定一个模板比如“这家店的菜品____”填充不同的形容词观察输出概率变化这种方法虽然原始但能直观展示模型是否学到了情感倾向。from transformers import AutoModelForSequenceClassification, BertTokenizer model_path saved_model tokenizer BertTokenizer.from_pretrained(model_path) model AutoModelForSequenceClassification.from_pretrained(model_path) model.eval() def predict_single(text): cleaned text.replace(http\S, , regexTrue) # 假设你已经把清洗逻辑封装为函数 enc tokenizer(cleaned, truncationTrue, paddingmax_length, max_length128, return_tensorspt) with torch.no_grad(): logits model(**enc).logits prob torch.softmax(logits, dim-1) pred torch.argmax(prob, dim-1).item() return pred, prob[0][1].item() test_text 这家店的味道真不错 pred, confidence predict_single(test_text) print(pred label:, pred, positive prob:, confidence)这个预测函数在逻辑上与训练时保持一致只是移除了 batch 维度。prob[0][1]取的是类别 1正面的概率如果你想要负面概率就看prob[0][0]。多测几条样本后如果出现反讽句子判错不需要感到挫败因为在这种短文本上 BERT 也无法从字面信息完全推断出语气连不少大模型也做不到。毕业设计答辩时直接说明模型对反讽句的局限性本身就是有深度的回答。我自己的习惯是每次训练完都会把最佳权重、验证集结果和几条典型预测样例一起归档到一个固定的文件夹里命名格式包含训练日期和数据版本。这个习惯救过我很多次——有时候一周后再看同一份代码完全想不起来当时用的超参数和最优 checkpoints 在哪做了归档就能随时回到当时那个状态。对于第一次做微调实验的人踩坑和回滚都是成长的必经之路提前把备份做好能省很多不必要的重跑时间。这份基于 WeiboSenti100k 和 bert-base-chinese 的微调路线从数据清洗、训练闭环到错误分析已经是一条很顺畅的路径照着走下来希望帮到你。本文还有配套的精品资源点击获取