简介:多标签与层次分类的小样本训练,常见于裁判文书要素抽取等场景。资源包基于UTC模型,仅需数条标注样本即可适配不同行业标签体系,相比常规方案Macro F1提升13%以上,明显降低标注门槛与成本。压缩包共11个文件,包含Python训练/评估脚本、文本数据集、TAR压缩数据、Notebook演示和论文PDF,整体约3.06MB,便于在本地快速复现、调试与二次开发。同时,资源结合CAIL2019婚姻家庭领域案情要素抽取案例,展示了从数据预处理到模型训练、评估的完整流程,可帮助读者理解多标签分类在复杂案情描述中的应用方式。已有327人学习下载,适合NLP算法工程师、研究人员及业务标签体系建设者参考。
1. UTC先解决三件头疼事:多标签、层次分类、小样本一起出现时怎么办
一条客服工单,可能同时命中“物流”“客服”两个标签,也可能要细分到“售后/退款/退款未到账”这种三级类目。过去用BERT接分类头,只有两三百条标注,小标签几乎学不到,Macro F1经常被拉到很低;要做层次分类,通常还得每层单独训练一个模型,错误逐层往下传。UTC(Universal Text Classification,通用文本分类)把分类问题改写成“文本-标签”的语义匹配:模型不直接预测类别,而是判断“这句话和某条标签描述像不像”,所以同一套模型能统一多标签分类与层次分类,小样本下也更稳。标题里“Macro F1提升13%+”不是模型自动给的,而是换任务建模方式、优化标签描述和阈值之后的综合收益。
2. 用UTC跑通多标签分类的最小闭环:环境、命令与Macro F1评估
2.1 安装环境与加载UTC模型
常见做法是用PaddleNLP的Taskflow接口直接拉UTC权重。先装基础框架,纯跑demo用CPU版就行,要做few-shot训练必须装GPU版:
pip install paddlepaddle paddlenlp安装后初始化一个多标签分类器:
from paddlenlp import Taskflow clf = Taskflow( task="text_classification", model="utc-base", mode="multi_label", # 多标签模式;版本不同参数名可能变化,报错时先help(Taskflow) device_id=-1, # CPU跑demo;有GPU改成0 )这里没有改模型内部结构,只把UTC当黑匣子用。task是PaddleNLP的调度入口,text_classification对应统一文本分类这一整类模型;model指定权重,我一般先用utc-base起步,效果不够再换更大的。首次运行会拉取预训练权重,第一次执行前最好确认磁盘空闲足够,不然会卡在下载阶段很久。device_id=-1走CPU,批量推理没问题;训练few-shot模型还是建议GPU,显存8G起步会比较舒服。
2.2 构造输入和标签描述:不要只传类名
UTC的输入不只是文本,还有标签列表。标签不能写成“物流”“客服”这种裸类名,要写成带具体表达的描述句。这一步直接决定小样本场景的天花板。
text = "快递放前台找不到了,打客服电话三次都没人接" label_desc = [ "物流问题:配送延迟、丢件、找不到包裹、放错位置", "客服问题:电话不接、回复慢、态度差、不解决问题", "退款问题:申请退款、退款没到账、退款流程卡住", ] res = clf(text, labels=label_desc) print(res)返回结果里每个候选标签都有一个独立分数。多标签模式下,模型输出的不是所有标签概率和为1,而是逐个标签独立打分,判定命中要用阈值去卡。新手最容易犯的错,一是把标签传成裸类名,二是拿0.5当全局最优阈值。前面这个错误会让zero-shot效果立刻掉一个档次,因为底层语义匹配找不到文本和标签描述之间的词汇与语义关联;后面这个问题,第5章会专门讲。
标签描述为什么影响这么大?UTC的本质是“文本-标签”匹配:模型把输入文本编码成语义向量,再和每条标签描述编码出的向量比对。描述里给出同义表达和业务真实说法,小样本条件下模型才能靠预训练知识找到相似点;描述只给两个词,文本里全是口语化表达,匹配分数自然上不去。
2.3 用固定测试集计算Macro F1
跑通预测后,不要只盯着几条样例看效果,要准备一份带真值的固定测试集,计算多标签Macro F1。多标签Macro F1的算法是:每个标签单独算precision、recall和F1,最后对所有标签取平均。
def macro_f1(y_true, y_pred, all_labels): label_f1 = [] for label in all_labels: tp = sum(1 for t, p in zip(y_true, y_pred) if label in t and label in p) fp = sum(1 for t, p in zip(y_true, y_pred) if label not in t and label in p) fn = sum(1 for t, p in zip(y_true, y_pred) if label in t and label not in p) precision = tp / (tp + fp) if tp + fp else 0.0 recall = tp / (tp + fn) if tp + fn else 0.0 f1 = 2 * precision * recall / (precision + recall) if precision + recall else 0.0 label_f1.append(f1) return sum(label_f1) / len(label_f1)y_true和y_pred是集合列表,每个集合里放命中的标签;all_labels是全部候选标签。每个标签独立统计tp、fp、fn后算F1,最后平均。相比micro F1,Macro F1在小样本场景下更诚实,它不会让高频标签的强势表现掩盖稀有标签几乎不可用的事实。评估集里稀有标签至少要有5到10条正例,否则Macro F1会被少数样本的随机抖动控制,指标忽高忽低。
测试集一旦定了就不要频繁换。每次改标签描述、阈值或训练数据,都用同一份测试集做回归对比,不然“提升13%”到底是模型变好还是数据集换样,根本说不清。
3. 层次分类落地:把标签树压平成多标签的路径与后处理
3.1 为什么层级分类不直接用逐层训练
层次分类最容易想到的方案是第一层分大类,第二层分小类,逐层调用模型。这个方案逻辑简单,但小样本场景有硬伤:上层分错,下层必错,错误逐层叠加;每层还要独立维护一个模型和一套标注。常见做法是把标签树压平成“路径标签”,让UTC一次预测完整路径。
def collect_paths(tree, parent=""): paths = [] for node, child in tree.items(): path = f"{parent}/{node}" if parent else node paths.append(path) if child: paths.extend(collect_paths(child, path)) return paths taxonomy = { "违规": {"广告": {"加微信": {}}, "侵权": {}}, "售后": {"退换货": {}} } print(collect_paths(taxonomy)) # ['违规', '违规/广告', '违规/广告/加微信', '违规/侵权', '售后', '售后/退换货']每个节点生成一条从根到当前节点的路径,路径天然带着层级信息。模型把“违规/广告/加微信”当一个独立标签去匹配,文本“加我微信领红包”会跟这条路径的语义距离更近,比先判断“是否违规”再判断“是否广告”更直接,也省掉多个模型的维护成本。路径分隔符用“/”主要还是可读性,你完全可以用“>>”或“-”代替,只要训练和推理保持一致。
这里要先跟业务确认一点:报表统计是只统计叶子标签,还是也要统计父级。如果只要叶子标签,训练时只保留叶子路径,父级路径不进标签列表;如果父级和叶子都要,就把所有路径放进去,预测后再做剪枝。
3.2 扁平全路径与逐层分类的取舍
| 方案 | 样本标注 | 模型调用 | 错误传播 | 适用场景 |
|---|---|---|---|---|
| 逐层分类 | 每层单独标 | N层N次 | 上层错则下层必错 | 层级很深、单层标签很多 |
| 扁平全路径 | 标完整路径 | 1次 | 无链式依赖,但路径数膨胀 | 层级≤4、叶子标签≤1000 |
我一般优先扁平全路径。小样本文本分类的核心瓶颈是样本量不够,扁平路径把“大类”和“小类”绑定在一次预测里,相当于每条样本多带了一份结构约束。逐层分类唯一明显占优的场景是层级特别深(五层以上)或叶子标签多到路径组合爆炸,这时拆层可以缩小单次分类的候选空间,减少路径数量对模型性能的影响。
路径数量一旦超过几百条,UTC推理耗时也会变高,因为每条路径都要参与一次匹配打分。这时常见做法是先按一级类目做路由,第一次调用只分顶级类,命中后再在那个顶级类下取二级路径做第二次预测,两段式调用,准确率略降,但速度能拉回来。
如果训练数据里只有叶子标签,没有父级路径,推理时还想顺便拿到父级分类,可以把预测得到的叶子路径直接映射回标签树,逐层摘出祖先节点,不需要让模型重复输出。
3.3 预测结果的后处理:父子路径冲突剪枝
扁平化之后,模型不知道标签之间存在父子关系,预测结果里常常出现“违规”和“违规/广告”同时被选中。后处理阶段需要做一次剪枝,保留最具体的那条路径。
def prune_paths(paths): keep = [] for p in sorted(paths, key=lambda x: x.count("/"), reverse=True): if not any(p.startswith(q + "/") for q in keep): keep.append(p) return keep print(prune_paths(["违规", "违规/广告", "违规/广告/加微信"])) # ['违规/广告/加微信']先把预测出的路径按路径深度降序排序,深度大的优先保留;然后检查当前路径的父路径是否已经在结果里,是则跳过。这样“违规/广告/加微信”作为最深路径被保留,“违规”和“违规/广告”作为冗余父级被丢弃。
剪枝只有一条规则还不够,要小心两个边界。第一,业务需要同时统计一级和二级时,不能直接删父级,而是把父子同时出现的case单独映射到父级统计,这属于报表层的映射逻辑,不应该放在推理返回里处理。第二,如果两个路径只是前缀相同但叶子不同,比如“违规/广告/加微信”和“违规/广告/刷单”,它们不是父子关系,剪枝不该删掉任何一个,上面的startswith判断自然也不会误删。
4. 小样本场景下Macro F1提升13%+的三个关键
4.1 先定义清楚:提升是对哪个基线说的
不谈基线就谈提升13%,等于耍流氓。常见能做到这个涨幅的场景是:原方案用预训练语言模型直接接FC分类头,在200到500条标注样本上微调,标签又不均衡,稀有标签几乎没学到东西,Macro F1只有0.5左右;换成UTC之后,稀有标签靠描述也能匹配上,再加上阈值校准和数据补充组织,整体从0.55拉到0.68,这就有13%以上的相对提升。
| 对比维度 | 传统微调分类模型 | UTC few-shot |
|---|---|---|
| 有效样本量 | 一般要3000条以上 | 200条能起步 |
| 稀有标签表现 | 依赖正例数量 | 依赖标签描述质量 |
| 新标签上线 | 重训并加标注 | 改标签描述即可 |
| 超参数敏感度 | 高 | 低一些,但阈值仍要调 |
如果你拿的是已经用一万条高质量标注数据练好的线上模型来比,UTC大概率追不上。这个提升更准确的描述是:在低资源场景下,用更好的任务建模方式把指标下限抬高。业务里数据量充足时继续用传统微调也没问题,UTC的优势区间恰恰在“没那么多标注、标签还经常变”的地方。
4.2 小样本训练集的组织:覆盖比数量更重要
小样本训练数据选取的第一原则不是随机抽样,而是保证每个标签至少出现3次正例,常见标签组合都要有覆盖。样本量少时,标签从没在训练集里出现过,模型只能靠“描述相似”硬猜,命中率必然不稳定。
def coverage_check(df, label_col): tag_cnt = {} for labels in df[label_col]: for label in labels: tag_cnt[label] = tag_cnt.get(label, 0) + 1 rare = {k: v for k, v in tag_cnt.items() if v < 3} return tag_cnt, rare把训练集塞进这个函数,返回的rare里如果还有业务高危标签,就先别急着训练,回去补数据。“3次”是经验值,不是数学上最优,但用来快速体检够了。多标签场景还要专门检查标签组合覆盖:比如“物流+退款”经常一起出现,训练集里如果这两个标签只单独出现,模型会倾向只预测其中一个,线上就会漏召回。
数据构造时,标签描述要参与训练样本组织,不只是推理时才拼接。训练脚本里通常有一步把标签列表转成描述文本,同一个样本的标签顺序要打乱,避免模型学到“第一个标签权重更高”的位置偏置。
4.3 训练参数经验值与验证方式
小样本训练最忌讳照搬大样本参数。以PaddleNLP官方训练脚本为例,典型命令长这样:
python train.py \ --model utc-base \ --train_file train.json \ --dev_file dev.json \ --num_epoch 30 \ --learning_rate 3e-5 \ --warmup_proportion 0.1 \ --max_seq_len 256 \ --batch_size 16 \ --save_dir checkpoints \ --eval_metric macro_f1epoch设置在20到50之间,样本越少越要长训,但也要盯着dev F1,训练集太小的时候模型很容易过拟合到训练集噪声。学习率取2e-5到5e-5,小样本尽量偏下限,3e-5是常用起点。warmup的作用是让学习率在一开始平稳爬升,防止极端loss破坏预训练语义。max_seq_len要看文本分布:短文本128够用,长文本先做关键句抽取再截断,直接拉512不一定划算。
| 参数 | 经验范围 | 说明 |
|---|---|---|
| learning_rate | 2e-5~5e-5 | 样本越少取偏小值 |
| num_epoch | 20~50 | 观察dev F1提前停止 |
| warmup_proportion | 0.05~0.1 | 稳定预训练参数 |
| max_seq_len | 128~512 | 按文本长度分布选 |
| threshold | 0.3~0.6 | 在dev集上扫描确定 |
验证集至少留100条,太少的话单条样本翻转就能让F1变化三四个点,根本分不清是模型变好还是运气好。比较UTC和旧方案的提升,跑5个随机种子取均值,再看差值是不是大于标准差之和;连波动都盖不住,就先不要对外说“提升13%”。
这里还有一点容易踩:dev集和test集要有相同标签分布。如果dev集里稀有标签很少,阈值扫描会偏向高频标签,线上表现和离线评估就会对不上。
5. UTC落地常见问题与排查:5个必踩的坑
5.1 标签描述写得太抽象,模型命中率虚低
现象:zero-shot或few-shot模式下,类别明明很直观,模型预测结果却很散,Macro F1只有0.4左右。原因:UTC做的是文本与标签描述的语义匹配,“物流”两个词和“包裹放前台丢了”这句话没有足够强的共现信息。解决:把标签描述改成“类别名+常见说法+反例排除”的结构,比如“物流问题:配送延迟、丢件、找不到包裹、放错位置;若只是售后维修,请归售后来处理。”多放业务真实说法,少放标准术语。改完之后对比一下命中率,通常立刻能看到几个点的提升。
5.2 固定阈值0.5,多标签召回吃亏
现象:预测结果里有一堆0.35到0.45的分数,按0.5切掉后recall骤降,F1反而下降。原因:多标签模式下模型逐标签打分,分数分布不受“和为1”约束,全局最优阈值往往不是0.5。解决:在dev集上从0.1到0.9扫描,找到Macro F1最大化的阈值,再去测test集:
best_thr, best_f1 = 0.5, 0.0 for thr in np.arange(0.1, 0.9, 0.05): y_pred = predict_all(dev_texts, label_desc, thr) f1 = macro_f1(y_true, y_pred, all_labels) if f1 > best_f1: best_thr, best_f1 = thr, f1predict_all是把“文本+标签列表+阈值”转成预测标签集合的包装函数。阈值不是玄学,只是多标签建模下本来就需要校准的一个决策边界。标签描述一旦修改,最优阈值也会漂移,扫描脚本要留在pipeline里,别只调一次就焊死。
5.3 层次分类出现父子路径同时命中
现象:预测结果是“违规”和“违规/广告”两个标签同时出现,线上报表统计翻倍。原因:路径列表在模型眼里都是平级标签,没有树结构约束,父子路径本来就是语义相近的两个候选。解决:训练样本只把叶子路径作为正标签,父级路径不进标签列表;预测后统一做一次剪枝,剪枝逻辑用前面第3节的prune_paths。如果业务报表必须统计父级,剪枝后用路径映射表把叶子路径汇总回父级,不要直接保留父子两条同时输出。
5.4 小样本指标忽高忽低,换seed就变
现象:固定测试集,只换随机种子,Macro F1在0.58和0.71之间跳。原因:标注数据量太小,抽样方差主导了训练波动,不一定是模型不稳。解决:跑5个种子,报告均值加减标准差,线上决策看两个方案的均值差是否大于标准差之和。如果差距没超过波动范围,别急着宣布换模型带来了提升。这也是“13%+”要加个“+”而不是精确到小数点的原因,小样本里没有任何单次实验能代表真实水平。
5.5 长文本被截断后关键信息丢失
现象:法律条款、客服备注这类文本,后段才是决定类别的内容,分类F1一直提不上来。原因:max_seq_len截断到256或512,后段信息直接丢弃。解决:先做关键句抽取,把含业务触发词和类别关键信息的句子拼成一段再进模型;或者按段落分块预测,多个分块取最高分作为最终结果。血泪经验是,不要以为把max_seq_len拉到1024就能解决问题,训练和推理时间会明显上涨,而且文本越长、语义越分散,模型越难把握重点。先做文本清洗和关键信息抽取,通常比硬调长度更划算。
6. 进阶一档:把标签描述模板化和阈值搜索固化成流水线
6.1 标签描述模板化
标签描述如果让每个工程师按自己习惯自由发挥,测试集指标会一直波动,因为变的是描述而不是模型。我建议把描述拆成一个简单的模板,项目之间只改示例和排除项,结构不随意变:
def build_label_desc(name, examples, negative_hint=""): desc = f"该文本属于【{name}】:例如{examples}" if negative_hint: desc += f";如果文本属于{negative_hint},则不要输出本标签。" return desc label_desc = [ build_label_desc("物流问题", "包裹丢失、配送延迟、找不到快件", "售后维修"), build_label_desc("售后维修", "设备故障、零件更换、维修进度", "配送到货问题"), ]这个函数同时用来生成训练数据和推理时的标签列表,保证两边描述完全一致,避免训练和推理用了两套语义。模板内容不需要多华丽,关键是稳定和可复用,业务方看到也能理解。
6.2 阈值搜索固化到pipeline里
每次改完标签描述,最值钱的下一个动作就是重扫阈值。把这个流程固化成一个评估函数,后续所有实验都从同一个入口取指标:
def evaluate_with_best_threshold(model, dev_texts, y_true, label_desc): best_thr, best_f1 = 0.5, -1.0 for thr in np.arange(0.1, 0.9, 0.05): y_pred = [model(t, label_desc, thr) for t in dev_texts] f1 = macro_f1(y_true, y_pred, label_desc) if f1 > best_f1: best_thr, best_f1 = thr, f1 return best_thr, best_f1传入的model是“文本、标签列表、阈值”到“标签集合”的函数,内部是Taskflow还是微调后的checkpoint,对上位函数透明。阈值搜索结果只代表dev集上的最优决策,线上数据分布一旦变化,要重新校验而不是沿用旧值。
我第一次从BERT硬切UTC时也翻车了,问题不在模型,而在两个默认习惯:标签描述写得太抽象、阈值焊死0.5。把这两件事改掉之后,小样本分类的Macro F1才真正稳下来。如果你刚接手这个方向,我建议优先把这两个动作固化,其余参数先按官方默认来跑基线,再逐项调。希望帮到你。
本文还有配套的精品资源,点击获取