ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

BERT微调多标签文本分类:数据、训练与避坑全攻略

2026/9/23 23:22:22 拓冰建站 浏览量
BERT微调多标签文本分类:数据、训练与避坑全攻略 简介面向自然语言处理入门与实战开发者这份下载包提供了基于Keras和Keras-bert的文本多标签分类完整实现。项目选用2020语言与智能技术竞赛事件抽取任务作为样例数据展示BERT微调在真实场景下的落地方案适合快速入门或改造复用。包内共10个文件包括4个Python脚本分别覆盖模型训练、评估、预测与FGM对抗训练同时提供train.csv/test.csv数据文件、中文BERT词表vocab.txt、依赖清单requirements.txt及README说明文档整体压缩后仅1.01MB结构精简清晰。已有1634人学习下载是理解多标签分类建模、BERT微调流程及竞赛实战思路的实用资料。通过运行该项目可以系统掌握数据预处理、模型构建、训练调参与结果评估的完整链路并借助对抗训练增强模型鲁棒性为后续迁移到其他文本分类任务打下基础。1. 多标签文本分类与BERT微调一次把数据、训练和坑讲透文本分类做到多标签这个分支时很多人的第一反应是“不就是把Softmax换成Sigmoid吗”。真上手才发现数据标注格式、阈值选择、类别不均衡、Keras-bert库的兼容性每一步都在等着你翻车。常见做法是把预训练BERT接一个多标签输出层用Keras-bert加载权重后微调这也是Keras生态里最成熟的落地路线。本篇就沿着数据准备、环境搭建、模型构建、训练调参与避坑这条线把能直接抄的代码和参数全部摆出来。2. 多标签任务的数据准备决定模型上限的不是网络是标注2.1 多标签分类与单标签分类的本质区别单标签分类里每条样本只有一个正确答案输出层用Softmax所有类别概率之和为1。多标签分类里一条样本可以同时命中多个标签例如一条工单“手机无法开机且充电无反应”同时属于“硬件故障”和“充电问题”这个时候输出层要用Sigmoid每个类别独立计算概率互不干扰。这个区别直接决定了三件事标签编码方式、损失函数、评估指标。标签从One-Hot变成Multi-Hot例如三个类别“软件/硬件/网络”一条同时命中硬件和网络的样本编码为[0,1,0,1]。损失函数用binary_crossentropy评估指标不能只看Accuracy因为Accuracy会严重偏向多数类通常以F1分数为主并结合各个类别的Precision和Recall一起看。2.2 构建多标签数据集从原始文本到TFRecord的完整转换拿到原始数据后最关键的步骤是建立“文本-标签”的映射。我通常先维护一个标签字典然后写脚本把原始标注转成三个文件train.txt、valid.txt、test.txt每一行是“标签ID逗号分隔\t文本内容”。import json # 标签字典线上环境建议存成json方便后续增删类别 label_dict { 软件故障: 0, 硬件故障: 1, 网络问题: 2, 账号问题: 3 } def parse_raw_data(raw_path, output_path): with open(raw_path, r, encodingutf-8) as fr, \ open(output_path, w, encodingutf-8) as fw: for line in fr: line line.strip() if not line: continue parts line.split(\t) if len(parts) ! 2: continue text parts[0] labels parts[1].split(,) # 原始标注为逗号分隔 label_ids [str(label_dict[lb]) for lb in labels if lb in label_dict] if not label_ids: continue fw.write(,.join(label_ids) \t text \n) parse_raw_data(raw_train.txt, train.txt)这段脚本做的事情很朴素把文本和标签拆开把文本标签转换成数字ID过滤掉标签字典里不存在的脏标注。注意保留所有标签字段多标签任务里一条样本的标签数量是不固定的后续Keras-bert的输入只需要文本和Multi-Hot向量不需要关心标签个数。数据量方面我一般建议每个标签至少有200条正样本才值得开始训练。低于这个量级微调出来的F1不会比规则好多少如果某些标签只有几十条后面的class_weight和阈值调整也救不回来。2.3 BERT输入序列的构造截断、填充与注意力掩码BERT的输入不是原始文本而是经过Tokenizer编码后的三个向量token_ids、segment_ids、attention_mask。多标签任务里文本预处理不区分标签统一走BERT的分词器即可。from keras_bert import Tokenizer vocab_path chinese_L-12_H-768_A-12/vocab.txt tokenizer Tokenizer(vocab_path) max_len 128 def encode_text(text): tokens tokenizer.tokenize(text) # Keras-bert的encode方法自动添加[CLS]和[SEP] # 这里的token_ids已经是数值idsegments全为0表示单句输入 token_ids, segment_ids tokenizer.encode( text, max_lenmax_len, truncate_methodLENGTH ) return token_ids, segment_idsfill_value设置为0正好对应token_ids里[PAD]的编码位置。截断策略用LENGTH也就是从右侧截断我实际对比过从左侧截断和保留首尾两种方式对短文本影响不大对长文本保留尾部信息反而略有提升但提升有限不值得为了这个改动增加代码复杂度。3. 从零搭建Keras-bert微调环境版本组合是一切问题的根源3.1 Keras-bert与TensorFlow的版本组合Keras-bert这个库比较特殊它官方依赖的是TensorFlow 1.x的Keras API在TensorFlow 2.x下跑要额外做兼容。很多人在环境配置这一步就翻车大多数是版本不匹配导致的。我从多个项目里沉淀下来的稳定组合有两种组合TensorFlowKerasPython备注方案A1.152.3.13.6/3.7最稳无需额外适配直接按文档写方案B2.42.43.7/3.8需要关闭tf.function的autograph或使用兼容层我倾向于推荐方案ATensorFlow 1.15虽然老但对Keras-bert的兼容性最好跑微调任务时不需要跟各种“tf.compat.v1”的报错纠缠。如果你所在的环境只能用TensorFlow 2.x那就要在代码开头加上兼容层import tensorflow as tf # TensorFlow 2.x下运行Keras-bert必须做的兼容处理 import keras_bert from keras_bert import load_bert_model这里的核心逻辑是把TensorFlow 2.x里被移到底层的东西重新映射到Keras-bert需要的命名空间常见报错是“module tensorflow has no attribute get_default_session”或“placeholder”相关错误大多是这一步缺失或顺序不对。3.2 下载中文BERT预训练权重与配置文件与标题配套的中文预训练模型是Google发布的BERT-Base-Chinese权重文件主要包含三个部分BERT配置文件bert_config.json、词表文件vocab.txt、模型权重bert_model.ckpt。下载后把这三个文件放在同一个目录下例如chinese_L-12_H-768_A-12/后续加载时只需要指定这个目录路径。from keras_bert import load_bert_model # 指定预训练模型目录 pretrained_path chinese_L-12_H-768_A-12/bert_model.ckpt config_path chinese_L-12_H-768_A-12/bert_config.json # 加载BERT模型返回一个Keras Model实例 bert_model load_bert_model( config_path, pretrained_path, seq_len128, output_layer_num4, # 取最后4层Transformer输出拼接 ) bert_model.summary()load_bert_model返回的模型输出是BERT的编码向量output_layer_num指定取最后几层拼接这个参数在小数据集上提升明显默认值为1也就是只用最后一层。如果你的数据量在数千条级别我建议从摘要输出形状或训练日志里确认一下shape是否与预期一致因为output_layer_num改变后输出的向量维度会变成768的倍数后续接Dense层时要对应调整输入维度。3.3 预训练权重是否下载成功的快速校验权重下载完成后快速校验是必须的动作。常见做法是用Tokenizer编码一个短文本过一遍bert_model.predict看输出向量是否合理。import numpy as np test_text 你好世界 token_ids, segment_ids encode_text(test_text) # 输入shape必须是[batch_size, seq_len] pred bert_model.predict([np.array([token_ids]), np.array([segment_ids])]) print(pred.shape, pred[0][0][:10]) # 打印第一句[CLS]位置的向量前10维 # 如果输出全0或数值恒定说明权重加载失败如果打印出来全是0或者每个样本输出完全相同说明权重文件没有正确加载此时优先检查bert_model.ckpt是否有对应的.index和.data文件而不是怀疑模型结构写错了。很多人卡在这一步几个小时最后发现是下载的文件只有ckpt的索引文件缺少实际权重。4. BERT微调模型构建在预训练之上接一个多标签分类头4.1 用函数式API构建微调模型Keras-bert加载出来的BERT模型是个自由参数全部可训练的Keras Model微调的含义就是在它的基础上追加自己的输出层然后整个模型拿去训练。这里要明确一个概念微调不是只训练新增的几层而是BERT原生层和新增层一起更新权重。from keras.layers import Dense, Dropout, Lambda from keras.models import Model import keras.backend as K num_labels len(label_dict) # 取BERT输出中的[CLS]向量位置0即为[CLS] cls_out Lambda(lambda x: x[:, 0], namecls_extract)(bert_model.output) dropout_out Dropout(0.3, namecls_dropout)(cls_out) logits Dense( unitsnum_labels, activationsigmoid, namemulti_label_output )(dropout_out) model Model(bert_model.input, logits) model.compile( optimizerAdam(2e-5), lossbinary_crossentropy, metrics[accuracy], ) model.summary()这段代码有四个要点需要说清楚。第一取[CLS]向量而不是把所有token的输出做池化对BERT来说是更常规的做法。[CLS]位置在预训练阶段就是为分类任务设计的。第二Dropout加在Dense层之前这是标准做法微调场景下0.3的dropout率比较均衡过小容易过拟合过大则训练变慢。第三输出层用Sigmoid而不是Softmax这是多标签的核心每个类别独立判概率。第四优化器用Adam学习率设2e-5BERT的微调学习率通常不敢往大了设。4.2 训练数据生成器避免一次性载入全部样本BERT的输入包含三个向量如果一次性把全部数据转成numpy数组内存会吃紧。更合理的做法是用生成器逐批喂数据。Keras-bert本身提供了get_data_generator之类的工具但自己写生成器更可控。import numpy as np from keras.utils import Sequence class DataGenerator(Sequence): def __init__(self, file_path, batch_size16, max_len128, shuffleTrue): self.batch_size batch_size self.max_len max_len self.shuffle shuffle self.samples [] with open(file_path, r, encodingutf-8) as f: for line in f: line line.strip() if not line: continue labels_str, text line.split(\t, 1) label_ids [int(x) for x in labels_str.split(,)] self.samples.append((text, label_ids)) def __len__(self): return int(np.ceil(len(self.samples) / self.batch_size)) def __getitem__(self, idx): batch_samples self.samples[idx * self.batch_size : (idx 1) * self.batch_size] token_ids [] segment_ids [] label_vecs [] for text, label_ids in batch_samples: t_ids, s_ids encode_text(text, self.max_len) label_vec np.zeros(num_labels, dtypefloat32) for lid in label_ids: label_vec[lid] 1.0 token_ids.append(t_ids) segment_ids.append(s_ids) label_vecs.append(label_vec) # Keras-bert的模型输入是三个向量token_ids, segment_ids return [np.array(token_ids), np.array(segment_ids)], np.array(label_vecs)生成器的好处有两个一是内存占用稳定不管数据集多大都不会爆二是可以方便地插入shuffle和样本权重逻辑。注意返回的输入列表顺序必须和前面模型定义时bert_model.input的顺序一致即token_ids在前、segment_ids在后否则会报shape不匹配的错误这种错误在Keras里有时不直接报而是训练出来的模型完全失效。4.3 EarlyStopping与模型保存微调最大的风险之一是过拟合BERT参数量大训练集又往往不够多。EarlyStopping是保命手段。from keras.callbacks import EarlyStopping, ModelCheckpoint early_stop EarlyStopping( monitorval_loss, patience3, restore_best_weightsTrue, verbose1, ) checkpoint ModelCheckpoint( best_bert_multi_label.h5, monitorval_loss, save_best_onlyTrue, verbose1, ) history model.fit( DataGenerator(train.txt, batch_size16), validation_dataDataGenerator(valid.txt, batch_size16), epochs10, callbacks[early_stop, checkpoint], verbose1, )EarlyStopping的monitor用val_loss比用val_accuracy更灵敏多标签场景下准确率往往虚高因为样本大部分标签是0只要全预测0就能拿到不错的Accuracy。save_best_only配合val_loss能保证保存的是泛化最好的权重patience设3比较合理设太大会浪费时间设太小会在loss震荡时过早停下。5. 模型评估与避坑指南多标签分类的隐藏陷阱5.1 用F1而不是Accuracy评估多标签效果多标签分类里Accuracy的欺骗性非常强。假设1000条样本、10个标签每个标签平均只有10%的正例那么把所有标签全部预测为0Accuracy是90%看起来效果很好实际毫无用处。我通常按两条线评估一是每个类别的Precision、Recall、F1二是整体Micro-F1和Macro-F1。from sklearn.metrics import f1_score, precision_score, recall_score def evaluate_model(model, data_generator, threshold0.5): y_true_all [] y_pred_all [] for i in range(len(data_generator)): x, y_true data_generator[i] y_pred model.predict(x) y_true_all.append(y_true) y_pred_all.append(y_pred) y_true_all np.vstack(y_true_all) y_pred_all np.vstack(y_pred_all) y_pred_bin (y_pred_all threshold).astype(int32) print(Micro-F1:, f1_score(y_true_all, y_pred_bin, averagemicro)) print(Macro-F1:, f1_score(y_true_all, y_pred_bin, averagemacro)) print(Per-class F1:, f1_score(y_true_all, y_pred_bin, averageNone)) return y_pred_binthreshold0.5只是一个起点最后往往要单独调。预测出来的概率分布在0.3到0.7之间的样本居多降低阈值能提升Recall但牺牲Precision调高则反过来。常见的做法是在验证集上遍历0.3到0.75之间的多个阈值选Micro-F1最高的那一个。5.2 踩坑记录Keras-bert运行中的五个典型问题现象一加载模型时报错AttributeError: module tensorflow has no attribute get_default_session。原因是TensorFlow 2.x移除了大量1.x的APIKeras-bert底层用了这些接口。解决方法是代码开头先执行tf.compat.v1的兼容映射并且一定在import keras_bert之前完成。现象二训练过程中loss下降很快但验证集F1始终在0.2上下徘徊。原因是验证集和训练集的标签分布不一致或者数据划分时出现了标签泄漏。排查方法是打印验证集的标签频次如果某个标签在验证集里只出现个位数次F1波动大是必然的。解决方法是按标签做分层采样尽量保证每个标签在训练集和验证集中的比例一致。现象三预测结果几乎全是0。原因有两种一是threshold设太高模型输出的概率普遍低于0.5二是训练标签中正样本太少模型学会了全部预测0。解决方法是查看模型输出的概率分布统计最大值和均值如果最大概率都只有0.2说明模型没学好优先检查数据如果是0.5附近调整阈值。现象四微调后模型在训练集上F1接近1验证集上反而比未微调时更差。这是典型的灾难性遗忘或者过拟合。把学习率从2e-5降到1e-5加大Dropout到0.4同时检查是否忘了加EarlyStopping。现象五预测时报错Error when checking input: expected Input-Token to have shape (None, 128) but got array with shape (None, 100)。这是编码长度与模型seq_len不一致导致的。Tokenizer编码时max_len设置了100而模型构建时seq_len设了128。排查方法是统一变量把max_len定义成全局常量模型和Tokenizer都用同一个值。5.3 类别不均衡的应对策略多标签数据里类别不均衡是常态有的标签几百条正样本有的只有五十条。直接训练的话少数类基本学不出来。常见做法是给少数类更高的损失权重。# 统计每个标签的正样本数量 def compute_class_weight(file_path, num_labels): pos_count np.zeros(num_labels) total_count 0 with open(file_path, r, encodingutf-8) as f: for line in f: line line.strip() if not line: continue label_ids [int(x) for x in line.split(\t)[0].split(,)] for lid in label_ids: pos_count[lid] 1 total_count 1 # 用负样本数/正样本数作为权重抑制多数类 neg_count total_count - pos_count class_weight np.where(pos_count 0, neg_count / (pos_count 1e-6), 1.0) # 做一下归一化避免权重过大导致loss爆炸 class_weight class_weight / np.median(class_weight) return class_weight这段计算适合在训练前跑一次把它乘到每个样本的损失上。Keras里通过model.fit的sample_weight参数传入每个样本的权重向量或者在自定义损失函数里乘以class_weight。我一般倾向于后者因为前者需要在生成器里额外返回一组权重容易把代码搞乱。注意权重归一化很重要不然少数学会标签的loss被放大十倍以上训练初期梯度爆炸是常事。6. 阈值调整与部署落地把微调好的模型用起来训练完成后的模型文件是一个完整的Keras H5文件加载方式与普通Keras模型一致但要注意预测时的预处理必须与训练时完全一致。常见做法是把Tokenizer和阈值一起封装成一个类这样可以避开后续调用方对BERT不熟悉导致的各种误用。from keras.models import load_model class MultiLabelClassifier: def __init__(self, model_path, vocab_path, max_len128, threshold0.5): self.model load_model(model_path, custom_objectsNone) self.tokenizer Tokenizer(vocab_path) self.max_len max_len self.threshold threshold self.label_dict label_dict self.id_to_label {v: k for k, v in label_dict.items()} def predict(self, text): token_ids, segment_ids self.tokenizer.encode( text, max_lenself.max_len, truncate_methodLENGTH ) probs self.model.predict([np.array([token_ids]), np.array([segment_ids])])[0] pred_labels [ self.id_to_label[i] for i, prob in enumerate(probs) if prob self.threshold ] if not pred_labels: # 没有任何标签超过阈值时兜底返回概率最高的那个 top_idx int(np.argmax(probs)) pred_labels [self.id_to_label[top_idx]] return pred_labels, probs关于阈值我会在验证集上按步长0.05遍历找到Micro-F1最优的值然后把这个值和模型、标签字典一起写入配置。还有一个细节在多标签场景里threshold0.5并非默认最优实际项目中0.4或0.45更常见因为多标签的正例占比天然偏低。假如没有做这个阈值搜索上线后会发现大量本该命中的标签被漏报。上线后还要做一件事对误判样本做分类分析。我的习惯是把预测结果和真实标签都落到本地文件每周抽一次看哪些类别的错误最多、错误是漏报还是误报、以及错误样本在文本长度和关键词分布上有什么特征。多标签文本分类的迭代瓶颈通常不在模型结构而在数据标注质量和类别边界的定义是否清晰。这一套流程跑通以后换领域、换分类体系都很快唯一要重做的只有数据和标签字典模型代码几乎不用动。希望这些参数和教训能帮你少走一段弯路。本文还有配套的精品资源点击获取