ARTICLE DETAIL

资讯详情

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

BERT中文情感分类实战:从CSV到分类头全流程解析

BERT中文情感分类实战:从CSV到分类头全流程解析 简介一份基于BERT模型的中文文本情感分类完整项目面向自然语言处理方向的毕业设计或课程实践者。资源提供了从数据预处理、模型加载、微调训练到预测评估的整套流程涉及bert-base-chinese语言模型、Tokenizer、分类层及Adam优化等关键知识点适合需要快速搭建情感分类基线并完成论文实验的本科生或初级NLP学习者。压缩包共23个文件以11个Python脚本为主涵盖建模、训练与推理实现另有shell启动脚本、训练/测试数据集、README说明等整体仅2.42MB结构紧凑便于二次开发。目前已有近千人学习下载。通过这份内容可省去环境配置与代码调试时间直接对照数据集与脚本理解BERT分类任务的完整实现同时附带操作过程说明能帮助新手避开常见坑点并完成项目复现。1. 中文情感分类不能只换模型先说清楚 BERT 的输入管道把这份毕业设计压缩包解开后第一眼看到的不是模型而是 modeling.py、tokenization.py、run_classifier.py 这些文件名。如果只照着 README 跑一遍很容易出来一个“能出结果但说不清为什么”的黑盒真正让有经验的开发者卡住的也不是 Transformer 层数而是中文句子变成 input_ids、attention_mask 之前那几步。这里要讲的就是一条从原始 CSV 到 BERT 分类头的完整链路数据字段怎么排、Tokenizer 怎么选、训练脚本怎么调、验证和预测怎么复用同一套预处理。资源适合正用 BERT 做中文文本情感分类毕业设计、竞赛复现或者想把规则分类器替换成预训练模型的人。思路是先把输入管道立住再谈损失、学习率和落盘预测。2. 准备数据与调用 BertTokenizer把中文句子切成 BERT 认识的 ID2.1 CSV 里的字段顺序比内容更影响代码项目数据目录下同时出现 train.csv、dev.csv、test.csv 和 train_sentiment.txt这其实是两套数据形态txt 往往是原始语料CSV 是已经切好训练集、验证集、测试集的结构化文件。我在拆这类项目时通常会先做一步统一不管原始 CSV 第一列有没有列名直接重命名成 text 和 label避免后面取列时靠数字索引。import pandas as pd from sklearn.model_selection import train_test_split df pd.read_csv(data/train.csv, encodingutf-8) df.columns [text, label] train_df, dev_df train_test_split( df, test_size0.1, random_state42, stratifydf[label], ) print(train_df[label].value_counts())这段代码的要点是train_test_split里的stratify。很多 BERT 中文情感分类任务里正面评论和负面评论数量并不均衡不做分层抽样验证集里可能出现某一类样本极少导致验证准确率忽高忽低。encodingutf-8也值得注意Windows 上导出的 CSV 经常是 GBKpandas 默认按 UTF-8 读会直接抛错我一般先转换成 UTF-8而不是在脚本里维护一堆编码参数。读进来之后还要清掉空文本和标签为 NaN 的行。BERT 对空字符串容易产生无意义的 [CLS] 输出虽然不报错但会拉低训练效果。df.dropna(subset[text, label])是成本最低的一步。2.2 BertTokenizer.encode_plus 返回的三个张量中文 BERT 的 Tokenizer 用的是字级分词这家餐厅的菜很新鲜会被切成单个汉字和标点。它不像英文那样依赖空格切词也不需要额外挂 jieba。所以加载模型和 Tokenizer 时直接指定bert-base-chinese即可。from transformers import BertTokenizer tokenizer BertTokenizer.from_pretrained(bert-base-chinese) encoded tokenizer.encode_plus( text这家餐厅的菜很新鲜服务一般, max_length128, paddingmax_length, truncationTrue, return_tensorspt, return_token_type_idsTrue, ) print(encoded[input_ids][0].tolist()) print(encoded[attention_mask][0].tolist()) print(encoded[token_type_ids][0].tolist())encode_plus做的事可以拆成四步看。字符串先转成 token 列表然后映射成 vocab 中的 ID每个序列开头补[CLS]结尾补[SEP]超出max_length的部分被截断不足的部分用[PAD]填充到统一长度。attention_mask的作用是告诉模型哪些位置是真实内容、哪些位置是填充的 padtoken_type_ids在单句子分类里通常全是 0只有输入句对时才用上。这里最容易踩坑的是paddingmax_length。它会无条件把每个样本都补到 128即使原始句子只有十几个字。短文本占大多数的情况下模型计算量浪费在 pad token 上。推荐做法是保留一个do_paddingFalse的原始编码训练时再在 DataLoader 里用 collate 动态补齐。下面是三张向量在一条 128 长度样本中的行为对比张量真实 token 位置padding 位置典型错误input_ids汉字对应的 ID0[PAD]不 pad 直接拼 batch 导致维度错attention_mask10全 1 或全 0模型无法区分填充位token_type_ids00句对任务里不区分前后句检查时不要只看 input_ids还要确认 attention_mask 里确实有 1 有 0。很多时候训练 loss 异常低结果却是模型把 pad 位置也当成有效内容学到的其实是填充模式。2.3 Dataset 封装时把 label 一并返回数据准备好后需要把每条样本封装成 PyTorch Dataset。这里要注意return_tensorspt返回的张量带 batch 维放进 Dataset 前要squeeze(0)否则后面stack时维度会变成四维报错非常隐蔽。import torch from torch.utils.data import Dataset class SentimentDataset(Dataset): def __init__(self, df, tokenizer, max_length128): self.examples [] for _, row in df.iterrows(): enc tokenizer( row[text], max_lengthmax_length, paddingmax_length, truncationTrue, return_tensorspt, ) self.examples.append({ input_ids: enc[input_ids].squeeze(0), attention_mask: enc[attention_mask].squeeze(0), labels: torch.tensor(int(row[label]), dtypetorch.long), }) def __len__(self): return len(self.examples) def __getitem__(self, index): return self.examples[index]这个封装把输入和标签放在同一个 dict 里训练循环里直接model(**batch)就能把三个字段喂给 BERT 分类模型。标签类型必须是torch.long交叉熵损失不接受 float。max_length128对中文情感分类基本够用如果语料里评论普遍超过 150 字可以根据tokenizer.model_max_length和显存重新定。到这里管道的第一段已经通了CSV 变成 DatasetDataset 会被 DataLoader 按 batch 取出来。下一步才轮到真正加载预训练权重和分类头。3. 加载 bert-base-chinese 后加分类头损失函数、优化器和 run_classifier 的取舍3.1 为什么优先用 BertForSequenceClassification项目里带着 modeling.py 和 run_classifier.py这两个文件是 Google BERT 官方仓库早期的 TensorFlow 实现。如果环境里装的是新版 TensorFlow 2直接跑 run_classifier.py 会遇到大量 API 兼容问题不太划算。我一般只在两种情况下参考原版脚本一是看它如何处理数据格式二是看它如何组织 train/eval 的 checkpoint 输出模型本身用 Hugging Facetransformers加载会更稳。from transformers import BertForSequenceClassification, BertConfig config BertConfig.from_pretrained( bert-base-chinese, num_labels2, hidden_dropout_prob0.1, ) model BertForSequenceClassification.from_pretrained( bert-base-chinese, configconfig, )BertForSequenceClassification做的事情是加载bert-base-chinese的 12 层 Transformer 权重取序列第一个位置也就是[CLS]的最后一层 hidden state经过一层 dropout再过一个Linear(768, num_labels)。所以num_labels2对应正面、负面两个输出节点。hidden_dropout_prob默认是 0.1如果训练集只有几千条调成 0.15 到 0.2 可以在一定程度上抑制过拟合但太小的话模型容易记住训练集的边缘表达。如果执意使用项目里的 modeling.py也可以从BertModel里拿到pooled_output然后自己拼接import torch.nn as nn class SentimentHead(nn.Module): def __init__(self, hidden_size768, num_labels2): super().__init__() self.dropout nn.Dropout(0.1) self.classifier nn.Linear(hidden_size, num_labels) def forward(self, pooled_output): return self.classifier(self.dropout(pooled_output))这么写的好处是能看清分类头结构坏处是还要自己处理梯度、loss、device 迁移。如果是毕业设计或短周期实验直接用封装好的模型能省很多时间但在答辩或技术方案里需要解释清楚[CLS]向量为什么能代表整个句子的语义。3.2 损失函数、优化器和 warmup 参数分类任务是两分类BertForSequenceClassification内部默认走CrossEntropyLoss不需要手动再套一层。真正要调的是优化器和调度器。预训练模型微调的常见做法是用 AdamW学习率在2e-5到5e-5之间比新训练一个模型的1e-3小两个数量级。weight_decay0.01也是 BERT 微调里常见的正则手段。from transformers import AdamW, get_linear_schedule_with_warmup epochs 3 batch_size 16 total_steps (len(train_dataset) // batch_size 1) * epochs optimizer AdamW( model.parameters(), lr2e-5, weight_decay0.01, ) scheduler get_linear_schedule_with_warmup( optimizer, num_warmup_stepsint(total_steps * 0.1), num_training_stepstotal_steps, )num_warmup_steps通常取总步数的 5% 到 10%。warmup 的作用是让模型在最初几百步用较小的学习率起步避免预训练权重在第一步就被过大的梯度冲坏。这里容易犯的错是把len(train_dataset) // batch_size写错成len(train_loader)实际要用每个 epoch 的迭代次数乘总轮数否则 scheduler 的终点会提前到最后阶段学习率变成 0模型几乎不再学习。训练循环里重点看三件事loss 是否下降、梯度是否爆炸、checkpoint 是否落盘。我一般在每 100 步打印一次loss.item()并把输入里attention_mask.sum().item()顺带打出来确认没有异常短的句子占比太高。for step, batch in enumerate(train_loader): batch {k: v.to(device) for k, v in batch.items()} outputs model(**batch) loss outputs.loss optimizer.zero_grad() loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0) optimizer.step() scheduler.step()clip_grad_norm_是这里的关键一行。BERT 微调偶尔会出现个别 batch 的梯度范数暴涨尤其当某条样本有异常长的标点或重复字符时。把 max_norm 设为 1.0 能保证单步更新不会把权重推出正常的损失面。3.3 checkpoint 保存与恢复方式项目源码运行后通常会在 output 目录生成 model.ckpt 或 pytorch_model.bin。用transformers保存时最佳实践是把模型和 Tokenizer 一起存到同一个目录后续 predict 时只用目录路径不依赖预训练模型名这样交付时也不容易弄错版本。model.save_pretrained(./output/bert-chn-sentiment) tokenizer.save_pretrained(./output/bert-chn-sentiment)这两个调用会把config.json、pytorch_model.bin、vocab.txt、tokenizer_config.json等文件写到同一个目录。恢复时from transformers import BertForSequenceClassification, BertTokenizer model BertForSequenceClassification.from_pretrained(./output/bert-chn-sentiment) tokenizer BertTokenizer.from_pretrained(./output/bert-chn-sentiment)以后写预测脚本时永远只依赖这个本地目录不出网也能跑。优点是训练和推理的词表、模型结构完全一致。要注意的是不要只保存模型权重不保存 tokenizer否则在部署机上加载会退回到默认的bert-base-chinese词表和训练时的 token 顺序不一致预测结果就废了。训练阶段的核心参数可以固定为这几组参考范围参数推荐范围显存不足时max_length128 ~ 256128截断长文案train_batch_size8 ~ 324 ~ 8配合梯度累积learning_rate2e-5 ~ 5e-52e-5适当延长 epochnum_train_epochs2 ~ 43 后看 loss 是否回升warmup_ratio0.05 ~ 0.10.14. 训练日志、验证准确率和过拟合这套 BERT 情感分类该怎么调4.1 用命令行脚本复现训练项目里带着 train.sh这就是把上面所有 Python 步骤串成一条命令的入口。用bash运行参数直接覆盖代码里的默认值。我在复现时一般会多写一个--do_eval这样每个 epoch 结束就能同时看到训练 loss 和验证准确率不用等全部训练完再补一次预测。export DATA_DIR./data export OUTPUT_DIR./output python run_classifier.py \ --task_name binary \ --do_train \ --do_eval \ --data_dir $DATA_DIR \ --model_name_or_path bert-base-chinese \ --max_seq_length 128 \ --train_batch_size 16 \ --eval_batch_size 32 \ --learning_rate 2e-5 \ --num_train_epochs 3 \ --warmup_proportion 0.1 \ --output_dir $OUTPUT_DIRtask_name在源码里通常对应一个 processor也就是告诉主脚本用哪套字段解析逻辑。binary是人为定义的两个类别对应项目里intent.py或run_classifier.py中的 DataProcessor。常见做法是先跑--do_train生成 checkpoint再用--do_eval单独加载最优 checkpoint 验证我在实际使用中会让一个脚本同时完成减少手动切换。如果是在服务器上跑几小时以上的任务用nohup或tmux把日志写进.log文件会更稳妥nohup bash train.sh train.log 21 tail -f train.log4.2 验证集准确率与分类报告怎么看项目里的 predict.py 通常只能读一条文本、输出一个标签这适合演示但不适合完整评估。接手这套代码时我会单独写一段评估逻辑把 dev.csv 全部过一遍输出每个类别的精确率、召回率和 F1。只看整体准确率很容易被类别不平衡骗了比如 80% 都是负面评论模型全预测负面也能有 80% 准确率。from sklearn.metrics import classification_report def evaluate(model, loader, tokenizer, device): model.eval() all_preds [] all_labels [] with torch.no_grad(): for batch in loader: batch {k: v.to(device) for k, v in batch.items()} logits model(**batch).logits preds logits.argmax(dim-1) all_preds.extend(preds.cpu().tolist()) all_labels.extend(batch[labels].cpu().tolist()) return classification_report(all_labels, all_preds, digits4)with torch.no_grad()在这里是必须的否则验证过程会缓存计算图显存越占越多第二次验证直接 OOM。logits.argmax(dim-1)取出两个类别中得分更高的那个。如果模型输出 loss直接取.logits不要用模型的输出 dict 结构猜测字段。验证集指标应该同时记录训练 loss。一般来说BERT 微调的正确趋势是训练 loss 持续下降验证 loss 先下降后略微回升。如果训练 loss 很低、验证准确率在 85% 附近上不去大概率是模型把训练集里的特殊说法背下来了这时候优先调 dropout 和学习率而不是盲目增加训练轮数。4.3 常见故障标签不平衡、截断和过拟合第一类问题label 分布严重倾斜。处理方式是在 CSV 里统计类别数量后用weighted采样或修改损失权重。修改损失权重并不需要换模型直接给CrossEntropyLoss(weighttorch.tensor([w0, w1]))即可少类别给更高权重。第二类问题max_length128把关键信息截掉了。中文评论常在结尾表达反转“味道不错但服务太差”如果前半部分是负面表达截断在 128 字内可能丢掉转折。排查方法是在训练前随机抽 200 条 text统计分字后的长度分布tokenized_lens [len(tokenizer.tokenize(t)) for t in df[text].sample(200, random_state1)] print(pd.Series(tokenized_lens).describe())如果超过 128 token 的样本占比超过 20%要么把 max_length 提到 256要么在数据层面做长句切割。注意提到 256 需要显存做对应调整。第三类问题loss 出现 NaN 或训练曲线震荡。BERT 微调很少出现 NaN但一旦出现先看学习率是不是设成了1e-3再看数据里有没有不可解码字符。我遇到过用 GBK 编码读入的 CSV 出现\x00变成无意义 token模型在某个 batch 上输出异常大 logit数值就炸了。清洗数据时把非中英文符号替换成空格能规避大部分问题。4.4 intent.py 在项目里的实际作用项目根目录下的 intent.py 更像一个业务封装层而不是训练脚本。常见做法是把文本里的“意图”或“类别标签”集中在同一个映射函数里避免训练脚本里反复写if label 1这种硬编码。LABEL_MAP { 0: negative, 1: positive, } def label_to_text(label_id): return LABEL_MAP.get(label_id, unknown) def text_to_label(label_text): for k, v in LABEL_MAP.items(): if v label_text: return k raise ValueError(funknown label: {label_text})这里的价值是让 CSV、训练脚本、predict 脚本共用同一套标签定义。如果数据集换成三分类只需要修改LABEL_MAP再同步调整num_labels其他代码不用改。否则 predict.py 和 train.csv 里的标签很容易出现错位比如一个脚本里 1 表示正向另一个脚本里 1 表示负向。5. 把 predict.py 改造成批量推理一条函数跑完整个测试集5.1 加载本地 checkpoint走完整预处理预测阶段的核心原则是训练时怎么处理文本预测时就怎么处理文本。加载模型和 tokenizer 时优先用本地目录不走bert-base-chinese在线下载确保环境一致。def build_predictor(model_dir./output/bert-chn-sentiment, devicecuda): tokenizer BertTokenizer.from_pretrained(model_dir) model BertForSequenceClassification.from_pretrained(model_dir) model.to(device) model.eval() return model, tokenizer, device def predict_one(text, model, tokenizer, device): enc tokenizer( text, max_length128, paddingmax_length, truncationTrue, return_tensorspt, ) enc {k: v.to(device) for k, v in enc.items()} with torch.no_grad(): logits model(**enc).logits pred_id logits.argmax(-1).item() return label_to_text(pred_id)model.eval()会关闭 Dropout预测输出从概率分布变成确定的 logits。如果省略这行每次预测同一句话可能得到不同结果因为分类头里的 Dropout 还在随机丢弃神经元。argmax(-1)得到的是 label_id最后再用intent.py的映射函数转回中文标签。5.2 基于 DataLoader 的批量落盘单条 predict_one 适合演示但测试集 5000 条时逐条调用 Python 循环非常慢。推荐把 predict_one 里相同的逻辑放进 batch 循环一次性输出结果到 CSV。from torch.utils.data import DataLoader test_dataset SentimentDataset(df_test, tokenizer, max_length128) test_loader DataLoader( test_dataset, batch_size64, shuffleFalse, ) preds [] with torch.no_grad(): for batch in test_loader: batch {k: v.to(device) for k, v in batch.items()} logits model(**batch).logits preds.extend(logits.argmax(-1).cpu().tolist()) df_test[predicted_label] [label_to_text(p) for p in preds] df_test.to_csv(data/test_result.csv, indexFalse, encodingutf-8-sig)batch_size64相对于训练时的 16 可以加大因为推断不保存梯度显存占用小很多。shuffleFalse必须保持否则预测顺序和 DataFrame 的行顺序对应不上。最后用encodingutf-8-sig写文件Excel 打开时中文不会乱码这条小细节在交付报告时很加分。整个流程跑通后训练脚本和预测脚本各守一段职责训练脚本负责更新权重预测脚本只负责把 clean text 映射成确定的类别中间不要混进任何临时调试代码。本文还有配套的精品资源点击获取
返回列表