简介:面向计算机相关专业毕业生及NLP入门开发者,这套中文情感分析项目基于WeiboSenti100k微博评论数据集,利用bert-base-chinese预训练模型进行微调,实现文本情感极性分类,可作为毕业设计主体或课程实践案例。压缩包整体约9.73MB,包含5个文件,其中train.py、inference.py两个Python脚本分别覆盖训练与推理环节,前者负责数据预处理、模型微调和保存,后者可直接对新文本进行情感预测;requirements.txt用于快速搭建依赖环境,README.md提供说明文档,CSV数据文件weibo_senti_100k.csv给出微博情感样本,代码、数据与文档区分明确,结构紧凑易读。目前已有103人学习下载,适合有一定Python基础、想系统掌握BERT微调流程的学生或开发者参考。通过运行源码,可以理解中文文本分类的完整链路,涵盖序列截断、分词编码、权重加载、微调参数选择、评估计算等关键步骤,并以此为基础扩展出完整毕业设计系统。
1. 先别急着跑模型:这半个下午我踩过的中文情感分析调参坑
毕业设计拿到这个题目:用 WeiboSenti100k 微调 bert-base-chinese 做中文情感分析。听起来就是“加载模型、跑训练、出精度”,但真到调起数据来,标签和模型对不上、显存烧穿、loss 不降,每一步都能让你多花一个晚上。这篇笔记按我复现这个任务时的实际顺序,把数据集结构、微调脚本、关键参数和五个翻车点一次性讲透,照着改完,新手也能在本地把分类器训出来。
2. 要微调之前,先把数据集的“脾气”摸透:WeiboSenti100k 结构与 bert-base-chinese 的适配
写代码前先花十分钟看数据,远比你直接丢进 trainer 划算。我第一次就是没看,把 label 序列当成文本直接喂进分类头,被报错提醒了半天。
2.1 WeiboSenti100k 到底长什么样:csv 字段、标签分布与常见误读
拿到这条数据时,常见的形式是一个weibo_senti_100k.csv。现实里经常能看到多个版本,有的文件名带_processed,有的把正负样本标成 0 和 1。拷贝到本地后第一件你会遇到的问题就是编码:Windows 下用 pandas 默认编码打开会乱码,把encoding参数显式设成'utf-8',不行就试'gb18030'。
import pandas as pd df = pd.read_csv("weibo_senti_100k.csv", encoding="utf-8") print(df.head()) print(df.columns.tolist()) print(df['label'].value_counts())如果看到label是字符串'positive'/'negative',或者1/0,都不奇怪。关键是把数据集里的原始标签映射到 BERT 分类头需要的整数 id,训练前先把字符串标签转掉,否则后面CrossEntropyLoss会拿字符串做索引,代码可能不报错,但 loss 永远不降:
label2id = {"negative": 0, "positive": 1} df["label"] = df["label"].map(label2id)这段代码的意义是什么?模型不认“positive”“negative”这种自然语言值,AutoModelForSequenceClassification的num_labels=2输出层只认0/1。如果你的数据集版本 label 已经是0/1,这行直接跳过。
同时我建议做一次去重和空值清洗。微博数据里重复文本很多,同一个事件被不同账号转发会很接近,不去重会让模型在验证集上“训练泄漏”,指标虚高。空值更是致命,str.len()直接报 NaN:
df = df.dropna(subset=["text"]).drop_duplicates(subset=["text"]) df["text_len"] = df["text"].astype(str).str.len() df["text_len"].describe()看describe()出来的 75 分位、90 分位。如果 90 分位的文本长度超过 200,那max_length就不能设 128 太低;如果绝大多数都小于 64,那 128 又太浪费显存。我复现时常见情况是 90 分位在 100 左右,所以max_length=128是够用的。
还要检查标签分布是不是均衡。微博情感数据集一般正负差不多,但有的压缩包版本可能某类多 30%。如果不均衡,要考虑要不要做加权采样,或者在评估时更信任 F1。
2.2 为什么选 bert-base-chinese 而不是其它中文预训练模型
bert-base-chinese 是 12 层、768 维的中文 BERT,分词粒度是字。它跟 Hugging Facetransformers配合最顺,16G 显卡能微调,一张普通游戏显卡也能跑小 batch。对比 ALBERT,参数量更小,但训练时显存并不一定更省,推理时还容易遇到输出层加载玄学;对比 RoBERTa 中文版,效果稍微好一点,但不少第三方权重在词表映射上存在问题;对比 ERNIE 需要额外的词法知识接口,bert-base-chinese 的接口完全是标准AutoModel风格。对毕业设计来说,可复现比刷高分更重要。
选它还有个直接理由:tokenizer和模型对象都能用标准类加载,不用自己配置额外依赖:
from transformers import AutoTokenizer, AutoModelForSequenceClassification tokenizer = AutoTokenizer.from_pretrained("bert-base-chinese") model = AutoModelForSequenceClassification.from_pretrained("bert-base-chinese", num_labels=2)注意这里不要先加载BertModel然后再手动加分类层。我在源码里见过这样的写法,新增的分类头没有暴露在model.parameters()里,或者随机初始化不全,训练时只更新主干,最后精度低得离谱。直接用上面的方式,transformers会自动初始化分类头。
如果你特别在意 10% 的效果提升,可以换用hfl/roberta-wwm-ext,它在微博短文本上的确比原版 BERT 稳一点。但如果你的实验环境是内网、没法直接下载权重,bert-base-chinese 的备选镜像和缓存文件更多,不容易卡在下载步骤上。我的原则是:模型先能跑通,再谈提升。
3. 用 PyTorch + Transformers 把微调跑通:从数据集加载到训练与评估
为什么不直接贴一个Trainer的完整代码?因为Trainer封得太严,报错时你不知道是优化器的问题、数据的问题还是参数的问题。我习惯先写原生训练循环,跑通后再决定要不要换Trainer。
3.1 准备训练脚本:数据加载、分词与 Dataset 封装
先切分训练集和验证集,固定随机种子保证你的结果能复现。这块用sklearn最省事:
from sklearn.model_selection import train_test_split from datasets import Dataset train_df, val_df = train_test_split(df, test_size=0.1, random_state=42) print(len(train_df), len(val_df))然后把 Pandas DataFrame 换成datasets.Dataset,再对整个数据集做分词。平时分词函数会写成接收 batch 的形式,避免循环拖慢速度:
from transformers import AutoTokenizer tokenizer = AutoTokenizer.from_pretrained("bert-base-chinese") def tokenize_fn(examples): return tokenizer(examples["text"], truncation=True, padding=False, max_length=128) train_ds = Dataset.from_pandas(train_df[["text", "label"]]) val_ds = Dataset.from_pandas(val_df[["text", "label"]]) train_ds = train_ds.map(tokenize_fn, batched=True) val_ds = val_ds.map(tokenize_fn, batched=True)这里有几件容易踩的事。第一,Dataset.from_pandas会把 DataFrame 的索引带成__index_level_0__,map 时不影响,但你不想要就删掉。第二,tokenizer参数里padding先设成False,不要让 map 阶段把所有样本 pad 到同样长度——那样占内存,而且在 DataLoader 里一样要重新 pad。第三,字段名如果叫label,后续会出问题,先把它重命名为labels:
train_df = train_df.rename(columns={"label": "labels"}) val_df = val_df.rename(columns={"label": "labels"})然后构造 DataLoader 时,要用DataCollatorWithPadding动态 padding,这样每个 batch 都按该 batch 的最大长度补齐,速度明显比全局 padding 快:
from torch.utils.data import DataLoader from transformers import DataCollatorWithPadding data_collator = DataCollatorWithPadding(tokenizer=tokenizer) train_loader = DataLoader(train_ds, batch_size=16, shuffle=True, collate_fn=data_collator) val_loader = DataLoader(val_ds, batch_size=32, shuffle=False, collate_fn=data_collator)这里的batch_size是经验值。对bert-base-chinese这种体量,16G 显存可以跑 16;如果你只有 8G,降到 8,或者用后面的梯度累计。
3.2 微调超参与训练循环:学习率、batch size、warmup、epoch
BERT 微调不能用大学习率,1e-4起步就会让预训练权重被“冲碎”,最常见的稳定区间是2e-5到5e-5。我一般选2e-5,epoch 设 3,weight decay 0.01,再配一个线性 warmup,前 10% 的步数把学习率从零升到目标值。这个组合在绝大多数文本分类任务上不会翻车:
from transformers import AdamW, get_linear_schedule_with_warmup optimizer = AdamW(model.parameters(), lr=2e-5, weight_decay=0.01) total_steps = len(train_loader) * 3 warmup_steps = int(0.1 * total_steps) scheduler = get_linear_schedule_with_warmup(optimizer, num_warmup_steps=warmup_steps, num_training_steps=total_steps)训练循环最简单的写法是直接让模型吃batch,因为DataCollatorWithPadding已经把input_ids、attention_mask、token_type_ids和labels全部准备好了:
import torch from tqdm import tqdm device = torch.device("cuda" if torch.cuda.is_available() else "cpu") model.to(device) model.train() for epoch in range(3): total_loss = 0 for step, batch in enumerate(tqdm(train_loader)): batch = {k: v.to(device) for k, v in batch.items()} outputs = model(**batch) loss = outputs.loss loss.backward() optimizer.step() scheduler.step() optimizer.zero_grad() total_loss += loss.item() print(f"epoch {epoch+1}, loss: {total_loss/len(train_loader):.4f}")这段代码的逻辑是:model(**batch)里labels字段直接参与 loss 计算,不需要你再手动写CrossEntropyLoss。如果之前没有把label改成labels,这一步会直接抛Keyword argument 'label' unexpected。
关于token_type_ids,bert-base-chinese允许不传,模型会默认全 0,对单句分类没有影响。但如果你看到某个 batch 没有这个字段而报错,多半是用了AutoModel而不是AutoModelForSequenceClassification,查一下自己的类名。
显存不足时,先把batch_size降到 8,再用梯度累计模拟大 batch。常见做法是累计 4 步再更新参数:
accumulation_steps = 4 optimizer.zero_grad() for step, batch in enumerate(tqdm(train_loader)): batch = {k: v.to(device) for k, v in batch.items()} outputs = model(**batch) loss = outputs.loss / accumulation_steps loss.backward() if (step + 1) % accumulation_steps == 0: optimizer.step() scheduler.step() optimizer.zero_grad()注意 loss 要除以累计步数,否则梯度累计会把等效学习率放大 4 倍,训练到一半 loss 突然跳高。
3.3 评估与模型保存:准确率、F1、来自数据集的陷阱
训练完成后别只打印 loss,要在验证集上看准确率和 F1。F1 对情感分类尤其重要,因为正负样本经常不是严格的 50:50,准确率会被多数类带偏:
from sklearn.metrics import accuracy_score, f1_score model.eval() preds, trues = [], [] with torch.no_grad(): for batch in val_loader: batch = {k: v.to(device) for k, v in batch.items()} logits = model(**batch).logits preds.extend(torch.argmax(logits, dim=-1).cpu().tolist()) trues.extend(batch["labels"].cpu().tolist()) print("accuracy:", accuracy_score(trues, preds)) print("f1:", f1_score(trues, preds, average="binary", pos_label=1))这里的pos_label=1假设你映射表里positive=1。如果你的映射是反的,F1 会颠倒,先打印一下set(trues)确认。
模型保存时一定要同时保存模型权重和 tokenizer,否则后面做推理时词表不一致,输出长度都对不上:
model.save_pretrained("./checkpoints/bert-sentiment") tokenizer.save_pretrained("./checkpoints/bert-sentiment")之后加载推理只需要:
model = AutoModelForSequenceClassification.from_pretrained("./checkpoints/bert-sentiment") tokenizer = AutoTokenizer.from_pretrained("./checkpoints/bert-sentiment")如果训练过程中出现过 eval loss 上升、train loss 还在降,就是过拟合信号,可以提前停止,或者减小 epoch 到 2。对 10 万条微博情感数据,3 轮一般够,再多就是浪费时间。
4. 微调避坑清单:五个让我翻车的常见问题与排查方法
中间章节写一个完整避坑思路,比堆参数更能救急。以下每条都是我实际遇到过的现象、原因和解决方案。
4.1 标签列名不对:报错 Keyword argument 'label' unexpected
现象:训练时model(**batch)直接抛TypeError: Keyword argument 'label' unexpected。原因:AutoModelForSequenceClassification在forward方法里期望接收labels,但我们数据集的字段叫label,多字母少字母差一个,模型内部没有这个参数名。解决:在切分数据之前统一改列名:
df = df.rename(columns={"label": "labels"})改完之后再走Dataset.from_pandas,DataLoader拿到的键才是模型认识的labels。如果你是从 JSON 加载的数据,在构造Dataset时直接指定{"text": ..., "labels": ...}也可以。
4.2 字符串标签没有映射,loss 不降但也不报错
现象:训练时 loss 前几轮一直不降,验证集 F1 很低,甚至出现 NaN。原因:如果你的 label 列已经被map过一次,但还有残余字符串,比如'1.0'这种浮点字符串,CrossEntropyLoss会把字符串转成数字再索引,但偶尔出现'positive'就变成 NaN。解决:映射完以后立刻检查:
print(df["labels"].value_counts()) print(df["labels"].dtype)确认只存在0和1,且 dtype 是int64或int32。如果出现object,再执行一次df["labels"] = df["labels"].astype(int)。这个操作一定要放在train_test_split前,否则验证集里的字符串会漏掉。
4.3 微博文本里的 URL、@用户和话题标签污染数据
现象:训练集准确率 92%,但你拿新微博去试,几乎所有长文本都判成负。原因:模型学到了“看见 #话题# 就认为是正”“看到 http 就认为是负”这类浅层信号,而不是真正的情感语义。训练前去噪是标准做法:
import re def clean_text(s): s = re.sub(r"http\S+", "", s) s = re.sub(r"@\S+", "", s) s = re.sub(r"#.*?#", "", s) s = re.sub(r"\s+", " ", s).strip() return s df["text"] = df["text"].astype(str).map(clean_text)我一般会把清洗放在label2id之后、切分训练集之前。注意:如果你打算把清洗后的模型部署到真实微博场景,线上同样要对文本执行clean_text,否则推理效果和训练时不一致,等于给自己挖坑。
4.4 显存不足 OOM:先把 batch 降下来,再讲梯度累计
现象:运行到第 100 步突然CUDA out of memory。原因:动态 padding 到当前 batch 最长的那条,如果 batch 里恰好有一条超长微博,占用就会被顶上去。解决:第一反应不是换显卡,而是把batch_size降到 8,再用梯度累计恢复等效 batch:
accumulation_steps = 4实现方式在 3.2 已经给过。这里补充一个判断标准:如果 batch size 8 且累计 4,等效 batch 是 32,足够稳定训练。如果再爆,就是max_length的问题,在tokenize_fn里把max_length从 128 改到 64,或者对文本做截断。注意截断后要重新统计数据分布,不要盲目截断到 64,否则长评论信息被丢光,F1 反而下降。
4.5 随机种子不固定,两次训练结果完全不一样
现象:同一份源码,第一次跑 acc 0.93,第二次 0.89,以为是代码改坏了。原因:没有全局固定随机种子,DataLoader的shuffle、模型 Dropout 层、CUDA 算子执行顺序都会带来随机性。解决:在脚本开头统一设置:
import random, numpy as np, torch def set_seed(seed=42): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed) torch.backends.cudnn.deterministic = True torch.backends.cudnn.benchmark = False set_seed(42)需要注意,cudnn.deterministic = True会让某些算子变慢,但对 10 万条规模的任务完全可接受。如果你用的是 MPS 或 CPU,把torch.cuda.manual_seed_all包在if torch.cuda.is_available()里,否则也会报警告。
5. 验证与进阶技巧:拿真实微博试手、跨领域压力测试,还有 Lora 微调的轻量替代
5.1 用自建样例快速判断模型有没有“瞎学”
验证集指标再高,也要写一段真实微博试一下。我每次训练完都会拿正负各几十条手工写的句子做冒烟测试:
samples = [ "今天天气真好,出去走了一圈,心情舒畅。", "这个客服等了一个小时也没人理,服了。", "刚买的耳机音质比想象中好。", "外卖又送错了,给了差评。", ] inputs = tokenizer(samples, padding=True, truncation=True, return_tensors="pt").to(device) logits = model(**inputs).logits preds = torch.argmax(logits, dim=-1).cpu().tolist() print([label2id_rev[p] for p in preds])如果正向句子被判成负,先别急着调参数,回到 4.3 看清洗是否执行了,再看训练集里是否存在大量否定句式,比如“不是不好”“并不讨厌”,这类样本其实在微博数据里占比不小,BERT 在短文本上对否定结构敏感度有限。多做几十条自己的测试集,比验证集上的一个数字更能反映真实场景。
5.2 Lora 微调:显存不够时的轻量替代
当本地只有 8G 显存,或者你想快速做多组对照实验,可以换 Lora 微调。它冻结原来的 BERT 权重,只训练注入的低秩分解矩阵,训练参数量降到原来的 1% 左右,显存压力明显变小:
from peft import LoraConfig, get_peft_model lora_config = LoraConfig(task_type="SEQ_CLS", r=8, lora_alpha=16, lora_dropout=0.1) lora_model = get_peft_model(model, lora_config) lora_model.print_trainable_parameters()后面训练循环不变,只是优化器传lora_model.parameters()。我个人的血泪经验是:对 10 万条微博情感任务,Lora 效果几乎不掉点,甚至因为正则化作用,在长尾噪声上更稳。这也符合当前大模型微调平台里常见的轻量微调路线。
我后来每次都先把数据清洗和随机种子写好,再碰模型参数。微调不是越久越好,BERT 训练 2 到 3 轮就够,过拟合只会让验证集“好看”,真实使用直接翻车。希望这篇能帮你少走这些弯路。
本文还有配套的精品资源,点击获取