简介:这是针对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给 padding,1给未登录词。如果换成字级别建模,可以省去分词环节,但需要更大的序列长度来容纳同样语义。
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, dtype="int64") 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, dtype="float32") 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结构,因为法律文本中要素往往分散在不同位置,例如“借款金额”和“利息约定”可能在相隔很远的句子里,双向循环网络能捕捉长距离依赖,注意力机制再对关键位置加权。
为什么不直接用 Transformer?CAIL2019 数据量不算大,训练语料只有数万条,自注意力模型在小样本上容易过拟合,而且显存占用高。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_layers=1, dropout=0.2): super().__init__() self.embedding = nn.Embedding(vocab_size, embed_dim) self.gru = nn.GRU(embed_dim, hidden_size, num_layers=num_layers, direction="bidirectional", dropout=dropout) 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, axis=1) context = paddle.sum((gru_out * score.unsqueeze(-1)), axis=1) # [B, 2H] logits = self.classifier(self.dropout(context)) # [B, num_labels] return logitsnn.GRU的direction="bidirectional"把两个方向的隐藏状态拼在一起,输出维度是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_last=True能避免最后一个 batch 样本数不足导致 BN 层报错,但如果你没有 BN 层,也可以不丢。
优化器我选 AdamW,相比 Adam 它把权重衰减从梯度中解耦,更适合 Transformer 类模型以及带 L2 正项的循环网络。Paddle 里设置:
optimizer = paddle.optimizer.AdamW( learning_rate=LEARNING_RATE, parameters=model.parameters(), weight_decay=1e-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, axis=0) all_labels = np.concatenate(all_labels, axis=0) 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_len | 256 | 过长增加显存,过短丢失尾部要素 |
| embedding_dim | 200 | 常用值,太小表达不足,太大易过拟合 |
| hidden_size | 128 | BiGRU 单方向维度,双方向后 256 |
| num_layers | 1 | 加深可以,但小数据上 1 层更好训 |
| batch_size | 32 | 显存不够时降为 16 |
| learning_rate | 2e-3 | AdamW 配合线性 warmup 更稳 |
| threshold | 0.5 | 可在验证集上搜索 0.3~0.7 |
训练中最常见的坑是标签向量没有转成float32,导致 BCE 损失报类型错误。另一个坑是DataLoader默认drop_last=False,如果最后一个 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_len=256, threshold=0.5): ids = encode_text(text, word2id, max_seq_len) ids = paddle.to_tensor([ids], dtype="int64") 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"], required=True) parser.add_argument("--model_dir", type=str, default="model/best_model.pdparams") parser.add_argument("--data", type=str, required=True) parser.add_argument("--output", type=str, default="out/predictions.json") parser.add_argument("--threshold", type=float, default=0.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 里的参数说明,就是一个完整的交付形态。
本文还有配套的精品资源,点击获取