十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

CAIL2019要素识别实战:基于PaddlePaddle的多标签TextCNN分类与阈值调优

CAIL2019要素识别实战:基于PaddlePaddle的多标签TextCNN分类与阈值调优 简介针对CAIL2019法研杯要素识别任务本压缩包提供一套基于PaddlePaddle的多标签分类实现面向深度学习和法律文本挖掘的学习者与从业者可用于法律文书要素抽取、批量标注等场景。包内文件共56个以Python源码、文本文件、编译字节码和模型文件为主附带README说明与运行日志整体大小约502KB其中py文件为源码主体pkl为模型参数txt与日志便于理解数据组织与运行过程。资源覆盖数据预处理、模型构建、训练评估、模型保存与推理等环节模型构建部分可结合CNN、RNN或Transformer等结构捕捉文本特征并借助Multi-Label Soft Margin Loss处理多标签分类问题。目前已有298人学习下载。项目目录结构清晰核心模块划分合理代码经助教测试可运行适合以此为基础复现赛题方案或二次开发也是学习PaddlePaddle工程实践与法律文本自动要素识别的实用参考。1. 要素识别不是单选是把每个标签分别打分CAIL2019法研杯的要素识别任务表面上是给一段案情文字返回几个标签真正动手做才发现这是一个典型的多标签分类问题输入不变输出变成一组独立的二分类结果。基于 PaddlePaddle 实现这条路CPU 上跑 TextCNN 就能出一个可用的基线难点反而在数据处理和阈值选择上。我会按自己做比赛基线的方式把多标签建模、数据清洗、模型搭建、训练评估和推理导出完整走一遍新手能照着跑通有经验的人也可以直接拿里面的阈值扫描和长尾标签处理思路去改进自己的项目。2. CAIL2019 要素识别的多标签建模与数据预处理2.1 多标签分类与多分类的本质差异一个样本可以同时命中多个要素这是多标签和多分类的最大分界。多分类模型在输出层用 softmax把所有类别得分归一化成概率和为 1多标签模型则在每个标签维度上单独做二分类输出层用 sigmoid每个维度取值 0 到 1互不干扰。这个差异直接决定了损失函数的选择softmax 配 CrossEntropyLosssigmoid 配 BCEWithLogitsLoss。如果错把要素识别做成多分类模型训练时会强行让标签之间产生竞争关系。比如“非法占有的故意”和“虚构事实”同时出现时softmax 会设法压低其中一个概率导致召回率明显下降。多标签建模还意味着评估不能用 accuracy 一锤定音因为大部分样本的负标签远多于正标签全预测 0 也能拿到很高的准确率。真正要看的是 F1 以及每个标签的精确率和召回率这个前提立住之后再往下处理数据才有意义。2.2 从原始 JSON 到 Paddle Dataset 的清洗映射比赛原始数据一般是若干条 JSON 记录每条记录里面既有案情文本也有一组要素标签。落地时第一步不是急着搭模型而是把文本和标签加工成模型能吃的东西。常见做法是先扫一遍全部样本建立 vocab 字表和 label2id 映射label2id 的 key 是标签原文value 是 0 到 num_labels-1 的整数。这个映射必须固定下来并单独存成 JSON后续推理阶段要读同一个文件否则标签顺序一错所有输出都会错位。文本清理方面我一般会做三件事统一全半角、去掉不可见字符、把连续空格压缩成单空格。法条编号、数字、当事人姓名都保留不删因为它们常常是判断某些要素的关键信号。长度截断上max_len 取 512 是一个在速度和效果之间比较稳的位置对超长文书采用“前 256 后 256”的拼接比直接砍尾部更容易保留判决依据和犯罪事实的收尾信息。import json import paddle import numpy as np from collections import Counter def clean_text(text): text text.replace(\u3000, ).replace(\n, ) return .join(text.split()) def build_label_dict(json_path, min_count5): label_counter Counter() with open(json_path, encodingutf-8) as f: items json.load(f) for item in items: for lab in item.get(keywords, []): # 要素标签字段名按实际数据调整 label_counter[lab] 1 label2id {__UNK__: 0} for lab, cnt in label_counter.most_common(): if cnt min_count: label2id[lab] len(label2id) return label2id class CAILDataset(paddle.io.Dataset): def __init__(self, json_path, vocab, label2id, max_len512): super().__init__() self.vocab vocab self.label2id label2id self.max_len max_len self.samples [] with open(json_path, encodingutf-8) as f: items json.load(f) for item in items: text clean_text(item.get(fact, )) # 文本字段名按实际数据调整 labels item.get(keywords, []) if not text: continue ids [self.vocab.get(t, 1) for t in list(text)] if len(ids) max_len: ids ids[:max_len // 2] ids[-(max_len - max_len // 2):] ids ids [0] * (max_len - len(ids)) label_vec np.zeros(len(label2id), dtypefloat32) for lab in labels: if lab in label2id: label_vec[label2id[lab]] 1.0 self.samples.append( (np.array(ids, dtypeint64), label_vec) ) def __getitem__(self, idx): return self.samples[idx] def __len__(self): return len(self.samples)这段代码有几个参数需要解释。max_len 取 512模型接收的每个样本都是定长向量短样本补 0长样本拆成前 256 和后 256 两部分拼在一起而不是随机截断。label2id 里的__UNK__占 0 号位文本词表里的 OOV 统一映射成 1这样 embedding 层可以把 padding_id0 单独留出来。min_count 控制最低标签频次出现次数太少的要素在训练集里可能只有个位数样本模型学不到可靠特征留着只会拖累 macro-F1。预处理参数推荐值说明max_len512前 256 后 256 截断保留首尾关键信息min_count5低于 5 次的低频标签并入 OTHER 或丢弃pad_id0Embedding 的 padding_idx 设为 0不参与梯度更新OOV id1词表中固定预留__UNK__对应 id2.3 标签共现统计与低频标签裁剪多标签任务里标签之间往往有共现关系比如“自首”和“如实供述”经常成对出现。很多教程会告诉你直接用共现规则做后处理但我的建议是先在验证集上量化再决定加不加规则。统计共现矩阵的代码很简单from collections import defaultdict co_occur defaultdict(int) for _, label_vec in CAILDataset(json_path, vocab, label2id).samples: idxs np.where(label_vec 1)[0] for i in range(len(idxs)): for j in range(i 1, len(idxs)): co_occur[(idxs[i], idxs[j])] 1如果 A 和 B 的共现次数占 A 总样本数的 95% 以上可以尝试一个启发式规则当模型预测 A1 时强制 B 也设为 1。不过这种规则要非常克制它会提升 A 的召回率但可能拉低 B 的精确率最终 macro-F1 不升反降。更稳妥的做法是先跑一版纯模型结果统计验证集上哪些错误标签总是一起出现用数据而不是感觉来定规则。3. 用 PaddlePaddle 搭建多标签 TextCNN 分类器与损失函数3.1 为什么基线上选 TextCNN 而不是 RNN/Transformer从基线角度看TextCNN 在要素识别这种“局部短语触发”的任务上有天然优势。“虚构事实”“以非法占有为目的”“经事先通谋”这些要素往往由连续几个词构成TextCNN 的多个卷积核刚好在 n-gram 级别捕捉这些触发片段。RNN 可以做但训练速度慢长文本堆叠起来显存压力大CPU 环境尤其吃亏。Transformer 从随机初始化开始训练通常需要更多数据和更久时间才能收敛直接用在几千条比赛样本上容易欠拟合。如果追求更高上限可以在跑通 TextCNN 基线后换成 Paddle 里的 ERNIE 3.0 Tiny 做迁移学习但如果目标是快速验证数据处理和阈值策略TextCNN 是性价比最高的第一版模型。3.2 TextCNN 前向实现与关键参数设置下面是一个可以直接用在 Paddle 2.x 上的 TextCNN 实现。forward 里没有显式加 sigmoid因为训练时用 BCEWithLogitsLoss 会让数值更稳定推理时再单独调 sigmoid。import paddle import paddle.nn as nn import paddle.nn.functional as F class TextCNN(nn.Layer): def __init__(self, vocab_size, num_labels, embed_dim128, num_filters256, filter_sizes(3, 4, 5), dropout0.3): super().__init__() self.embedding nn.Embedding(vocab_size, embed_dim, padding_idx0) self.dropout nn.Dropout(dropout) self.convs nn.LayerList([ nn.Conv1D(embed_dim, num_filters, k, paddingk // 2) for k in filter_sizes ]) self.fc nn.Linear(num_filters * len(filter_sizes), num_labels) def forward(self, x): emb self.embedding(x) # [B, L, D] emb emb.transpose([0, 2, 1]) # [B, D, L] conv_out [] for conv in self.convs: c F.relu(conv(emb)) # [B, num_filters, L] c F.max_pool1d(c, kernel_sizec.shape[-1]).squeeze(-1) conv_out.append(c) feats paddle.concat(conv_out, axis1) # [B, num_filters * 3] feats self.dropout(feats) logits self.fc(feats) # [B, num_labels] return logits这里有几个参数是经验值。embed_dim128 对中文字级表来说足够不需要一开始就上 300 维num_filters256 让每个卷积核提取更丰富的局部特征filter_sizes(3,4,5) 对应三元组、四元组、五元组特征基本覆盖法律文本里常见的固定搭配长度。paddingk//2 保证卷积输出长度和输入一致最后用全局 max pooling 取每个特征图的最大值再把三个卷积核的结果拼起来送进全连接层。需要注意卷积接池化之后得到的特征向量只保留每个 n-gram 的局部响应不关心它在文本中出现的位置。这对要素识别来说通常是好事因为“自首”出现在开头还是结尾并不影响它是否成立。3.3 BCEWithLogitsLoss 与正负样本不均衡多标签分类默认的损失函数是每个标签独立算二分类交叉熵。Paddle 的BCEWithLogitsLoss会在内部对 logits 做 sigmoid再计算损失比手动sigmoid BCELoss数值更稳定。真正需要额外处理的是正负样本不均衡。def compute_pos_weight(dataset, num_labels): pos_count np.zeros(num_labels, dtypefloat32) for _, label_vec in dataset.samples: pos_count label_vec neg_count len(dataset.samples) - pos_count pos_weight neg_count / np.maximum(pos_count, 1.0) return paddle.to_tensor(pos_weight) criterion nn.BCEWithLogitsLoss(pos_weightpos_weight)pos_weight 的作用是放大正样本的损失。假设某个标签只有 5% 的正样本那么它的 neg_count / pos_count 约等于 19模型预测错一个正样本的代价会是负样本的 19 倍训练时就会更积极地预测 1。计算时要按标签维度分别算不能所有标签共用一个权重因为要素之间的正负比差异可能非常大。如果觉得 pos_weight 不够灵活可以用 Focal Loss 替代它对易分类样本自动降权对难分样本保留更大梯度。后面第 4 章会给出替代实现。4. 多标签训练循环、F1 评估与阈值调优4.1 用 Paddle 写训练循环并加入验证早停训练循环本身不复杂需要注意的细节有三个dataloader 要 shuffle、验证要切到 eval 模式、模型保存必须以验证 F1 为准而不是训练 loss。下面的代码包含了早停逻辑patience 设成 3 轮。EPOCHS 20 BATCH_SIZE 64 LR 2e-3 model TextCNN(vocab_sizelen(vocab), num_labelslen(label2id)) criterion nn.BCEWithLogitsLoss(pos_weightpos_weight) optimizer paddle.optimizer.Adam(learning_rateLR, parametersmodel.parameters()) train_loader paddle.io.DataLoader( train_ds, batch_sizeBATCH_SIZE, shuffleTrue, drop_lastTrue ) val_loader paddle.io.DataLoader( val_ds, batch_sizeBATCH_SIZE, shuffleFalse ) best_f1, bad_epochs 0.0, 0 for epoch in range(EPOCHS): model.train() total_loss 0.0 for batch_id, (x, y) in enumerate(train_loader): logits model(x) loss criterion(logits, y) loss.backward() optimizer.step() optimizer.clear_grad() total_loss loss.item() val_f1 evaluate(model, val_loader, threshold0.5) print(fepoch{epoch 1}, loss{total_loss / len(train_loader):.4f}, val_macro_f1{val_f1:.4f}) if val_f1 best_f1: best_f1 val_f1 bad_epochs 0 paddle.save(model.state_dict(), best_model.pdparams) else: bad_epochs 1 if bad_epochs 3: print(early stop) break早停条件是验证集 macro-F1 连续三轮不增长作用是防止模型在训练集上过拟合。drop_lastTrue 会丢掉最后一个不完整的 batch避免 BN 或者 loss 统计受极小 batch 影响。这里用了 Adam学习率 2e-3 对 TextCNN 是常见起点如果 loss 震荡可以降到 1e-3。4.2 Micro/Macro F1 与验证集阈值扫描多标签分类的评估指标要谨慎选择。Micro F1 把所有标签的预测结果汇总后计算高频标签贡献更大Macro F1 先算每个标签的 F1 再取平均让低频标签和低频标签同等重要。要素识别场景通常更关心 Macro F1因为低频要素往往是案件定性的关键点。from sklearn.metrics import f1_score def evaluate(model, loader, threshold0.5): model.eval() y_trues, y_probs [], [] with paddle.no_grad(): for x, y in loader: logits model(x) probs F.sigmoid(logits).numpy() y_probs.append(probs) y_trues.append(y.numpy()) y_true np.concatenate(y_trues, axis0) y_prob np.concatenate(y_probs, axis0) y_pred (y_prob threshold).astype(int) return f1_score(y_true, y_pred, averagemacro)这里返回的是 macro-F1。如果你发现验证集效果和线上分数差异大多半是标签分布不一致而不是评估代码写错。阈值扫描是提升分数最直接的手段因为模型输出的概率一般不会精确落在 0.5 附近。for t in np.arange(0.30, 0.61, 0.05): score evaluate(model, val_loader, thresholdt) print(fthreshold{t:.2f}, macro_f1{score:.4f})阈值越低预测出的正样本越多召回率上升但精确率下降阈值越高则相反。最优值往往不在 0.5而落在 0.3 到 0.45 之间尤其当正样本比例很低时。注意只能用验证集扫描阈值选好之后用测试集做最终验证否则阈值会对测试集过拟合。阈值Macro F1现象0.300.7213召回高误报较多0.350.7358平衡较好0.400.7311精确率上升召回开始掉0.500.7025默认阈值通常不是最优上面是一个典型示例实际数字因数据分布而异但趋势是一致的先扫阈值再谈模型结构优化。4.3 长尾标签的类别权重与 Focal Loss 替代方案pos_weight 已经能压住一部分不均衡但某些标签可能在整个训练集里只出现几十次即使放大权重模型依然很难学会。一个更稳定的做法是在损失函数里加入 Focal Loss 思想让模型把注意力集中在那些置信度低、容易被分错的样本上。def focal_loss(logits, labels, gamma2.0, alphaNone): probs F.sigmoid(logits) p_t labels * probs (1 - labels) * (1 - probs) ce F.binary_cross_entropy_with_logits(logits, labels, reductionnone) focal_weight (1 - p_t) ** gamma if alpha is not None: focal_weight focal_weight * (labels * alpha (1 - labels) * (1 - alpha)) return (focal_weight * ce).mean()gamma 通常取 2代表对置信度高的简单样本降权alpha 是一个 0 到 1 之间的正样本权重可以按标签维度传入。和 pos_weight 相比Focal Loss 更平滑不容易让个别标签的损失值爆炸。实际项目里我自己会先跑一版 BCE pos_weight如果低频标签还是经常被预测成 0再切成 Focal Loss。5. 推理脚本、ONNX 导出与三个容易踩的坑训练结束后的推理脚本要复用训练时的 vocab、label2id 和 clean_text任何一个不一致都会导致线上效果暴跌。加载模型时先重建 TextCNN 实例再paddle.load读取保存的 state_dict。id2label {v: k for k, v in label2id.items()} def predict(model, text, vocab, label2id, id2label, max_len512, threshold0.45): model.eval() ids [vocab.get(t, 1) for t in list(clean_text(text))] if len(ids) max_len: ids ids[:max_len // 2] ids[-(max_len - max_len // 2):] ids ids [0] * (max_len - len(ids)) x paddle.to_tensor([ids], dtypeint64) with paddle.no_grad(): logits model(x) probs F.sigmoid(logits).numpy()[0] labels [id2label[i] for i, p in enumerate(probs) if p threshold] return labels, probs如果要把模型放到服务端可以先用paddle.jit.to_static把动态图模型转成静态图再用 paddle2onnx 导出 onnx 文件。导出命令大致如下paddle2onnx --model_dir ./inference_model/ \ --model_filename model.pdmodel \ --params_filename model.pdiparams \ --save_file model.onnx \ --opset_version 13导出之前要确认输入输出是固定形状尤其是 batch 维。TextCNN 里的 max_pool1d 对输入长度敏感所以推理时输入 id 必须 padding 到和训练一致的 max_len否则 onnxruntime 会因为 shape 不匹配报错。三个容易踩的坑一是标签顺序错位训练和推理用了不同的 label2id.json输出概率变成随机顺序二是文本清理不一致训练时清掉了全角空格推理时没有处理导致 OOV 暴增三是阈值变化验证集调优得到的阈值是 0.35上线时嫌误报高改成 0.5结果 F1 掉了两三个点。我自己的习惯是在训练目录里固定保存 vocab.json、label2id.json、threshold.txt 三个小文件推理项目直接读取不去手工拷贝配置。先保证推理脚本和实验阶段输入逻辑完全一致再去调线上阈值多标签分类的坑大部分都能被这两个习惯挡住。本文还有配套的精品资源点击获取
返回列表