ARTICLE DETAIL

建站实战干货

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

BERT微调实战:Keras实现多标签文本分类与FGM对抗训练

2026/9/12 16:22:52 拓冰建站 浏览量
BERT微调实战:Keras实现多标签文本分类与FGM对抗训练 简介一套基于Keras和Keras-bert的文本多标签分类实现方案面向需要完成课程设计或期末大作业的Python学习者也适合希望快速上手BERT微调的开发者。项目包含完整源码与文档说明代码注释详细从数据预处理、模型训练到评估预测均有清晰实现部署即可使用。资源包共10个文件包含4个Python脚本负责训练、评估、预测及对抗训练工具、2个CSV数据文件、2个TXT说明与配置文本、1个Markdown说明文档及其他工程配置整体压缩包仅1.01MB结构紧凑便于学习。目前已有137人学习参考兼具实用性与参考价值。通过对照源码和文档可掌握基于Keras-bert进行多标签分类的完整流程也可从中了解FGM对抗训练、模型调优等进阶技巧是完成NLP相关课设作业的可靠参考。1. 从多标签文本分类到BERT微调为什么这个Keras实现值得拆开看文本多标签分类难点并不在模型结构而在数据处理和训练细节。这个项目用Keras和keras-bert把中文BERT预训练权重加载、多标签微调、FGM对抗训练、评估预测串成一条完整链路。最难得的是它没有过度封装一份train.csv、一个vocab.txt、一个chinese_L-12_H-768_A-12目录就能在GPU上训练出可用的多标签模型。如果你正在做期末大作业或课程设计想快速验证BERT微调在文本分类上的效果同时也想搞清楚input_ids、segment_ids、sigmoid输出层这些概念怎么落地这套源码比纯看教程有用得多。另外它对BERT微调的学习率设置、阈值选择、版本兼容问题也有完整代码注释适合作为入门到实战的跳板。2. 数据集与预处理把原始文本变成BERT可接受的input_ids和label矩阵BERT微调的第一步不是搭模型而是把原始文本和标签处理成BERT能理解的数字输入。项目里数据文件只有train.csv、test.csv和vocab.txt没有现成的pickle或npy所以预处理逻辑集中在model_train.py里。这里先处理标签再处理文本最后用生成器按批次喂给模型。2.1 标签编码MultiLabelBinarizer的使用前提多标签分类的输出层是sigmoid每个输出神经元对应一个标签所以标签向量必须是长度等于标签总数的01序列。sklearn的MultiLabelBinarizer是干这个的标准工具import pandas as pd from sklearn.preprocessing import MultiLabelBinarizer df pd.read_csv(data/train.csv) df[tags] df[tags].str.split( ) mlb MultiLabelBinarizer() y mlb.fit_transform(df[tags]) print(样本数:, y.shape[0], 标签数:, y.shape[1])fit_transform会先扫描所有样本的标签列表构建标签全集的排序列表再把每行文本的标签子集映射为1其余为0。这里的mlb.classes_保存了标签与索引的对应关系后面预测时需要用它把输出索引还原成标签名。注意CSV中的分隔符是空格如果是逗号分隔需要换成,。有一个很隐蔽的坑测试集可能会包含训练集没出现过的标签所以一定只用训练集去fit测试集只transform否则标签空间会被测试集污染出现信息泄露。第一次写多标签项目时我在这里挂过后来习惯先train_test_split再fit。2.2 keras-bert的Tokenizer字级切分与特殊token中文BERT的vocab.txt是字粒度词表keras_bert的Tokenizer读取后能自动把文本切成字并加上[CLS]和[SEP]from keras_bert import Tokenizer token_dict {} with open(chinese_L-12_H-768_A-12/vocab.txt, r, encodingutf-8) as reader: for i, line in enumerate(reader): token line.strip() token_dict[token] i tokenizer Tokenizer(token_dict) # 单条文本的处理 ids, segments tokenizer.encode(人工智能与文本分类, max_len128) print(ids[:10]) print(segments[:10])encode返回两个数组ids是每个token在词表中的索引segments是句子分段标识单句全为0。max_len128表示序列长度不足128就填充到128超过就截断。keras-bert会强制保留首尾的[CLS]和[SEP]所以实际正文最多占126个token。如果文本较长max_len设成256效果更好但显存占用会上升。项目里用128作为默认值适合大多数短文本分类场景。2.3 用生成器构建训练数据避免内存爆炸当数据量达到几十万条时一次性把所有token ids都放进内存会占用大量空间。常见做法是用生成器按批次喂数据import numpy as np def data_generator(df, y, tokenizer, max_len, batch_size): n len(df) while True: for i in range(0, n, batch_size): batch_x1 [] batch_x2 [] batch_y y[i:ibatch_size] for text in df[text].iloc[i:ibatch_size]: ids, seg tokenizer.encode(text, max_len) batch_x1.append(ids) batch_x2.append(seg) yield [np.array(batch_x1), np.array(batch_x2)], batch_y这里y是前面mlb.transform得到的稠密numpy数组维度是[样本数, 标签数]。如果标签数量特别大且稀疏可以把y改成稀疏矩阵但计算损失时还要转稠密实际收益不大直接存0/1矩阵更省心。生成器配合model.fit时需要设置steps_per_epoch为ceil(n / batch_size)否则while True会让生成器无限跑下去。这个坑几乎每个第一次用生成器的人都会踩项目源码注释里也专门提到了。2.4 预处理流程一览步骤输入输出关键点读取CSVtrain.csvDataFrame确认tags分隔符标签编码标签字符串列表numpy 01矩阵只用训练集fit词表加载vocab.txttoken_dict保持原有行序文本切分原始文本ids, segmentsmax_len统一批次生成文本和标签[x1, x2], y需要配合steps_per_epoch这个表格基本就是训练脚本前80行的逻辑。数据准备好以后接下来进入模型部分。3. 模型构建与微调在预训练权重上做单层分类头模型部分的核心是利用keras_bert的load_trained_model_from_checkpoint加载中文BERT预训练权重然后在其输出之上拼接一个全连接层。这个环节要理解两个点BERT输出序列怎么变成一条文本向量以及微调时怎么控制学习率。3.1 用keras-bert加载预训练模型并添加多标签输出层from keras_bert import load_trained_model_from_checkpoint from keras.layers import Dense, Dropout, Lambda from keras.models import Model bert_model load_trained_model_from_checkpoint( config_filechinese_L-12_H-768_A-12/bert_config.json, checkpoint_filechinese_L-12_H-768_A-12/bert_model.ckpt, seq_len128, trainableTrue ) # bert_model.input 是 [input_ids, segment_ids] input_ids bert_model.input[0] segments bert_model.input[1] output bert_model.output # (batch, 128, 768) cls Lambda(lambda x: x[:, 0])(output) # 取[CLS]向量 cls Dropout(0.2)(cls) pred Dense(label_num, activationsigmoid)(cls) model Model(inputs[input_ids, segments], outputspred)bert_model.output的形状是[batch_size, seq_len, hidden_size]中文BERT base的hidden_size是768。多标签分类只需要整条文本的语义表示所以取[CLS]位置x[:, 0]作为文本向量。trainableTrue表示微调阶段BERT的底层权重也参与梯度更新这样能针对当前领域做适应。如果训练数据比较少可以设成trainableFalse只训练顶层Dense但效果会差不少。这段代码里还有一个容易忽略的细节旧版keras-bert的load_trained_model_from_checkpoint返回的模型可能带有训练损耗层直接使用会报输出层不匹配的错误。遇到时用Model重新包装一次或者干脆升级到0.88.0以上版本。3.2 FGM对抗训练在Embedding上添加扰动FGMFast Gradient Method是一种轻量对抗训练它在Embedding层输出上加上与梯度方向一致的扰动让模型学会对微小输入变化不敏感。项目里的FGM.py就是这个用途。由于Keras高层训练的梯度流被封装在compile和fit里直接在回调里修改embedding很麻烦我一般用TensorFlow 2的GradientTape写自定义循环import tensorflow as tf class FGM: def __init__(self, model): self.model model self.embedding_weights [] for layer in self.model.layers: if embedding in layer.name: self.embedding_weights.append(layer.embeddings) def attack(self, epsilon0.5): # 为每个embedding权重保存原始值并加上梯度方向扰动 self.backup [] for emb in self.embedding_weights: grad tf.gradients(self.model.loss, emb)[0] delta epsilon * grad / tf.norm(grad, ord2) self.backup.append(emb.value()) emb.assign_add(delta) def restore(self): for emb, val in zip(self.embedding_weights, self.backup): emb.assign(val)上面是符号式写法实际训练里我习惯这样optimizer tf.keras.optimizers.Adam(learning_rate3e-5) fgm FGM(model) for step, (x, y) in enumerate(train_loader): with tf.GradientTape() as tape: pred model(x, trainingTrue) loss tf.reduce_mean(tf.keras.losses.binary_crossentropy(y, pred)) grads tape.gradient(loss, model.trainable_variables) fgm.attack(epsilon0.5) with tf.GradientTape() as tape2: pred_adv model(x, trainingTrue) loss_adv tf.reduce_mean(tf.keras.losses.binary_crossentropy(y, pred_adv)) grads_adv tape2.gradient(loss_adv, model.trainable_variables) fgm.restore() final_grads [g1 g2 for g1, g2 in zip(grads, grads_adv)] optimizer.apply_gradients(zip(final_grads, model.trainable_variables))逻辑说明先算一次正常梯度调用fgm.attack把embedding向梯度方向偏移一小步再算一次对抗样本的梯度最终用两份梯度之和更新参数最后恢复原始embedding。epsilon0.5是常见值超过1.0容易让预训练语义受到破坏。如果你只是为课程设计跑通流程完全可以不用FGM但代码里保留它会让项目在答辩时更出彩。3.3 微调超参数参考超参数建议值注意optimizerAdam别用SGD收敛慢learning_rate2e-5 ~ 5e-5超过1e-4基本不收敛max_len128 ~ 256与显存和文本长度线性相关batch_size16 ~ 32显存不足先减这里epochs3 ~ 5多标签任务收敛较快FGM epsilon0.5过大导致训练不稳定BERT微调的核心是低学习率因为预训练权重已经是一个良好的语义空间微调只是在局部调整。如果学习率设成1e-3很快会出现梯度爆炸或损失震荡。如果追求更好效果可以用warmup策略前10%的steps把学习率从0逐渐升到3e-5后面再线性衰减。这个项目没有内置warmup但自己加也不复杂。4. 评估与预测多标签的评价指标和推理输出训练完成后model_evaluate.py和model_predict.py分别负责在测试集上计算指标和对新文本输出标签。这两个脚本决定了模型能不能真正落地也是答辩时老师喜欢追问的地方。4.1 多标签评价指标不要只看accuracy多标签场景下单标签分类的全对才算对过于严格没有参考价值。通常用micro-F1、macro-F1和汉明损失。sklearn实现如下from sklearn.metrics import f1_score, hamming_loss y_pred (model.predict([x1_test, x2_test]) 0.5).astype(int) print(micro-F1:, f1_score(y_test, y_pred, averagemicro)) print(macro-F1:, f1_score(y_test, y_pred, averagemacro)) print(hamming loss:, hamming_loss(y_test, y_pred))micro-F1把每个样本的每个标签看作单独的一次预测统计全局的精确率和召回率再计算F1因此受样本量大的标签影响更大。macro-F1先对每个标签单独算F1再求算术平均在标签不均衡时通常低于micro-F1。hamming loss表示样本-标签对预测错误的平均比例0表示完全正确它允许部分标签预测错误适合多标签任务。注意model.predict返回的是sigmoid输出概率必须用阈值转成0/1。这里用0.5但实际项目往往需要调整阈值第5章会讲怎么搜索。4.2 预测脚本把输出索引还原成标签名称model_predict.py的核心逻辑如下import numpy as np def predict_tags(text, threshold0.5): ids, seg tokenizer.encode(text, max_len128) prob model.predict([np.array([ids]), np.array([seg])])[0] indices np.where(prob threshold)[0] tags [mlb.classes_[i] for i in indices] return tags, probtokenizer.encode得到的是单条序列但模型要求输入带batch维度所以用np.array([ids])包一层。prob是长度为标签总数的数组indices是大于阈值的下标再通过mlb.classes_映射回原始标签名。返回值带上prob方便查看每个候选标签的置信度。预测阶段最容易踩的坑是mlb必须是训练阶段fit好的那个对象不能在预测脚本里重新读train.csv再次fit。因为一旦标签顺序变了输出下标和标签名的对应关系就全错了。项目里应该在训练结束时把mlb保存成pickle预测时直接加载。这里建议用joblib.dump(mlb, mlb.pkl)简单方便。4.3 验证集划分让每个标签都有机会被看到项目没有提供独立的验证集model_train.py里应该会从train.csv切出一部分当验证。多标签分层抽样比单标签麻烦如果直接train_test_split不设stratify某个小标签可能只出现在验证集中训练时模型从没见过这个标签自然对它预测为0这会严重拉低macro-F1。简单可靠的做法是按每个样本的标签数量分层from sklearn.model_selection import train_test_split label_count y.sum(axis1) train_idx, val_idx train_test_split( np.arange(len(df)), test_size0.1, random_state42, stratifylabel_count )这样做能保证训练集和验证集里每条样本带多少标签的分布一致但不保证每个标签的类别均衡。如果想做得更严谨可以加一个检查如果某个标签在训练集中的频次少于10就把该标签对应的样本强制移入训练集。课程设计里能用上面这个代码已经足够展示多标签划分的思路了。5. 部署细节与易踩的坑版本匹配、显存优化和阈值调优最后聊实际跑项目时最容易卡住的三个问题。这些问题不解决代码再完整也跑不起来。5.1 keras-bert与TensorFlow版本匹配keras-bert停更较早内部使用Keras 2.x的API。如果你现在的TensorFlow版本很高会出现AttributeError: module keras.engine has no attribute Layer一类的错误。原因就是from keras和from tensorflow.keras混用。我推荐的做法是创建独立conda环境固定用TensorFlow 1.15和Keras 2.3.1这一代组合conda create -n bert-multi python3.6 conda activate bert-multi pip install tensorflow-gpu1.15.0 pip install keras2.3.1 pip install keras-bert这里不需要指定keras-bert的具体版本PyPI上的最新版就能和上面的组合工作。安装完成后先做一次加载验证from keras_bert import load_trained_model_from_checkpoint print(load ok)如果直接报ImportError: cannot import name load_trained_model_from_checkpoint大概率是keras和tensorflow版本冲突按上面的组合重建环境即可。5.2 显存控制按需增长否则直接OOMBERT base模型本身加上梯度、优化器状态单卡6GB显存会非常紧张。TensorFlow的默认行为是一次性申请全部剩余显存如果机器上有别人在训练模型你的程序会直接OOM。训练脚本开头加这段# TF 1.x import tensorflow as tf config tf.ConfigProto() config.gpu_options.allow_growth True tf.keras.backend.set_session(tf.Session(configconfig))如果用的是TF 2.xphysical_devices tf.config.list_physical_devices(GPU) if physical_devices: tf.config.experimental.set_memory_growth(physical_devices[0], True)设置要放在import keras_bert之前才生效。训练时如果OOM优先把batch_size从32降为16而不是先改max_len。因为batch_size直接影响梯度张量和优化器状态的显存占用改max_len只能减少一部分序列维度。5.3 用验证集自动搜索最优阈值多标签分类的输出阈值不一定要用0.5。如果你更看重精确率可以把阈值调高更看重召回率就调低。最简单的方法是在验证集上做一次线性搜索best_f1 0 best_threshold 0.5 val_prob model.predict([val_x1, val_x2]) for threshold in np.arange(0.1, 0.9, 0.05): val_pred (val_prob threshold).astype(int) f1 f1_score(val_y, val_pred, averagemicro) if f1 best_f1: best_f1 f1 best_threshold threshold print(best threshold:, best_threshold, F1:, best_f1)遍历0.1到0.9的候选阈值每次把概率转成01矩阵计算micro-F1取最高分对应的阈值。搜索完成后把best_threshold写进预测脚本的默认参数或者存到config文件里。标签特别多且数据量很大时也可以为每个标签单独搜索阈值但那样过拟合验证集的风险更高全局单阈值在大多数场景下够用。跑通项目后你可以把输出层改成两层MLP或者加入文本长度作为辅助特征对比一下F1变化这会比单纯跑通一遍更有收获。本文还有配套的精品资源点击获取