
简介本资源面向自然语言处理方向的开发者与算法工程师聚焦小样本条件下的多标签与层次分类任务基于UTC模型实现行业领域标签的快速适配。仅需少量标注样本即可显著提升分类效果Macro F1提升13%以上有效降低标注门槛与成本适用于案情要素抽取、行业文本归类等复杂场景。资源包共11个文件包含4个Python脚本、4个txt数据与说明文件、1个ipynb交互式实验笔记、1个gz压缩数据及1份UTC论文PDF整体约3.06MB覆盖训练、评估、数据转换与可视化交互的完整流程。目前已有327人学习下载。读者可获取从数据预处理、模型训练到效果评估的全套代码与实验记录借助论文与notebook快速理解UTC在小样本多标签分类中的实现细节并直接迁移至自有业务标签体系省去重复搭建与调参成本。1. UTC 统一文本分类模型在小样本多标签场景下到底强在哪多标签和层次分类这两个任务真正做过的工程师都知道痛在哪里标签之间不是互斥的一条文本可能同时命中「投诉」和「退款」也可能在层次体系里同时落到「售后 物流 延迟」这条路径上。传统做法是给每个标签训一个二分类器标签一多标注量就爆炸小样本场景下几乎没法收敛。而 UTCUnified Text Classification这套统一范式的核心思路是把多标签、层次分类、单标签分类全部转成「文本 标签描述」的匹配问题让模型去判断「这段文本和这个标签描述是否匹配」而不是硬学一个多分类头。这个思路在小样本下的价值非常直接你不需要每个标签几百条标注只需要给每个标签写一句自然语言描述模型就能靠预训练阶段积累的语义对齐能力做零样本或小样本推理。Macro F1 提升 13% 这个数字通常出现在标签分布极不均衡、长尾标签占多数的场景里——因为 Macro F1 对每个标签一视同仁长尾标签一旦被救回来整体分数就会明显抬升。这套方案适合谁适合手上标注数据只有几十到几百条、标签体系却在几十到上百个、还要求可解释性的团队。如果你正在用「一个标签一个分类器」的堆叠方案并且已经被长尾标签的召回率折磨过那接下来的内容值得你照着走一遍。2. 把多标签和层次分类统一成匹配任务原理与数据构造2.1 UTC 为什么能用一个框架吃掉两类任务传统多标签分类的建模方式是「N 个独立二分类」或者「标签共现矩阵 分类器链」层次分类则更麻烦要么做扁平化把层次路径当成一个复合标签要么做层级递进预测每一层一个模型。这两种做法在小样本下都有同一个死穴每个标签或每条路径分到的训练信号太少模型根本学不出稳定的决策边界。UTC 的做法是把问题重新定义。对一条文本 x 和候选标签集合 L {l1, l2, ..., ln}模型不直接输出「x 属于哪个标签」而是对每个 (x, li) 组合打一个匹配分。训练目标是让正确标签的匹配分高于错误标签。这样一来多标签就是「多个标签同时匹配」层次分类就是「父节点和子节点路径上的标签都匹配」本质上变成了同一个排序/匹配问题。这个转换带来两个实际好处。第一标签描述是自然语言模型可以利用预训练阶段学到的语义知识即使某个标签只有一两条样本只要描述写得准匹配分也不会太差。第二推理时新增标签不需要重新训练只要加一条标签描述进候选集这对标签体系频繁变动的业务场景非常友好。2.2 标签描述怎么写才不掉分标签描述的质量直接决定小样本下的上限。我一般按「任务类型 领域 标签语义 边界说明」四段式来写。比如层次分类里的「售后 物流 延迟」不要只写「物流延迟」而要写成「用户反馈订单配送时间超出承诺时效属于售后场景下的物流问题不包含商品质量问题」。下面是一个把原始标签体系转成 UTC 可用的标签描述文件的脚本import json # 原始层次标签体系路径 - 标签名 hierarchy_labels { 售后/物流/延迟: 物流延迟, 售后/物流/丢件: 包裹丢失, 售后/商品/质量: 商品质量问题, 售前/咨询/价格: 价格咨询, } # 任务类型前缀帮助模型区分不同层级的语义 task_prefix { 售后: 用户已经下单后产生的问题, 售前: 用户尚未下单处于咨询阶段, } def build_label_description(path, name): parts path.split(/) domain parts[0] prefix task_prefix.get(domain, ) # 四段式任务类型 领域 标签语义 边界说明 desc f{prefix}具体类别为{name}仅针对{ /.join(parts[1:]) }这一细分场景不包含其他同类问题。 return desc label_descriptions [] for path, name in hierarchy_labels.items(): label_descriptions.append({ label_path: path, label_name: name, description: build_label_description(path, name) }) with open(utc_label_descriptions.json, w, encodingutf-8) as f: json.dump(label_descriptions, f, ensure_asciiFalse, indent2) print(f生成 {len(label_descriptions)} 条标签描述)这段脚本的逻辑很直白把层次路径拆成领域和细分场景用task_prefix注入任务类型信息再用固定模板拼出边界说明。参数上最关键的是task_prefix这个字典它决定了模型能不能区分「售前咨询」和「售后投诉」——这两类文本用词高度重叠如果描述里不显式区分匹配分很容易混淆。边界说明那句「不包含其他同类问题」看起来是废话但在小样本下能有效压低误匹配因为模型会倾向于把语义相近但实际不同的标签推开。2.3 小样本下的数据构造每条样本要配多少负标签UTC 训练时需要正负样本对。正样本就是 (文本, 正确标签描述)负样本是 (文本, 错误标签描述)。小样本场景下负样本的构造策略比正样本数量更影响 Macro F1。我的经验是每个正样本配 4 到 8 个负标签其中至少 2 个来自同一父节点下的兄弟标签硬负样本其余从全局随机采软负样本。硬负样本逼模型学细粒度边界软负样本防止模型退化成只区分大类。import random def build_pairs(text, true_label_path, all_labels, hard_neg_ratio0.5, num_neg6): text: 原始文本 true_label_path: 正确标签的路径如 售后/物流/延迟 all_labels: 全部标签描述列表 hard_neg_ratio: 硬负样本占比 num_neg: 每个正样本配的负样本数 parent /.join(true_label_path.split(/)[:-1]) # 硬负样本同一父节点下的其他标签 hard_negs [l for l in all_labels if l[label_path].startswith(parent) and l[label_path] ! true_label_path] # 软负样本全局其他标签 soft_negs [l for l in all_labels if not l[label_path].startswith(parent)] num_hard int(num_neg * hard_neg_ratio) num_soft num_neg - num_hard sampled_hard random.sample(hard_negs, min(num_hard, len(hard_negs))) sampled_soft random.sample(soft_negs, min(num_soft, len(soft_negs))) pairs [(text, true_label_path, 1)] for l in sampled_hard sampled_soft: pairs.append((text, l[label_path], 0)) return pairshard_neg_ratio设 0.5 是个稳妥起点。如果发现模型在兄弟标签上混淆严重就往上调到 0.7如果整体召回偏低说明负样本太难往下调到 0.3。num_neg不建议超过 10否则训练时长线性增长但收益递减。这个函数返回的是三元组列表直接喂给后续的 Dataset 类即可。3. 从零跑通 UTC 小样本训练环境、配置与关键参数3.1 环境准备与依赖安装UTC 类方案通常基于预训练语言模型做微调环境不复杂但版本要对齐。我一般用 Python 3.9 PyTorch 1.13 transformers 4.30 这个组合比较稳。conda create -n utc_cls python3.9 -y conda activate utc_cls pip install torch1.13.1 transformers4.30.2 datasets2.12.0 pip install scikit-learn pandas numpy tqdm装完之后先验证一下 GPU 是否可用python -c import torch; print(torch.cuda.is_available())返回 True 再往下走。如果只有 CPU小样本训练也能跑但 batch size 要降到 8 以下训练时间会拉长三到五倍。3.2 训练配置学习率、batch size 和损失函数怎么定小样本微调最容易翻车的地方就是学习率。数据量小的时候学习率稍大就会过拟合模型在训练集上 F1 冲到 0.9验证集上只有 0.4。我一般把学习率设在 1e-5 到 3e-5 之间用 AdamW 优化器权重衰减 0.01。batch size 方面如果显存够用 16 或 32不够就用梯度累积把gradient_accumulation_steps设成 4等效 batch size 也能上去。损失函数用 BCEWithLogitsLoss 或者 Margin Softmax 都行前者简单稳定后者在标签区分度上更好但调参麻烦。小样本场景我倾向先用 BCE 跑通基线。from transformers import AutoTokenizer, AutoModelForSequenceClassification from torch.utils.data import Dataset import torch class UTCPairDataset(Dataset): def __init__(self, pairs, tokenizer, max_len128): self.pairs pairs self.tokenizer tokenizer self.max_len max_len def __len__(self): return len(self.pairs) def __getitem__(self, idx): text, label_desc, label self.pairs[idx] # 文本和标签描述拼接用 [SEP] 分隔 encoding self.tokenizer( text, label_desc, max_lengthself.max_len, paddingmax_length, truncationTrue, return_tensorspt ) return { input_ids: encoding[input_ids].squeeze(0), attention_mask: encoding[attention_mask].squeeze(0), labels: torch.tensor(label, dtypetorch.float) } model_name bert-base-chinese tokenizer AutoTokenizer.from_pretrained(model_name) model AutoModelForSequenceClassification.from_pretrained(model_name, num_labels1)这里num_labels1是因为我们把匹配任务做成了二分类输出一个 logit 表示匹配程度。max_len128对大多数短文本够用如果标签描述写得长可以提到 192但要注意显存。文本和标签描述用 tokenizer 的双句模式拼接中间自动加[SEP]这是让模型做交叉注意力的关键——它必须同时看到文本和标签描述才能判断匹配。3.3 训练循环与 Macro F1 验证训练循环本身不复杂重点在验证阶段怎么算 Macro F1。多标签场景下每个标签都要单独算 precision 和 recall再取平均。层次分类还要考虑路径一致性但小样本阶段先把扁平化的 Macro F1 跑出来。from sklearn.metrics import f1_score import numpy as np def evaluate(model, dataloader, threshold0.5): model.eval() all_preds, all_labels [], [] with torch.no_grad(): for batch in dataloader: input_ids batch[input_ids].cuda() attention_mask batch[attention_mask].cuda() outputs model(input_idsinput_ids, attention_maskattention_mask) logits outputs.logits.squeeze(-1) probs torch.sigmoid(logits).cpu().numpy() preds (probs threshold).astype(int) all_preds.extend(preds.tolist()) all_labels.extend(batch[labels].numpy().tolist()) macro_f1 f1_score(all_labels, all_preds, averagemacro) return macro_f1threshold0.5是默认值但小样本下这个阈值往往不是最优的。我一般会在验证集上扫一遍 0.3 到 0.7选 Macro F1 最高的那个。别小看这一步阈值从 0.5 调到 0.4Macro F1 有时能差 2 到 3 个点。另外averagemacro是必须的用 micro 的话长尾标签的贡献会被头部标签淹没你就看不到真实的长尾表现了。4. 避坑与排查小样本 UTC 训练里最容易翻车的五件事4.1 标签描述写得太短模型直接摆烂现象训练 loss 降得很快但验证集 Macro F1 卡在 0.3 左右上不去长尾标签几乎全预测为负。原因标签描述只有两三个词比如「物流延迟」模型无法从中区分它和「物流丢件」的语义边界。小样本下模型本来就缺训练信号描述再短匹配分就退化成随机。解决按 2.2 的四段式模板重写描述确保每条描述里包含任务类型、领域、细分场景和边界说明。写完可以人工检查一遍把两条不同标签的描述放在一起你自己能不能一眼看出区别看不出就继续改。4.2 负样本全随机采模型学不会细粒度区分现象大类分得很准但同一父节点下的兄弟标签互相混淆层次分类的路径准确率很低。原因负样本从全局随机采大部分是语义差距很大的标签模型只需要学「粗粒度区分」就能把 loss 降下去没有动力去学细粒度边界。解决按 2.3 的策略保证至少一半负样本来自同一父节点。如果兄弟标签本身很少可以人工构造一些「近似标签」作为硬负样本比如把「物流延迟」和「物流缓慢」都放进候选集逼模型区分。4.3 学习率没调训练集 F1 虚高现象训练三个 epoch 后训练集 Macro F1 到 0.95验证集只有 0.45典型过拟合。原因小样本数据量小默认学习率 5e-5 太大模型几步就把训练集记住了。解决学习率降到 1e-5 到 3e-5加 warmupwarmup 比例设 0.1。同时开 early stopping验证集 F1 连续两个 epoch 不涨就停。如果还过拟合加 dropout 到 0.3或者对标签描述做同义词替换做数据增强。4.4 阈值用默认 0.5长尾标签全被吞掉现象整体 accuracy 看着还行但 Macro F1 很低一查发现长尾标签的召回率接近 0。原因模型对长尾标签的匹配分普遍偏低0.5 的阈值把它们全过滤掉了。解决在验证集上扫阈值按 Macro F1 选最优值。更细的做法是每个标签单独设阈值但小样本下每个标签的验证样本太少单独设阈值容易过拟合我一般先用全局阈值等数据量上来再考虑分标签阈值。4.5 标签体系变动后直接复用旧模型现象新增了几个标签直接拿旧模型推理新标签的 F1 惨不忍睹。原因UTC 虽然支持零样本但前提是标签描述写得好且新标签和旧标签的语义空间有重叠。如果新标签是完全陌生的领域零样本效果会大打折扣。解决新增标签后至少拿几十条样本做一轮小样本微调或者用「标签描述 少量样本」做 prompt tuning。别指望零样本能解决所有问题该补数据就补数据。5. 把 Macro F1 再往上推阈值分标签校准与层次一致性后处理跑通基线之后如果还想把 Macro F1 往上推有两个技巧我反复用过效果稳定。第一个是分标签阈值校准第二个是层次一致性后处理。分标签阈值校准的思路是全局阈值对头部标签可能偏低对长尾标签可能偏高。与其用一个阈值不如给每个标签单独找一个。但小样本下每个标签的验证样本可能只有几条直接扫阈值会过拟合。我的做法是用「全局阈值 标签偏移量」先定一个全局阈值 t然后对每个标签根据它在验证集上的正负样本分布算一个偏移量 delta_i最终阈值是 t delta_i。delta_i 用贝叶斯平滑避免样本太少时抖动过大。def calibrate_thresholds(probs_per_label, labels_per_label, global_threshold0.5, smooth5.0): probs_per_label: dict, 每个标签的预测概率列表 labels_per_label: dict, 每个标签的真实标签列表 smooth: 平滑系数越大越接近全局阈值 thresholds {} for label in probs_per_label: probs np.array(probs_per_label[label]) labels np.array(labels_per_label[label]) if len(labels) 0: thresholds[label] global_threshold continue # 用正负样本概率均值差作为偏移信号 pos_mean probs[labels 1].mean() if (labels 1).any() else global_threshold neg_mean probs[labels 0].mean() if (labels 0).any() else global_threshold delta (pos_mean - neg_mean) / 2 # 平滑样本越少偏移越小 weight len(labels) / (len(labels) smooth) thresholds[label] global_threshold delta * weight return thresholdssmooth5.0的意思是当某个标签只有 5 条验证样本时偏移量只取一半。样本越多偏移越充分。这个参数可以根据你的验证集大小调验证集大就调小让偏移更激进。层次一致性后处理是另一个提分点。UTC 做扁平化预测时可能出现「子节点命中但父节点没命中」的矛盾情况。后处理规则很简单如果子节点预测为正强制把父节点也置为正如果父节点预测为负强制把子节点置为负。这一步不需要重新训练纯规则后处理在层次分类任务上通常能再涨 1 到 2 个点的 Macro F1。def enforce_hierarchy_consistency(preds, label_paths): preds: dict, label_path - 0/1 label_paths: 全部标签路径列表 # 按路径深度从深到浅处理 sorted_paths sorted(label_paths, keylambda p: len(p.split(/)), reverseTrue) for path in sorted_paths: parts path.split(/) if len(parts) 1: continue parent /.join(parts[:-1]) if parent not in preds: continue # 子节点为正 - 父节点强制为正 if preds[path] 1: preds[parent] 1 # 父节点为负 - 子节点强制为负 if preds[parent] 0: preds[path] 0 return preds这两个技巧叠加使用我在几个小样本层次分类任务上把 Macro F1 从基线又抬了 3 到 5 个点。但要注意分标签阈值校准依赖验证集如果验证集本身太小校准反而会掉分这时候就老老实实用全局阈值。层次一致性后处理则几乎无副作用只要你的标签体系确实是严格的树形结构就可以放心加。最后说个我自己的习惯每次跑完实验我都会把标签描述文件、阈值配置和负样本采样比例一起存档因为小样本实验的可复现性很差换个随机种子结果就可能波动两三个点。不存配置两周后你自己都复现不出来。希望帮到你。本文还有配套的精品资源点击获取