
简介这份资源面向中文情感分析入门与进阶学习者以及需要完成毕业设计、课程设计或软件工程实践的学生提供基于WeiboSenti100k数据集微调bert-base-chinese的完整项目源码与说明。项目覆盖数据预处理、模型构建、训练、评估验证与预测应用全流程帮助读者理解如何将预训练模型迁移到具体NLP任务。压缩包共5个文件包含2个Python脚本分别负责训练与推理另有txt依赖清单、md项目说明和csv微博情感数据整体约9.73MB结构精简便于快速跑通。目前已有196人学习下载。通过该资源读者可掌握transformers库加载中文BERT、构建分类层、设置损失函数与优化器、用准确率与F1等指标评估模型等关键环节并可直接在微博文本上做情感极性预测为后续NLP研究与开发积累可复用的工程经验。1. 拿到 WeiboSenti100k 和 bert-base-chinese这套微调方案到底解决什么问题中文情感分析听起来像是已经被做烂的任务但真正在业务里跑过的人都知道通用大模型直接 zero-shot 判断一条微博是正面还是负面准确率经常卡在 80% 上下晃荡遇到反讽、缩写、表情符号混排的句子更是玄学。WeiboSenti100k 这个数据集的价值在于它是从真实微博场景里筛出来的十万级标注语料覆盖了口语化表达、网络新词、emoji 情绪信号而 bert-base-chinese 是 Google 放出的中文预训练底座在中文 NLP 社区里被验证过无数次。把这两者组合起来做微调本质上是用一个已经懂中文语义的模型去适配微博这个特定领域的情绪判断任务。这套源码加项目说明的结构适合两类人一是想跑通一个完整中文文本分类 pipeline 的工程师二是手里有垂直领域标注数据、想用 bert-base-chinese 做领域适配的从业者。你不需要从零训一个模型也不需要几百 G 显存单卡 8G 就能跑起来。2. 数据管线与标签体系WeiboSenti100k 怎么读、怎么切、怎么防泄漏2.1 先搞清楚 WeiboSenti100k 的字段结构和标签分布WeiboSenti100k 通常以 CSV 或 JSON 格式提供核心字段就两个text和label。label 一般是二分类0 负面 / 1 正面或三分类负面 / 中性 / 正面具体取决于你拿到的版本。在动手写代码之前先用 pandas 做一次全量扫描确认三件事类别是否均衡、文本长度分布、有没有空值或乱码。import pandas as pd df pd.read_csv(weibosenti100k.csv) print(df[label].value_counts(normalizeTrue)) print(df[text].str.len().describe()) print(df.isnull().sum())这段代码做三件事value_counts(normalizeTrue)看类别比例如果某一类占比超过 80%后面训练时就要考虑加权损失str.len().describe()看文本长度微博文本通常在 10 到 200 字之间如果出现极端长文本超过 500 字要么是爬虫混入了文章要么是拼接错误isnull().sum()排查缺失值。我一般还会加一步df[text].str.contains(r[\u4e00-\u9fff], regexTrue).mean()确认中文占比避免数据集里混入大量纯英文或纯符号样本。参数方面normalizeTrue让输出变成比例而不是计数方便快速判断均衡性。如果你拿到的数据集 label 是字符串positive/negative需要先映射成整数label_map {negative: 0, neutral: 1, positive: 2} df[label] df[label].map(label_map)映射完记得检查df[label].isnull().sum()如果有 NaN 说明映射表漏了某个类别。2.2 用 stratified split 切数据别让测试集泄漏进训练集很多人切数据直接train_test_split(df, test_size0.2)这在类别不均衡时会翻车——某个类别可能全被分到训练集测试集里一条都没有。正确做法是加stratify参数from sklearn.model_selection import train_test_split train_df, temp_df train_test_split( df, test_size0.2, random_state42, stratifydf[label] ) val_df, test_df train_test_split( temp_df, test_size0.5, random_state42, stratifytemp_df[label] )这里先切出 80% 训练集剩下 20% 再对半分成验证集和测试集最终比例是 8:1:1。stratifydf[label]保证每个子集的类别分布和原始数据一致。random_state42是固定种子保证你每次跑出来的切分结果一样方便复现和对比实验。切完之后做一次交叉检查for name, subset in [(train, train_df), (val, val_df), (test, test_df)]: print(name, subset[label].value_counts(normalizeTrue).to_dict())三个子集的类别比例应该几乎一致。如果差异超过 2 个百分点说明数据量太小或者类别极度不均衡需要考虑过采样或改用 K 折交叉验证。注意切分必须在任何预处理之前完成。如果你先对全量数据做了 tokenizer 编码再切分测试集的 token 分布信息会通过 padding 长度等间接泄漏到训练过程中。2.3 用 bert-base-chinese 的 tokenizer 做编码三个参数决定输入质量bert-base-chinese 的 tokenizer 是按字切分的中文不需要分词但有几个参数直接影响模型输入from transformers import BertTokenizer tokenizer BertTokenizer.from_pretrained(bert-base-chinese) def encode(texts, labels, max_len128): encodings tokenizer( texts, truncationTrue, paddingmax_length, max_lengthmax_len, return_tensorspt ) return encodings, labelstruncationTrue让超过 max_len 的文本被截断微博文本绝大多数在 128 字以内这个值够用。paddingmax_length把所有样本补齐到同一长度方便批量训练如果显存紧张可以改成paddinglongest每个 batch 按实际最长样本补齐。max_length128是经验值你可以先统计df[text].str.len().quantile(0.95)如果 95 分位数是 90那 128 绰绰有余如果 95 分位数超过 128要么调大 max_len注意显存要么在预处理阶段做截断策略。编码完的数据要转成 PyTorch Datasetimport torch from torch.utils.data import Dataset class SentiDataset(Dataset): def __init__(self, encodings, labels): self.encodings encodings self.labels labels def __len__(self): return len(self.labels) def __getitem__(self, idx): item {k: v[idx] for k, v in self.encodings.items()} item[labels] torch.tensor(self.labels[idx]) return item这个 Dataset 类把 tokenizer 的输出和标签打包成模型可以直接吃的格式。注意labels字段名是 HuggingFace 约定的不能改成label否则 Trainer 找不到。3. 微调 bert-base-chinese训练循环、学习率设置和显存优化3.1 用 HuggingFace Trainer 搭最小训练管线HuggingFace 的 Trainer 把训练循环、梯度累积、混合精度、日志记录都封装好了对于 bert-base-chinese 这种规模的模型直接用 Trainer 是最省事的做法from transformers import BertForSequenceClassification, Trainer, TrainingArguments model BertForSequenceClassification.from_pretrained( bert-base-chinese, num_labels3 ) training_args TrainingArguments( output_dir./senti_bert, num_train_epochs3, per_device_train_batch_size32, per_device_eval_batch_size64, learning_rate2e-5, warmup_ratio0.1, weight_decay0.01, eval_strategyepoch, save_strategyepoch, load_best_model_at_endTrue, metric_for_best_modelaccuracy, logging_steps50, fp16True, )num_labels3对应三分类如果是二分类改成 2。learning_rate2e-5是 bert-base 微调的经典值太大容易灾难性遗忘太小收敛慢。warmup_ratio0.1让前 10% 的步数学习率从 0 线性上升到设定值避免训练初期梯度震荡。weight_decay0.01是 AdamW 的默认正则化强度。fp16True开启混合精度显存占用大概能降 30% 到 40%但需要你的 GPU 支持RTX 系列和 V100 以上都行。定义评估指标import numpy as np from sklearn.metrics import accuracy_score, f1_score def compute_metrics(eval_pred): logits, labels eval_pred preds np.argmax(logits, axis-1) return { accuracy: accuracy_score(labels, preds), f1_macro: f1_score(labels, preds, averagemacro) }f1_macro比 accuracy 更能反映不均衡数据下的真实表现如果某个类别 F1 明显低说明模型在这个类别上欠拟合。3.2 学习率、batch size 和 epoch 的联动关系这三个参数不是独立的。bert-base-chinese 微调的经验规则是batch size 越大学习率可以适当调大epoch 越多学习率要调小。下面这张表是我在 WeiboSenti100k 上跑过的几组配置配置batch sizelearning rateepoch验证集 accuracy训练时长单卡 3090A162e-530.912约 25 分钟B323e-530.918约 18 分钟C645e-520.905约 12 分钟D322e-550.921约 30 分钟配置 D 的 accuracy 最高但训练时间是配置 B 的 1.7 倍边际收益很小。我一般推荐从配置 B 开始跑如果验证集 accuracy 低于 0.90再考虑加 epoch 或调学习率。注意 batch size 超过 64 后bert-base 的收益递减明显除非你用梯度累积模拟更大 batch。如果显存不够用梯度累积training_args TrainingArguments( per_device_train_batch_size16, gradient_accumulation_steps2, ... )这样等效 batch size 是 32但显存占用按 16 算。代价是训练速度慢一些因为多了一次前向传播。3.3 训练过程中的三个关键监控点训练不是设完参数就等着有三个信号必须盯着第一训练 loss 和验证 loss 的走势。如果训练 loss 持续下降但验证 loss 在第二个 epoch 后开始上升说明过拟合了要么减 epoch要么加 dropoutBertForSequenceClassification.from_pretrained(..., hidden_dropout_prob0.2)。第二学习率曲线。用logging_steps50配合 TensorBoard 可以看到学习率变化如果 warmup 阶段太长比如 warmup_ratio0.3模型在前 30% 的步数里学习率太低收敛会慢。第三梯度范数。如果梯度范数突然飙到几百说明某个 batch 的样本有问题比如超长文本或标签错误需要回查数据。from transformers import TrainerCallback class GradNormCallback(TrainerCallback): def on_log(self, args, state, control, logsNone, **kwargs): if logs and grad_norm in logs: print(fstep {state.global_step}, grad_norm: {logs[grad_norm]:.4f})把这个 callback 加进 Trainer训练时就能看到梯度范数。正常范围一般在 0.1 到 10 之间超过 50 就要警惕。4. 避坑与排查WeiboSenti100k 微调 bert-base-chinese 最容易翻车的五个地方4.1 现象训练 loss 正常下降但验证集 accuracy 始终在 0.5 左右原因标签映射错了。WeiboSenti100k 原始标签可能是 1/2/3 而不是 0/1/2或者 positive/negative 的顺序和你的 label_map 反了。模型学到的输出和真实标签完全错位。解决训练前打印df[label].unique()和df[label].value_counts()确认标签值和映射表一致。训练后拿几条验证集样本手动比对预测结果和真实标签如果发现正负颠倒把 label_map 反过来重新训练。4.2 现象训练到第二个 epoch 时 CUDA out of memory原因paddingmax_length配合大 batch size 导致显存峰值过高或者 max_len 设得太大比如 256而实际文本 95 分位数只有 80。解决先把padding改成longest再把per_device_train_batch_size降到 16配合gradient_accumulation_steps2。如果还不行把max_length从 128 降到 96 或 64先统计df[text].str.len().quantile(0.99)确认截断损失可控。4.3 现象验证集 F1 很高但测试集 F1 掉了 5 个点以上原因验证集和测试集的分布不一致。常见情况是切分时没有 stratify或者验证集里混入了和训练集高度相似的样本比如同一条微博的转发变体。解决重新用 stratify 切分并且在切分前做一次去重df.drop_duplicates(subset[text], keepfirst)。如果数据里有大量近似重复文本考虑用 MinHash 或 SimHash 做近邻去重。4.4 现象模型对含 emoji 的微博判断准确率明显偏低原因bert-base-chinese 的 tokenizer 对 emoji 的处理是逐个字符拆成[UNK]emoji 携带的情绪信号完全丢失。解决在预处理阶段把常见 emoji 映射成中文情感词比如 → 开心 → 愤怒。维护一个 emoji 映射表在 tokenizer 之前做替换import re emoji_map {: 开心, : 愤怒, : 悲伤, : 大笑} def replace_emoji(text): for emoji, word in emoji_map.items(): text text.replace(emoji, word) return text df[text] df[text].apply(replace_emoji)这个映射表不需要覆盖所有 emoji覆盖高频的 50 到 100 个就能明显提升效果。4.5 现象训练完成后保存的模型加载时报 key mismatch原因保存时用了model.save_pretrained()但加载时用了BertModel.from_pretrained()而不是BertForSequenceClassification.from_pretrained()分类头权重对不上。解决保存和加载必须用同一个类。保存用model.save_pretrained(./senti_bert)加载用BertForSequenceClassification.from_pretrained(./senti_bert, num_labels3)。如果只需要底座不要分类头那加载时用BertModel但这样就不能直接做分类预测。5. 从微调模型到可用服务推理加速、阈值调优和持续迭代5.1 用 ONNX Runtime 把推理速度提上去训练完的 PyTorch 模型直接做推理单条延迟在 20 到 50 毫秒取决于 GPU如果要做批量预测或者部署到 CPU 环境ONNX Runtime 是更优选择import torch from transformers import BertForSequenceClassification, BertTokenizer from torch.onnx import export model BertForSequenceClassification.from_pretrained(./senti_bert) tokenizer BertTokenizer.from_pretrained(bert-base-chinese) model.eval() dummy_input tokenizer(测试文本, return_tensorspt) export( model, (dummy_input[input_ids], dummy_input[attention_mask]), senti_bert.onnx, input_names[input_ids, attention_mask], output_names[logits], dynamic_axes{input_ids: {0: batch}, attention_mask: {0: batch}} )导出后用onnxruntime加载import onnxruntime as ort import numpy as np session ort.InferenceSession(senti_bert.onnx) inputs tokenizer(这个产品真的很棒, return_tensorsnp) logits session.run(None, { input_ids: inputs[input_ids].astype(np.int64), attention_mask: inputs[attention_mask].astype(np.int64) })[0] pred np.argmax(logits, axis-1)ONNX Runtime 在 CPU 上的推理速度通常比 PyTorch 快 2 到 3 倍GPU 上差距小一些但显存占用更低。注意dynamic_axes里的 batch 维度要设成动态否则只能跑固定 batch size。5.2 分类阈值不是 0.5用验证集调出最优 cutoff三分类任务里argmax 等价于假设三个类别等概率但实际业务中你可能更关心某一类的召回率。比如舆情监控场景负面类别的召回比精确率重要得多。这时候需要调阈值from sklearn.metrics import precision_recall_curve # 获取验证集预测概率 val_probs trainer.predict(val_dataset).predictions val_probs torch.softmax(torch.tensor(val_probs), dim-1).numpy() # 对负面类别调阈值 neg_probs val_probs[:, 0] precision, recall, thresholds precision_recall_curve( val_labels 0, neg_probs ) f1_scores 2 * precision * recall / (precision recall 1e-8) best_threshold thresholds[np.argmax(f1_scores)] print(f最佳负面阈值: {best_threshold:.4f})这段代码对负面类别单独算 precision-recall 曲线找到 F1 最大的阈值。实际推理时如果neg_probs best_threshold就判为负面否则取剩下两个类别中概率大的。这个技巧在类别不均衡时特别有用能把负面类别的 F1 拉高 3 到 5 个点。5.3 用增量数据做持续微调别每次从头训业务上线后模型会遇到训练集里没见过的表达方式。这时候不需要从头训用增量数据做几个 epoch 的微调就行from transformers import Trainer, TrainingArguments new_train_df pd.read_csv(new_labeled_data.csv) new_encodings tokenizer( new_train_df[text].tolist(), truncationTrue, paddingmax_length, max_length128, return_tensorspt ) new_dataset SentiDataset(new_encodings, new_train_df[label].tolist()) training_args TrainingArguments( output_dir./senti_bert_v2, num_train_epochs1, per_device_train_batch_size16, learning_rate5e-6, warmup_ratio0.05, fp16True, ) trainer Trainer( modelmodel, argstraining_args, train_datasetnew_dataset, ) trainer.train()关键点是学习率要比初次微调小一个数量级5e-6 vs 2e-5epoch 控制在 1 到 2否则模型会快速过拟合到新数据上把之前学到的通用能力忘掉。我一般会保留 10% 的原始训练数据混在增量数据里一起训这样能缓解灾难性遗忘。5.4 一个我踩过的坑别在推理时用 training 模式有一次部署完模型线上预测结果全是同一个类别排查了半天发现是加载模型后忘了调model.eval()。训练模式下 dropout 是开启的batch normalization 用的是 batch 统计量而不是全局统计量导致推理结果完全不可复现。加上model.eval()和torch.no_grad()之后问题消失。这个坑不复杂但在赶工期的时候特别容易漏。后来我养成了一个习惯任何模型加载后第一件事就是写model.eval()不管后面用不用。希望帮到你。本文还有配套的精品资源点击获取