简介:一套基于Keras与Keras-bert的文本多标签分类实践项目,面向有一定NLP基础、希望快速上手BERT微调的开发者。项目以2020语言与智能技术竞赛的事件抽取数据为样例,展示了多标签分类模型的完整搭建与应用思路,适合作为课程设计或项目参考。压缩包共10个文件,大小约1.01MB,包含4个Python脚本(模型训练、评估、预测及FGM对抗训练)、2个CSV数据集(训练集与测试集)、2个TXT文件(BERT中文词表与依赖清单),另有README说明与gitignore配置,结构清晰,便于按需取用。已有1634人学习下载。借助这份资源可掌握Keras-bert加载预训练模型、微调BERT并完成多标签文本分类的核心流程,同时可借鉴竞赛数据的预处理方式与对抗训练技巧,节省自行整理数据和排查环境依赖的时间。
1. 多标签文本分类为什么要看得上Keras-bert这条路
多标签文本分类往往被当成多分类来写,但真实项目里,一条新闻可以同时是“财经+民生”,一个工单可以同时命中“网络故障”和“退款投诉”,这跟二选一的单标签任务完全是两种学习问题。用Keras和Keras-bert把BERT预训练权重加载进来做微调,是这类任务里启动成本最低、调参路径最清晰的方案之一:你不需要自己训练词向量,也不需要从头搭Transformer,只要把文本编码成BERT认识的输入,接一个多标签输出层,用两阶段微调把参数调顺,在几千条标注数据上就能拿到一个可上线的分类服务。这篇笔记写给想在项目里快速跑通文本多标签分类的工程师,也写给被各种大模型微调工具绕花眼、想走一条确定路径的新手。我们这次只把Keras-bert这条路走通,把参数和坑讲透。
2. 把多标签任务翻译成BERT能学的东西:数据预处理与编码
微调BERT之前,最容易被低估的是数据准备。文本多标签分类的标签不是“第几类”,而是“命中哪几个类”,这一点会直接影响输出层设计、损失函数和评估指标。如果一开始就按多分类的思路处理,后面每一步都会跟着错。
2.1 多标签不是多分类:sigmoid输出、multi-hot标签和F1评估
多分类任务里,一个样本只能属于一个类别,标签是one-hot向量,输出层用softmax,损失函数用categorical_crossentropy。多标签任务里,一个样本可以命中多个类别,标签是multi-hot向量,比如“网络故障+退款投诉”对应的标签向量是[1, 0, 1, 0],输出层必须用sigmoid,让每个输出节点独立判断“这个标签是否命中”,损失函数用binary_crossentropy。
这里有一个常见的翻车点:直接把多标签任务当成多分类,最后softmax把所有标签概率归一化成总和1,模型被迫在“网络故障”和“退款投诉”之间二选一,验证集F1自然上不去。先把标签编码这一步做对:
from sklearn.preprocessing import MultiLabelBinarizer label_list = [ ['网络故障', '退款投诉'], ['网络故障'], ['账户问题', '退款投诉'], ] mlb = MultiLabelBinarizer() y = mlb.fit_transform(label_list) print(y.shape) # (3, 3) print(mlb.classes_) # ['账户问题' '网络故障' '退款投诉']这里MultiLabelBinarizer会把每条样本的标签列表展开成固定列数的multi-hot矩阵,列顺序由classes_决定。训练时y直接喂给sigmoid输出层,预测时输出的是每个标签的概率,再用阈值决定是否命中。
评估指标也别沿用accuracy。多标签场景下accuracy指“所有标签全部预测正确才算对”,在标签数多、样本稀疏时几乎永远是0。我一般用macro F1和每个标签单独的precision/recall来评估,线上对每条样本要求严格一点时才看精确匹配率。还有一点要注意:如果某个标签在训练集里只出现十几次,最好在验证集里单独看一眼它的F1,别被整体平均掩盖掉。
2.2 BERT预训练参数与词表准备:keras-bert要从目录里读什么
用keras-bert微调,需要先拿到一份中文BERT预训练参数。常见做法是下载开源的中文BERT模型,解压后目录里通常包含三类文件:vocab.txt是词表,bert_config.json是模型结构配置,bert_model.ckpt或对应的weights文件是预训练权重。keras-bert加载时,词表和权重是分开用的,词表交给Tokenizer,权重目录或文件路径交给load_bet_model。
from keras_bert import load_vocabulary, Tokenizer, load_bet_model vocab_path = 'chinese_bert_wwm/vocab.txt' bert_ckpt_path = 'chinese_bert_wwm/bert_model.ckpt' token_dict = load_vocabulary(vocab_path) tokenizer = Tokenizer(token_dict) bert_model = load_bet_model( bert_ckpt_path, seq_len=128, trainable=False )load_bet_model的第一个参数可以直接传.ckpt文件路径,某些版本的keras-bert也支持传包含权重文件的目录。seq_len是我们自己定的最大序列长度,BERT内部最多支持512个token,实际分类任务通常取64到256。trainable=False表示加载预训练权重时先把BERT层全部冻结,这个开关在二阶段微调里很有用,后面会展开说。
加载完成后,建议先打印一下模型结构和输入名,确认输入顺序。keras-bert的BERT模型输入是两个张量:第一个是token ids,第二个是segment ids。很多初学者在这里把输入顺序搞反,训练时loss不降,还以为是模型问题。print(bert_model.inputs)一下,看到类似[Input-Token, Input-Segment]的顺序就稳了。
2.3 用Keras-bert的Tokenizer编码文本:从可变长句子到定长矩阵
BERT不能直接吃原始字符串,需要先把文本转成token ids和segment ids。keras-bert的Tokenizer.encode(text, max_len=seq_len)会完成切词、添加[CLS]和[SEP]、截断和padding,返回两个等长数组:
ids, segs = tokenizer.encode('账户无法登录,想退款', max_len=32) print(ids[:8]) # [101, 1908, 3359, ... , 102, 0, 0, 0] print(segs[:8]) # [0, 0, 0, 0, 0, 0, 0, 0]ids里101是[CLS]的id,102是[SEP]的id,长度不足32的部分用0补齐。segments在单句分类任务里全部是0,表示整段文本都属于句子A。如果做句对任务,第二个句子的segment id会是1,但文本分类用不到。
训练时不可能一条条调用encode,那样太慢。我一般写一个生成器,在batch内部批量编码,喂给model.fit:
import numpy as np def build_data_generator(texts, labels, tokenizer, seq_len, batch_size=32): n = len(texts) order = np.arange(n) def gen(): while True: np.random.shuffle(order) for start in range(0, n, batch_size): batch_idx = order[start:start + batch_size] x1 = np.zeros((len(batch_idx), seq_len), dtype=np.int32) x2 = np.zeros((len(batch_idx), seq_len), dtype=np.int32) for i, idx in enumerate(batch_idx): ids, segs = tokenizer.encode(texts[idx], max_len=seq_len) x1[i, :len(ids)] = ids x2[i, :len(segs)] = segs yield [x1, x2], labels[batch_idx] return gen()这个生成器有几个关键细节。x1和x2是int32类型的定长矩阵,形状是(batch_size, seq_len),每个token id不能超过BERT词表范围,所以dtype用int32足够。生成器每次shuffle一次顺序,然后按batch yield,fit时指定steps_per_epoch。这里有一个容易被忽略的点:如果语料里存在超长文本,tokenizer.encode内部已经做了截断,不需要我们手动处理,但截断会丢掉后半段信息。如果业务上后半段同样关键,比如合同文本的条款集中在末尾,就要考虑分段编码或者改用更长上下文的模型,这是另一个话题了。
多标签分类的数据集划分也有讲究。sklearn的train_test_split直接支持stratify,但多标签的multi-hot矩阵不适合直接做分层抽样,因为一条样本同时属于多个类,无法简单映射到单一类别。我一般先按业务上最主要的一个标签分层,或者干脆按时间顺序切分,保证验证集和线上数据的分布更接近。
3. 用Keras和Keras-bert搭建微调模型:冻结、池化、接多标签输出层
数据准备好了,接下来是模型搭建。微调这个词听起来玄学,本质就是:把BERT已经学到的语言表示拿过来,在它的基础上加一层很薄的分类头,然后用你的业务数据反向传播更新参数。BERT部分可以整体微调,也可以部分冻结,这就是我们说的微调策略。
3.1 load_bet_model返回的到底是什么:输入张量、输出shape与可训练开关
load_bet_model返回的是一个Keras Model对象,输入是[Input-Token, Input-Segment],输出是BERT最后一层的序列特征。以BERT-Base为例,hidden size是768,所以bert_model.output的形状是(None, seq_len, 768),也就是每个token位置都有一个768维的向量。
很多教程会直接取[CLS]位置的向量作为整句话的表示,但多标签场景下我会更推荐先看序列输出,自己做池化,而不是直接用BERT内部的NSP池化层。原因很简单:BERT的[CLS]向量在预训练时主要服务于“两句是否连续”的任务,它对整句话的语义有代表性,但对多标签这种“一句话里同时存在多个主题”的情况,序列平均池化往往更稳。
from keras.layers import Dense, Dropout, GlobalAveragePooling1D from keras.models import Model from keras.optimizers import Adam from keras.metrics import AUC seq_len = 128 num_labels = len(mlb.classes_) bert_model = load_bet_model(bert_ckpt_path, seq_len=seq_len, trainable=False) seq_output = bert_model.output # (None, seq_len, 768) pooled = GlobalAveragePooling1D()(seq_output) # (None, 768) pooled = Dropout(0.3)(pooled) output = Dense(num_labels, activation='sigmoid')(pooled) model = Model(bert_model.inputs, output) model.compile( optimizer=Adam(learning_rate=2e-5), loss='binary_crossentropy', metrics=[AUC(name='auc')] ) model.summary()这里GlobalAveragePooling1D会对seq_len维做平均,把每个token位置的768维向量压成一个768维的句向量。Dropout(0.3)是防止分类头过拟合,多标签任务里标签多、正负样本不均,Dropout加在池化之后、输出层之前,是标准做法。输出层激活函数必须用sigmoid,每个节点独立输出0到1之间的概率。
为什么多标签不用softmax?softmax强制所有标签概率加起来等于1,而多标签的语义是每个标签独立参与判定。一个工单可以既是“网络故障”又是“退款投诉”,这两个概率应该可以同时高,也可以同时低。sigmoid给每个标签独立的概率,配合binary_crossentropy,模型才能学到这种“多选多”的分布。
3.2 多标签分类头怎么接:平均池化、Dropout和输出层设计
分类头虽然只有几层,但接法直接决定模型能不能收敛。除了GlobalAveragePooling1D,还可以用GlobalMaxPooling1D或者直接取CLS位置。
from keras.layers import Lambda # 取CLS位置向量,shape从(None, seq_len, 768)变为(None, 768) cls_output = Lambda(lambda x: x[:, 0, :])(seq_output)这两种接法我都试过。取CLS在单标签情感分类上表现很好,但在多标签场景里,如果一句话同时涉及两个主题,CLS向量容易偏向其中占主导的主题,另一个主题的特征被稀释。平均池化把所有token的信息均匀揉进去,对“多主题并存”的文本更友好。如果你的标签有明显的强相关结构,比如“A出现时B大概率也出现”,也可以尝试max pooling,它能抓住最强烈的信号。没有绝对优劣,我在项目里的习惯是先平均池化跑一版基线,再花半天做CLS和max的对比实验。
还有一个细节:分类头初始化的权重范围。Dense层默认的glorot_uniform初始化对BERT输出这种已经归一化的特征通常是合适的,不需要额外改动。真正要改的是Dropout比例,如果训练集只有两三千条,Dropout可以加到0.4;如果数据过万,0.2到0.3就够。多标签任务里标签之间的共现信息很重要,Dropout太大会把共现关系打散。
3.3 二阶段微调流程:先训分类头再解冻BERT,为什么一定要分两步
直接加载BERT后立即全部解冻、用一个比较大的学习率训练,loss很容易在第一个epoch就冲上天。这是因为BERT的预训练权重已经在语言模型任务上收敛得很好了,但新加的Dense分类头是随机初始化的,两者参数尺度不匹配。随机初始化的分类头在初期会输出乱七八糟的梯度,如果BERT主干的权重也跟着一起大步更新,就会把预训练学到的语言表示冲坏,这属于典型的“灾难性遗忘”。
所以二阶段微调几乎是BERT文本分类项目的标准做法。第一阶段冻结BERT全部层,只训练分类头,让分类头先适配BERT的输出特征;第二阶段再解冻BERT层,用一个很小的学习率整体微调。
# 阶段一:只训练分类头 for layer in bert_model.layers: layer.trainable = False model.compile( optimizer=Adam(learning_rate=1e-3), loss='binary_crossentropy', metrics=[AUC(name='auc')] ) train_gen = build_data_generator(train_texts, train_labels, tokenizer, seq_len, batch_size=32) val_gen = build_data_generator(val_texts, val_labels, tokenizer, seq_len, batch_size=32) model.fit( train_gen, steps_per_epoch=len(train_texts) // 32, validation_data=val_gen, validation_steps=len(val_texts) // 32, epochs=3 ) # 阶段二:解冻BERT,统一微调 for layer in bert_model.layers: layer.trainable = True model.compile( optimizer=Adam(learning_rate=2e-5), loss='binary_crossentropy', metrics=[AUC(name='auc')] )阶段一的学习率可以给到1e-3,因为此时只有一层Dense在更新,不用担心破坏BERT。阶段二切换到2e-5,这是BERT微调最常用的学习率数量级。2e-5这个数字不是玄学,它来自BERT原始论文的经验:预训练模型的参数在微调时只能被很轻微地扰动,学习率超过5e-5,某些batch上就会出现loss暴涨。如果你用的是更大规模的预训练模型,学习率可以再降一半。
阶段一跑几个epoch就够了,判断标准是训练集AUC明显起来、不再剧烈波动。阶段二通常需要更多epoch,但也不要盲目跑几十轮,BERT部分在少量标注数据上微调太久会过拟合。一般5到10个epoch就能看到验证集指标开始回落,这时候要靠回调来截断。
4. 微调BERT的超参数与回调配置:训练过程怎么控制和判断
模型结构搭好之后,训练环节决定最终效果。BERT微调的超参数范围其实很窄,不像训练词向量那样可以大幅调整,关键就几个:学习率、batch size、epochs、序列长度。把这些控制好,再配合回调,训练过程基本不会跑偏。
4.1 学习率、batch size与epochs:BERT微调的起步档位
BERT微调的学习率建议从2e-5起步,1e-5和3e-5也都是常见选择。如果训练集很小,比如不到5000条,用1e-5更稳;如果数据量超过2万条,3e-5到5e-5可以适当加速收敛。使用Adam优化器时,默认的beta参数不需要动,唯一建议改动的是gradient clipping,后面讲坑的时候细说。
batch size受显存限制,常见的BERT-Base在seq_len=128时,batch size可以开到16到32。batch size越小,梯度噪声越大,BERT微调时表现为loss曲线震荡;我一般用16起步,显存不够就把seq_len降到64,而不是强行减小batch size。seq_len降到多少直接看业务文本长度分布,先统计一下语料长度,如果95%的文本都在100个token以内,seq_len=128就足够了,没必要硬上256。
epochs没有标准答案。阶段一固定3到5轮,阶段二看好验证集。很多初学着在阶段二跑满20个epoch,结果训练loss很低、验证AUC却在第6轮开始回落,这就是过拟合。多标签场景里标签一多,过拟合更隐蔽,因为整体AUC可能还在缓慢上升,但某个低频标签的F1已经开始崩了。所以训练过程中要盯每个标签的验证指标,而不是只看一个平均值。
4.2 用回调控住训练过程:EarlyStopping、ReduceLROnPlateau和模型存档
Keras的回调机制在BERT微调里特别有用。EarlyStopping监控验证集loss,连续几个epoch不下降就自动停;ReduceLROnPlateau在loss平台期自动把学习率降一半,给模型二次微调的机会;ModelCheckpoint把每个epoch里验证集最好的权重保存下来,防止最后几轮过拟合破坏了前面的最优状态。
from keras.callbacks import EarlyStopping, ReduceLROnPlateau, ModelCheckpoint callbacks = [ EarlyStopping( monitor='val_loss', patience=5, restore_best_weights=True ), ReduceLROnPlateau( monitor='val_loss', factor=0.5, patience=2, min_lr=1e-6 ), ModelCheckpoint( 'best_multi_label_model.h5', monitor='val_loss', save_best_only=True ) ] model.fit( train_gen, steps_per_epoch=len(train_texts) // 32, validation_data=val_gen, validation_steps=len(val_texts) // 32, epochs=15, callbacks=callbacks )EarlyStopping的patience我一般设5,也就是连续5个epoch验证loss不降就停。patience太小的风险是错过后面某个epoch的突然下降,太大的风险是浪费时间。ReduceLROnPlateau的factor=0.5表示学习率每次减半,min_lr设在1e-6防止减到零。有一个细节:阶段一里不要用ReduceLROnPlateau,因为分类头本来就在快速收敛,学习率减半反而拖慢节奏;阶段二再用。
ModelCheckpoint的monitor建议用val_loss而不是val_auc。AUC偶尔会在某些epoch跳高后又跌回来,val_loss更平滑。save_best_only=True保证磁盘上始终只有一份最优权重,避免训练几十轮后磁盘被一堆h5文件塞满。
4.3 训练结束不等于调完:验证集上的多标签阈值搜索
训练完的模型输出的是概率,不是最终标签。多标签任务里,把概率大于0.5判定为命中是最自然的想法,但实际效果通常不行。原因有两个:一是标签在训练集中的先验频率不同,高频标签的输出概率天然偏高,低频标签即使命中也只输出0.3左右;二是batch size小、样本不均衡时,sigmoid输出存在偏移。多标签分类的工程实践里,阈值必须单独在验证集上搜索。
from sklearn.metrics import f1_score # pred_val: 模型在验证集上的输出概率, shape (N, num_labels) # y_val: 验证集multi-hot标签, shape (N, num_labels) best_thresholds = [] for label_idx in range(num_labels): best_f1 = -1.0 best_thresh = 0.5 for thr in np.arange(0.05, 0.95, 0.05): pred_binary = (pred_val[:, label_idx] >= thr).astype(int) f1 = f1_score(y_val[:, label_idx], pred_binary, zero_division=0) if f1 > best_f1: best_f1 = f1 best_thresh = thr best_thresholds.append(best_thresh)这段代码对每个标签单独搜索最优阈值,搜索范围0.05到0.95、步长0.05。zero_division=0是为了避免某个标签在验证集上完全没有预测为正时sklearn报警告。搜索出来的阈值通常会呈现出明显的规律:高频标签阈值低,因为模型倾向于保守;低频标签阈值也低,因为模型本来就不敢输出高分,要放低门槛才能召回。
阈值搜索不能拿到测试集或线上数据上去做,否则就是把测试集当成训练集的一部分,评估结果虚高。正确做法是阈值只在验证集上搜索一次,然后固定下来,再用测试集做最终评估。线上推理时阈值是写死在配置里的,不能每次请求都重新搜索。
5. BERT多标签微调的常见问题与排查:五个实战翻车点
这一章列几个我在实际项目里真实踩过、也帮别人排查过的坑。每个现象都见过不止一次,写下来当个检查清单。
5.1 坑一:环境依赖打架,keras-bert和TensorFlow版本对不上
现象:pip install keras-bert之后,import keras_bert直接报错,或者加载模型时报TypeError: 'str' object is not callable;训练时又冒出维度不匹配的诡异报错。
原因:keras-bert是老牌的Keras库,它同时兼容原生Keras和tf.keras,但pip会把keras当作依赖一并装进来。如果你的项目用的是TensorFlow 2.x,而环境里又装了独立的keras包,两个Keras的符号互相覆盖,加载BERT模型时就会出现这种灵异报错。
解决:先确认项目到底走哪一套接口。我建议在TensorFlow 2.x环境下统一用tf.keras,代码里from keras.layers要改成from tensorflow.keras.layers,from keras_bert里的load_bet_model和Tokenizer保持不动,因为keras-bert底层会自动对接当前Keras环境。安装依赖时,把独立keras版本固定住,或者干脆pip uninstall keras,强制走tf.keras。配环境不是越新越好,keras-bert项目更新不频繁,它和某个TensorFlow版本配合良好,就不要轻易升级TensorFlow。
5.2 坑二:验证集F1一直是0,但训练loss在降
现象:训练过程看起来正常,loss一路向下,AUC也在0.8以上,但验证集的F1算出来是0,或者低得离谱。
原因:多半不是模型问题,而是阈值和评估口径问题。多标签验证集上如果直接用0.5作为阈值,而某个标签在验证集里的正样本比例只有5%,模型输出的概率普遍在0.1到0.4之间,那么阈值0.5会把所有样本都判为负,F1自然是0。低频多标签场景这是常态,不是bug。
解决:先别动模型,把训练结束后的验证集概率导出来,画一个直方图看看分布。然后按4.3节的阈值搜索方法跑一遍,再算F1。另外一个常见原因是验证集的标签划分有误,val_labels的形状和应用在y_val上是one-hot还是multi-hot不一致,导致所有样本都判错。我习惯在训练前打印一下y_val.sum(axis=0)看一眼每个标签在验证集里的出现次数,如果某个标签是0次,它在F1评估里就永远是0。
5.3 坑三:解冻BERT后loss直接发散,甚至出现NaN
现象:阶段一训练正常,阶段二解冻BERT后,第一个epoch的loss先降后暴涨,有些batch直接变成NaN。
原因:最常见的有两个。一是学习率太大,BERT微调的解冻阶段学习率必须控制在5e-5以内,2e-5是安全值,很多人沿用阶段一的1e-3直接翻车。二是数据里存在异常样本,比如文本本身是一串乱码、标签全零,或者文本过长被截断后只剩下一堆无意义的碎片token。解冻后BERT参数被扰动,这些异常样本的梯度就会把loss推上NaN。
解决:先把学习率降到2e-5,并在Adam里加上梯度裁剪,clipnorm=1.0,防止极端梯度冲垮参数。然后检查训练集里是否有空字符串、重复文本、全零标签的样本,把明显异常的过滤掉。最后在阶段二第一个epoch用模型预测几个batch,确认输出的概率区间正常,再继续训练。
5.4 坑四:预测概率全挤在0.5附近,模型像没学过一样
现象:训练完所有的输出概率都在0.4到0.6之间,看不出哪个标签有明确的命中倾向。
原因:这个现象在二阶段微调里经常出现,核心原因是分类头没有真正训练起来。阶段一只跑了1个epoch,分类头的权重还没从随机初始化收敛过来,直接进入阶段二,BERT层的微调梯度淹没了分类头的学习信号。另一个可能原因是在池化时取错位置,比如取到了padding区域的向量,padding部分的特征全是0,池化结果被稀释。
解决:回到阶段一,把epochs增加到3到5,观察训练集AUC是否明显升到0.9以上,确认分类头已经学会基本的模式后再解冻。池化位置问题可以通过打印model.predict的输出和标签真值分布对比来排查。还有一个小细节:验证集和训练集的seq_len必须一致,不能用训好的模型换一个更长的seq_len做预测,BERT的位置编码是固定的,长度一变输出就会乱。
5.5 坑五:模型保存和加载后predict结果变了,或者直接报错
现象:训练好的模型保存成h5,重新加载后predict的结果和保存前不一样,严重时直接报错找不到自定义层。
原因:keras-bert模型内部用了大量自定义层,如果保存的是完整模型而不是weights,加载时必须提供对应的custom_objects,否则Keras无法反序列化。如果你在池化时用了Lambda层,Lambda层保存时会记录函数的引用,但换了一台机器或者换了函数名,Python解释器找不到对应函数,加载就失败。
解决:推荐的做法是只保存权重,不保存完整模型结构。结构在代码里是固定的,每次预测前用相同代码重建模型,再load_weights训练好的权重,这样彻底避开custom_objects问题。线上的时候用tf.saved_model.save保存成SavedModel格式,这个格式对自定义层的依赖要小得多,后面单独讲。经验是:Keras的h5格式在调试阶段用用可以,部署线上服务还是要换成SavedModel。
6. 微调之后还没完:阈值校准、单条验证与导出部署
模型训练完只完成了一半,后面还有三件事不能省:阈值校准、单条自检、导出部署。
6.1 按标签搜索阈值:别用一个阈值管所有类
第4章里已经贴过阈值搜索的代码,这里再补一个细节:搜索步长的粒度会影响结果。0.05步长足够找到合理的阈值,想更精细可以先用0.05粗扫,找到最优区间后在区间内再用0.01细扫。每个标签都单独搜索,得到一组thresholds数组,保存成json或者numpy文件,线上预测时加载使用。
这里还有个容易被忽略的问题:如果验证集太小,某个标签只有个位数的正样本,搜索出来的阈值会过拟合到这几个样本上。至少要保证验证集里每个标签有30个以上正样本,再谈阈值搜索。数据量不够时,可以先不考虑阈值精确性,把两个标签的阈值看成一个范围,上线后根据线上反馈再动态调整。
6.2 单条文本走一遍完整流程:微调是否合格的自检方法
模型部署前,我会写一个不依赖服务、能直接跑的单条预测脚本,把tokenizer、模型、阈值串起来自检:
def predict_one(text, tokenizer, model, label_names, thresholds, seq_len=128): ids, segs = tokenizer.encode(text, max_len=seq_len) x1 = np.array([ids], dtype=np.int32) x2 = np.array([segs], dtype=np.int32) prob = model.predict([x1, x2], verbose=0)[0] result = {} for idx, name in enumerate(label_names): result[name] = { 'probability': round(float(prob[idx]), 4), 'hit': bool(prob[idx] >= thresholds[idx]) } return result拿几类样本测一下:包含多个标签的样本、完全无标签的样本、和训练集分布差异很大的新样本。多标签模型最容易出现的上线问题是“该命中的没命中”,尤其是低频标签。如果单条自检时发现某个低频标签概率始终低于阈值,可以回看训练集里这个标签的样本是不是文本模式太单一,导致泛化不足。
6.3 导出SavedModel:从Keras训练到线上服务的最后一公里
训练好之后,导出用SavedModel格式而不是继续用h5。SavedModel是TensorFlow官方推荐的部署格式,不依赖训练时的Python代码,服务端加载时不需要重建模型结构。
import tensorflow as tf tf.saved_model.save(model, 'serving_model')导出后目录里会有saved_model.pb和variables目录,预测服务直接加载这个目录。有一点要注意:如果模型里有自定义层,SavedModel会保留自定义层类的引用,服务端加载时仍然需要把对应的自定义层代码放在可见位置。keras-bert的BERT层就是这样,所以生产环境里我会在服务代码里import keras_bert,而不是只依赖SavedModel的序列化结果。
这整套流程走下来,一个文本多标签分类服务就能稳定上线了。如果遇到新标签、新语料,我的习惯是动手调参之前先花几分钟统计标签分布,看看哪些标签样本过少、哪些标签总是共现。多标签项目里,模型结构能带来的提升是有限的,真正的瓶颈往往在标签定义和数据质量上。把这一步做成固定动作之后,我踩坑的次数明显少了。希望帮到你。
本文还有配套的精品资源,点击获取