PaddlePaddle多标签文本分类实战:法律文书要素识别与工程化

发布时间:2026/9/13 14:59:08
PaddlePaddle多标签文本分类实战:法律文书要素识别与工程化 简介这是针对CAIL2019法研杯要素识别任务的多标签分类项目基于百度PaddlePaddle框架构建面向法律NLP初学者与深度学习实践者解决从裁判文书等文本中识别案件事实、争议焦点等多类要素的问题。压缩包共56个文件以Python源码11个py、编译后的pyc文件、txt文本数据、日志记录及一个pkl模型文件为主整体大小约502KB目录包含主项目、配置、数据、训练与测试模块便于直接对照学习。目前已有298人学习适合用于了解PaddlePaddle建模流程、BCE多标签损失函数的实际应用以及Micro F1/Macro F1指标的评价逻辑。项目还附带了README与运行日志可帮助掌握从数据预处理、模型构建、训练验证到保存预测的完整链路对自动化处理法律文档具有一定参考价值。1. 从CAIL2019要素识别说起为什么用Paddle做多标签分类如果你处理过法律裁判文书一定会对“要素抽取”有印象——同样一份判决书需要同时识别出“借款本金”“利息计算标准”“担保责任”等多类要素且这些要素往往叠加出现不是非此即彼。CAIL2019法研杯把这套任务做成了标准评测而多标签分类正是它的核心建模方式。最近我拆了一个基于PaddlePaddle的多标签分类项目源码结构和训练流程都比较完整适合想上手深度学习文本分类的人。PaddlePaddle的优势在于动静统一、部署工具链齐全而且它对中文字典和预训练模型的支持比多数框架更贴合中文场景。这个项目把数据预处理、模型定义、训练验证和预测打包在了一个较清晰的工程目录里接下来我从数据到训练逐步拆开讲。2. 法律文本预处理与样本构造从裁判文书到固定长度序列2.1 原始数据组织与标签编码项目里的data/train和data/test目录存放的是原始文本常见格式是每行一条 JSON包含文书原文和对应的要素标签。例如{text: 被告王某某于2018年3月向原告借款10万元约定月息1分。, labels: [民间借贷, 利息约定, 本金返还]}多标签分类的第一步是把这些不定数量的标签映射成固定维度的多热向量multi-hot。先读取所有训练样本统计出全部出现过的标签集合然后建立标签到序号、序号到标签的双向映射label_list sorted(set(label for sample in train_data for label in sample[labels])) label2id {label: idx for idx, label in enumerate(label_list)} id2label {idx: label for label, idx in label2id.items()} num_labels len(label_list)这段代码用集合去重后排序保证标签顺序稳定。label2id用于把原始标签名转成索引id2label用于推理时把索引转回标签名。num_labels决定模型输出层维度。注意这里排序不是必须的但稳定顺序能保证后续保存模型时索引不变避免预测结果错位。2.2 中文分词与词表构建法律文本有大量专业术语和固定表述直接按字切分也可行但分词能让模型更早捕捉词汇边界。这个项目没有依赖外部分词库而是直接用 PaddlePaddle 自带的jieba版本做预分词。我的做法是先对全部训练文本分词统计词频并过滤低频词from collections import Counter import jieba word_counter Counter() for sample in train_data: words jieba.lcut(sample[text]) word_counter.update(words) min_freq 1 vocab_words [word for word, cnt in word_counter.items() if cnt min_freq] word2id {word: idx 2 for idx, word in enumerate(vocab_words)} # 0: PAD, 1: UNK word2id[[PAD]] 0 word2id[[UNK]] 1这里把词频为 1 的词也保留下来因为法律文本中很多关键要素如案号、当事人姓名出现频率低但判别性强。word2id里预留0给 padding1给未登录词。如果换成字级别建模可以省去分词环节但需要更大的序列长度来容纳同样语义。2.3 序列截断、Padding 与 DataLoader深度模型要求同一批次内序列等长。法律文书长度差异极大短的几十字长的上万字。常见做法是设置一个max_seq_len例如 256超长截断、短则补零import numpy as np from paddle.io import Dataset def encode_text(text, word2id, max_seq_len): tokens jieba.lcut(text) ids [word2id.get(word, 1) for word in tokens] # 1 对应 UNK if len(ids) max_seq_len: ids ids[:max_seq_len] else: ids [0] * (max_seq_len - len(ids)) return np.array(ids, dtypeint64) class LegalDataset(Dataset): def __init__(self, texts, labels, word2id, max_seq_len, num_labels): self.texts texts self.labels labels self.word2id word2id self.max_seq_len max_seq_len self.num_labels num_labels def __getitem__(self, idx): input_ids encode_text(self.texts[idx], self.word2id, self.max_seq_len) label_vec np.zeros(self.num_labels, dtypefloat32) for label in self.labels[idx]: label_vec[label2id[label]] 1.0 return input_ids, label_vec def __len__(self): return len(self.texts)encode_text里的截断策略是直接取前max_seq_len个词这在长文本分类中会丢失尾部信息。更好的做法是首尾截断或按关键句加权但项目中基线模型采用简单截断也足够跑通。label_vec是多热向量一个样本可能同时有多个 1这是多标签与单标签在数据层面的本质区别。Paddle 的Dataset类需要实现__getitem__和__len__之后配合paddle.io.DataLoader使用。3. 用Paddle定义多标签分类模型从Embedding到Sigmoid输出3.1 模型结构选型文本多标签分类的常见基线有 TextCNN、TextRCNN、BiLSTM Attention 以及预训练模型加分类头。这个项目的src/model.py实现的是BiGRU Attention结构因为法律文本中要素往往分散在不同位置例如“借款金额”和“利息约定”可能在相隔很远的句子里双向循环网络能捕捉长距离依赖注意力机制再对关键位置加权。为什么不直接用 TransformerCAIL2019 数据量不算大训练语料只有数万条自注意力模型在小样本上容易过拟合而且显存占用高。BiGRU 参数少收敛快在 1080Ti 上训练一版 baseline 不到半小时。如果你想更快上分可以换成预训练的中文 BERT但要把序列长度缩到 128 以下否则显存不够。3.2 动态图实现Paddle 2.x 默认动态图模式模型定义像 PyTorch 一样直观。recognizer.py里的核心模块如下import paddle import paddle.nn as nn class MultiLabelRecognizer(nn.Layer): def __init__(self, vocab_size, embed_dim, hidden_size, num_labels, num_layers1, dropout0.2): super().__init__() self.embedding nn.Embedding(vocab_size, embed_dim) self.gru nn.GRU(embed_dim, hidden_size, num_layersnum_layers, directionbidirectional, dropoutdropout) self.dropout nn.Dropout(dropout) self.attention_linear nn.Linear(hidden_size * 2, 1) self.classifier nn.Linear(hidden_size * 2, num_labels) def forward(self, input_ids): emb self.embedding(input_ids) # [B, L, E] gru_out, _ self.gru(emb) # [B, L, 2H] score self.attention_linear(gru_out).squeeze(-1) # [B, L] score paddle.nn.functional.softmax(score, axis1) context paddle.sum((gru_out * score.unsqueeze(-1)), axis1) # [B, 2H] logits self.classifier(self.dropout(context)) # [B, num_labels] return logitsnn.GRU的directionbidirectional把两个方向的隐藏状态拼在一起输出维度是hidden_size * 2。注意GRU默认对每个时间步都输出而我们在注意力层把它们压缩成一个句子向量。attention_linear是一个标量打分层softmax在序列维度上归一化然后把gru_out按权重求和。最后classifier直接输出每个类别的 logits不再套 softmax——因为后面要用带 sigmoid 的损失函数。3.3 为什么用 Sigmoid 而不是 Softmax单标签分类的最后一层通常接 softmax让所有类别概率之和为 1。但多标签场景中一个文本可以同时属于多个类别softmax 会强制类别互斥导致错误建模。正确做法是让每个类别独立做二分类用 sigmoid 把 logits 压到 (0,1)再通过阈值如 0.5决定是否命中。probs paddle.nn.functional.sigmoid(logits) predictions (probs.numpy() 0.5).astype(int)这里的predictions是一个 batch 的 0/1 矩阵每一行代表一个样本的标签命中情况。阈值 0.5 是默认值实际可以按验证集的 F1 动态调优比如某些罕见要素的分数普遍偏低把阈值降到 0.3 能提升召回。4. 训练循环与调参BCEWithLogitsLoss、AdamW与Micro F1监控4.1 数据迭代与损失函数Paddle 的DataLoader会自动把Dataset返回的 numpy 数组转成 Tensor。训练循环里我习惯先定义损失函数和优化器from paddle.io import DataLoader import paddle.nn.functional as F BATCH_SIZE 32 EPOCHS 20 LEARNING_RATE 2e-3 loader DataLoader( train_dataset, batch_size BATCH_SIZE, shuffle True, num_workers 2, drop_last True ) def loss_fn(logits, labels): # 直接用 logits内部会做 sigmoid 再算 BCE return F.binary_cross_entropy_with_logits(logits, labels)这里必须用binary_cross_entropy_with_logits它把 sigmoid 和交叉熵合并计算数值上比手动sigmoid后接BCELoss更稳定。drop_lastTrue能避免最后一个 batch 样本数不足导致 BN 层报错但如果你没有 BN 层也可以不丢。优化器我选 AdamW相比 Adam 它把权重衰减从梯度中解耦更适合 Transformer 类模型以及带 L2 正项的循环网络。Paddle 里设置optimizer paddle.optimizer.AdamW( learning_rateLEARNING_RATE, parametersmodel.parameters(), weight_decay1e-5 )4.2 训练与验证循环每个 epoch 跑完训练集后必须在验证集上计算 Micro F1 和 Macro F1否则无法判断是否过拟合。下面是一个简洁的训练骨架best_f1 0.0 for epoch in range(EPOCHS): model.train() total_loss 0.0 for batch_id, (input_ids, labels) in enumerate(loader): logits model(input_ids) loss loss_fn(logits, labels) loss.backward() optimizer.step() optimizer.clear_grad() total_loss loss.item() avg_train_loss total_loss / len(loader) # 验证 model.eval() all_probs, all_labels [], [] with paddle.no_grad(): for input_ids, labels in val_loader: logits model(input_ids) probs paddle.nn.functional.sigmoid(logits).numpy() all_probs.append(probs) all_labels.append(labels.numpy()) all_probs np.concatenate(all_probs, axis0) all_labels np.concatenate(all_labels, axis0) preds (all_probs 0.5).astype(float32) micro_f1 f1_score_micro(all_labels, preds) macro_f1 f1_score_macro(all_labels, preds) if micro_f1 best_f1: best_f1 micro_f1 paddle.save(model.state_dict(), model/best_model.pdparams)这里我用model.train()和model.eval()切换 dropout 状态。验证时paddle.no_grad()节省显存。best_f1只监控 Micro F1因为 CAIL2019 官方主指标是 Micro F1但这会导致 Macro F1 偏低。如果想均衡可以用两者平均值作为保存标准。4.3 关键参数表与训练常见坑参数推荐值说明max_seq_len256过长增加显存过短丢失尾部要素embedding_dim200常用值太小表达不足太大易过拟合hidden_size128BiGRU 单方向维度双方向后 256num_layers1加深可以但小数据上 1 层更好训batch_size32显存不够时降为 16learning_rate2e-3AdamW 配合线性 warmup 更稳threshold0.5可在验证集上搜索 0.3~0.7训练中最常见的坑是标签向量没有转成float32导致 BCE 损失报类型错误。另一个坑是DataLoader默认drop_lastFalse如果最后一个 batch 只有 1 条样本BN 层会计算出 NaN。我的检查顺序是先看 loss 是否下降再看 Micro F1 是否随 epoch 上升最后抽查几个预测样例的置信度分布。如果置信度普遍集中在 0.5 附近说明模型没有收敛需要降低学习率或增大 embedding_dim。5. 模型保存、批量预测与Paddle项目的工程化打包5.1 用训练好的参数做推理保存的best_model.pdparams只是参数文件预测时还要重新实例化模型并加载model MultiLabelRecognizer( vocab_size len(word2id), embed_dim 200, hidden_size 128, num_labels len(label_list) ) model.set_state_dict(paddle.load(model/best_model.pdparams)) model.eval() def predict(text, word2id, id2label, model, max_seq_len256, threshold0.5): ids encode_text(text, word2id, max_seq_len) ids paddle.to_tensor([ids], dtypeint64) logits model(ids) probs paddle.nn.functional.sigmoid(logits).squeeze(0).numpy() labels [id2label[i] for i, p in enumerate(probs) if p threshold] return labels, probs这里的encode_text必须与训练时完全一致包括截断长度、分词器版本。另外一个容易踩的坑是模型内部有dropout层推理前务必调用model.eval()否则每次预测结果会随机变化。Paddle 的eval()只关闭 dropout 和 BN 的 running 统计更新不会改变梯度计算状态。5.2 把预测流程封装成命令行工具项目根目录的main.py已经提供了简单的命令行入口。为了让其他人能用我一般会加一个predict_file模式批量处理 JSON 文件并输出结果到out/目录python main.py --mode predict \ --model_dir model/best_model.pdparams \ --data data/test/test.json \ --output out/predictions.json \ --threshold 0.5 \ --max_seq_len 256对应的main.py骨架是import argparse, json if __name__ __main__: parser argparse.ArgumentParser() parser.add_argument(--mode, choices[train, predict], requiredTrue) parser.add_argument(--model_dir, typestr, defaultmodel/best_model.pdparams) parser.add_argument(--data, typestr, requiredTrue) parser.add_argument(--output, typestr, defaultout/predictions.json) parser.add_argument(--threshold, typefloat, default0.5) args parser.parse_args() if args.mode predict: # 加载词表、标签映射、模型 # 逐行读取 args.data调用 predict()写入 args.output pass使用argparse而不是硬编码路径能让项目被其他人复现时少改代码。threshold参数暴露给终端意味着你可以用不同阈值试跑再结合验证集选择最优。5.3 参考Paddle OCR的便携打包经验如果你要把这个项目交给没有 Python 环境的同事PaddlePaddle 生态里的 OCR 项目是一个很好的打包范本——它们常用PyInstaller把模型和代码打成一个独立可执行文件同时附带paddle的动态库。对本文的分类项目我的做法是pip install pyinstaller pyinstaller -F main.py --hidden-import paddle --hidden-import paddle.nn注意 Paddle 的某些底层算子如paddle.fluid是运行时加载的--hidden-import无法覆盖全部情况更稳妥的方案是直接把整个虚拟环境用conda-pack压成压缩包分发到目标机器解压后可直接运行。我在拆过的 Paddle OCR 便携版项目里还见过把模型权重、词典和可执行文件放在同一目录下通过sys.path[0]定位资源路径这样双击运行或命令行调用都不会报找不到文件的错。参考这个思路本项目在打包前应把model/best_model.pdparams、word2id字典和label2id映射统一放到resource/目录然后让main.py用os.path.dirname(os.path.abspath(__file__))拼接路径保证任意工作目录下都能找到依赖文件。最后可以把打包后的文件重命名为cail2019_recognizer.exe或cail2019_recognizer配合 README 里的参数说明就是一个完整的交付形态。本文还有配套的精品资源点击获取