简介:一份面向司法领域文本挖掘的机器学习实践资源,以刑事案件判决书为语料,通过Python实现法律文本分类与罪名/判决预测。随着法律文本公开规模扩大,利用自然语言处理技术构建司法语料库并自动分类,成为法律信息化的重要方向,该资源恰好覆盖从数据处理、特征构建到模型训练与预测的完整链路。项目整合了朴素贝叶斯、SVM、KNN等多种算法,并附有预处理后的数据集、停用词表与已训练模型,适合自然语言处理学习者、法律文本分析研究者及需要落地分类任务的开发者参考。压缩包共34个文件,约28.32MB,主要包含15个pkl模型文件、5个py源码文件、4个txt数据/停用词文件、6个xml工程配置及2个json数据集,结构清晰,代码注释处按提示启用即可运行。目前已有385人学习浏览,资源提供完整项目代码与多个训练好的模型,可快速复现实验结果或在此基础上扩展,对理解机器学习在法律文本分类中的实际应用具有直接帮助。
1. 机器学习做法律文本分类:这活儿看着常规,水比想象的深
法律文本分类在自然语言处理里是个典型的领域文本分类任务——把起诉状、判决书、调解书这类文书,自动归到对应的案由、罪名或法条上。它和新闻分类、商品评论分类最大的区别在于:错误代价极高。分错一个新闻标签,推荐系统顶多给你推错一条资讯;分错一个法律案由,后面的文书流转、统计报表、同类案件推送全部跟着错。尤其在法院的审判辅助系统、律所的卷宗归档、法律咨询平台的智能问答路由这些场景里,分类结果直接决定下游任务的质量,所以它从来不是“随便训个文本分类模型”就能交代的活儿。
这个方向适合三类人:第一类是刚开始接触领域文本分类的算法工程师,想找一个语料规范、标注逻辑清晰、业务价值明确的切入场景;第二类是做法律信息化系统但苦于人工分拣文书的人,想用机器学习替代一部分重复劳动;第三类是NLP学生,想理解“通用模型到领域落地”中间到底隔了多远。说实话,从公开的裁判文书和法规条文出发,你完全能在不接触任何敏感数据的前提下,把一套端到端的法律文本分类流程跑通。我最初做的时候也觉得“无非是文本分类”,真正接需求后才发现,挑战全在数据整理、类目体系设计和长文本处理上,模型反而不是瓶颈。这篇文章就按我踩过的路线,从数据准备讲到类目设计,再到模型选型和参数调优,最后落到一批真实文书上怎么检验效果。
2. 法律文本分类的底层逻辑:类目体系决定了任务上限
2.1 案由、罪名、法条:先搞清楚你要分的是什么
做法律文本分类第一件事不是选模型,而是确认你的标签体系到底是什么。法律领域里最常见的三个分类维度是案由、罪名和法条,它们长得像,但背后完全是三套逻辑。
案由是民事案件在立案登记时确定的案件性质,比如“民间借贷纠纷”“买卖合同纠纷”“机动车交通事故责任纠纷”。案由体系由最高法发布的《民事案件案由规定》统一规定,是一个层级化的树状结构——第一级有买卖合同纠纷、借款合同纠纷等大类,往下还有第二级、第三级。罪名是刑事领域的概念,来自《刑法》分则,比如“危险驾驶罪”“盗窃罪”“诈骗罪”。法条分类则更细,直接预测某个案件涉及的具体法律条文。
我在实际项目里见过最典型的翻车:客户说“做法律文本分类”,然后丢过来一批裁判文书,标注好的标签里混着案由、罪名、法条三种体系。你以为在做一个多分类任务,实际上类的语义粒度完全不一致——案由粒度粗、法条粒度极细,模型训练出来效果自然像抽签。常见做法是先把分类任务锁死到一个维度上:优先做案由分类,因为它层级清晰、样本相对充足、业务上最常用。罪名分类的问题在于长尾严重,很多罪名一年也没几个案例。法条分类则更偏向检索式系统,不太适合直接当分类任务来做。
如果你是第一次尝试这个方向,我的习惯是:第一阶段只做“民事案由三级分类”,这是整个方向上性价比最高的切入点。数据相对好找(从公开裁判文书里按模板抽取),类别数量落在数十到数百的量级,既不会因为类太少显不出模型价值,也不会因为类太多导致训练迟迟不收敛。
2.2 法律长文本的特点:为什么通用文本分类的经验不能直接套
法律文书和新闻标题、商品评论这类短文本的最大差异在于结构化和长。一份判决书动辄几千字,包含当事人信息、诉讼请求、事实与理由、本院查明、本院认为、判决结果等多个固定板块。有价值的分类信号分散在不同板块——案由分类的信号主要在“当事人争议焦点”和“本院认为”部分,而不是开头那一大段当事人信息。
通用文本分类最常见的手法就是对输入做截断——保留前512个token。这在BERT类模型上几乎是标准操作,因为模型最长序列就是512。但法律文本这么做会丢掉大量判决逻辑。我当时做了个实验:同一批民事判决书,只取前512字符训练出来的模型,F1在测试集上大约0.78;改用中段和后段截断拼接,F1直接跳到0.85。区别就在于判决书的案由信号很大程度集中在“本院认为”附近的论证段落,而这部分在全文后三分之一才出现。
所以处理法律文本分类的第一个习惯,是不要直接对全文做简单截断,而是先按文书结构切片。常见做法是用正则把“本院认为”“判决如下”这类固定标记位切出来,把不同板块的文本拼接成一个结构化输入,再送进模型。另一个习惯是善用文本统计特征——案件涉及的标的额、当事人数量、是否刑事附带民事,这些结构化字段往往比一段文字对分类更有区分度,把它们作为离散特征拼进模型,和文本特征做融合,效果通常好过单纯喂文本。
法律文本还有一个棘手之处:陈述方式高度模板化,看似相同的句式在不同案由下可能含义不同。比如“原告主张被告返还借款”在民间借贷纠纷里是核心事实,在买卖合同纠纷里只是欠付货款的一种表述。这种语义偏移很难通过通用预训练模型捕捉,需要领域数据做针对性微调。因此,第一版模型千万别指望零样本或者少样本就能在真实文书上可用,必须准备足够的领域标注数据。
3. 从零搭一套法律文本分类流程:数据、清洗与特征工程
3.1 冷启动数据从哪来:公开文书、标注规范和半自动扩充
在数据获取这件事上,要严格遵循合规红线。中国裁判文书网的批量抓取目前是受限的,个人开发者大规模下载裁判文书存在合规风险。我做的模拟项目X选的是另一条路:用公开的法律法规条文、法院发布的典型案例全文,以及某些高校实验室公开的法律文本数据集(比如某高校发布的民事裁判文书分类语料)来组装初始训练集。这些数据大多是脱敏处理过的,去掉了当事人真实姓名和身份证号,用“原告某某”“被告某某”替代。
拿到原始文本后,第一个步骤是清洗。一份从PDF转出来的判决书,经常带页码、页眉页脚、乱码换行、甚至表格线。我用下面这套流程做标准化,代码量不大但每一步都有用:
import re def normalize_legal_text(raw: str) -> str: # 去掉页码、页眉页脚 text = re.sub(r'[-—]*\s*\d+\s*[-—]*页?', '', raw) text = re.sub(r'第\s*\d+\s*页[,。]?', '', text) # 合并被PDF断开的中文行 text = re.sub(r'(?<=[^\d])\n(?=[^\d])', '', text) # 去掉当事人真实姓名痕迹,替换成占位符(模拟脱敏) text = re.sub(r'原告[^\s,。]{2,4}', '原告XX', text) text = re.sub(r'被告[^\s,。]{2,4}', '被告XX', text) # 压缩空白 text = re.sub(r'\s+', '', text) return text.strip()清洗逻辑里最关键的是第二行和第三行:合并PDF换行时用了一个负向断言,避免把“第1页”这种页码标记误并进正文。姓名脱敏用的是正则匹配“原告/被告+2到4个字”的模式,这个在批量处理时有效性很高。注意脱敏的目的是消除文本里的个人标识,不是为了把模型训练成“看到XX就是原告”,真正训练时占位符不会污染分类特征。
清洗完之后要解决的是标注数据的来源。常见做法是“规则预标注+人工抽检”:先写一套基于关键词的规则分类器,把“民间借贷纠纷”对应“借款”“偿还本金”“利息”等关键词,“买卖合同纠纷”对应“交付货物”“支付货款”“质量异议”等关键词,大量文书通过规则先打上预标签,再由标注人员对不确定样本做二次复核。这个方法能把标注成本压缩三分之二,同时保证冷启动语料有基本规模。
3.2 类别体系与样本分布的盘点:不平衡是必然,别心存侥幸
法律文本分类的标注类别天然是不平衡的,而且不是一般的不平衡。民事案由里“民间借贷纠纷”和“买卖合同纠纷”能占掉总样本的百分之三四十,“股权转让纠纷”“建设工程施工合同纠纷”的样本则寥寥无几。如果直接用原始分布训练,模型会对头部类别严重过拟合,尾部类别几乎学不到东西。
建类别体系时有三个实用技巧。第一,合并稀疏类别:凡是一级目录下样本数少于某个阈值(我一般定50条)的类别,直接上卷到父级类别,避免模型在几十个“微型类”里反复震荡。第二,层级分类优先:如果业务只要求一级类目,就用一级类目做训练标签,省去对细粒度标签的需求;如果业务需要三级类目,就做“父类预测+子类预测”的两级模型,不要试图用一个单标签分类器同时搞定多级粒度。第三,为每个类别保留5到10条“边界样本”作为验证集专用,防止跑实验时连验证集都被头部类别污染。
我通常在构造数据集时加一个可复现的切分逻辑,按文书编号做分层抽样而不是随机抽样,确保每个类别在训练集和验证集都有足够且相近的占比。分层抽样用sklearn的StratifiedShuffleSplit即可,唯一要注意的是它的n_splits参数我习惯设成1,因为多次切分只会增加实验调试的复杂度,不会带来额外信息。
3.3 特征工程的两条路线:轻量高特征和深度语义特征的取舍
模型选型上我习惯走两步。第一步,先用“记忆性强的传统模型”跑基线——TF-IDF或Word2Vec词向量,配合多分类SVM或XGBoost。这不是为了上线,而是为了用最廉价的方式验证类目体系是否合理、数据清洗是否到位、特征和标签之间是否存在基本相关性。如果传统模型跑下来F1连0.65都不到,说明数据质量或标签体系大概率有问题,此时不应该急着上BERT。
第二步再上预训练模型。我用的是bert-base-chinese作为底座,这个模型的词表覆盖中文常用字词,对繁体、生僻字的表现一般,但法律文本恰好是简体规范用语居多的场景,所以很合适。如果算力紧张,可以换roberta-wwm-ext或更轻的albert-chinese-tiny,但后者的精度会打折扣。法律文本里的专业术语密集,通用预训练模型没见过也没关系,微调过程会自行适应,不需要强行在预训练阶段用领域数据再做一遍mask语言建模,微调数据量足够时这样做收益有限且成本很高。
from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.svm import LinearSVC # 文本进TF-IDF之前先做关键词抽取,减轻维度压力 vectorizer = TfidfVectorizer(max_features=20000, ngram_range=(1, 2), min_df=2, max_df=0.95) X_tfidf = vectorizer.fit_transform(train_texts) # LinearSVC在文本高维稀疏特征上收敛快,适合做baseline clf = LinearSVC(C=1.0, class_weight='balanced', max_iter=2000) clf.fit(X_tfidf, train_labels) acc = clf.score(X_tfidf_val, val_labels) print(f'baseline acc: {acc:.4f}')这段代码里三个参数值得说明。max_features=20000控制了特征维度,法律文本用TF-IDF时词表容易膨胀到十万以上,但大多数词是低频噪声,截断到两万足够覆盖高频判别词。class_weight='balanced'对不平衡类别至关重要,它让SVM在损失函数里按类别频率做加权,给尾部类别更高权重。max_iter=2000是经验值,LinearSVC默认1000次迭代在手写文本上没问题,但法律文本的特征矩阵稀疏度不一样,加大迭代能避免提前收敛到次优解。
跑完传统基线的意义不在于分出高下,而在于看到上限。如果LinearSVC在某个类别的F1只有0.3,你要么去查这个类别的样本是否过于混杂、要么去看它的关键词是否和相邻类别高度重合,这个排查过程省下的时间一定会超过你在BERT上多调的几轮参数。
4. 微调BERT做法律文本分类:训练脚本与参数配置
4.1 长文本输入的结构化裁剪:不是截断,是重新组装
把预训练模型用到法律文本上,第一个绕不开的问题是512 token的序列长度限制。之前提到直接截前512字符不行,我把法律文本的板块重排和裁剪分成三步做。第一步,用标记词定位“事实与理由”“本院查明”“本院认为”“判决如下”四个核心段落。第二步,把每个段落各自做截断——因为判决书的论述密度在“本院认为”最高,这个段落保留最多,其他段落按比例缩减。第三步,把截断后的段落按“事实与理由+本院查明+本院认为”的顺序拼接成最终输入,并预留最多64个token给特殊标记和段落分隔符。
这样裁剪后,模型的输入不再是“从开头数512个字”,而是“按语义重要性筛选后的512个字”。实际操作中我还加了段落内部的滑动窗口:如果“本院认为”超过256个字,就从这个段落的开头和结尾各取128个字拼起来,这样既能保留段落首尾的判决逻辑,又不会把长段落的细节全丢光。
def build_model_input(text: str, max_len: int = 512) -> str: segs = split_legal_sections(text) # 返回dict: 事实, 查明, 认为, 判决 parts = [] # 事实部分只取前96字符 parts.append(segs['fact'][:96]) # 查明部分取前128字符 parts.append(segs['proved'][:128]) # 认为部分是核心,取前224字符,如果不够长则补尾部 opinion = segs['opinion'] if len(opinion) > 224: opinion = opinion[:176] + opinion[-48:] parts.append(opinion) # 最后用[SEP]拼接,总长度留给模型tokenizer做精确截断 return '[SEP]'.join(parts)[:max_len]这段代码里build_model_input的返回字符串在送入BERT前会经过tokenizer进一步编码,所以Python层面先做一个粗截断,细节交给tokenizer的truncation=True参数收尾。opinion部分“前176后48”的结构是我反复试出来的经验组合,能保留段落开头的判决事由和结尾的裁定结论。你可以按自己语料的情况调整比例,但记住一个原则:法律文书的决定性论据往往在段落的开头和结尾,中间是展开论述,压缩空间最大。
4.2 预训练模型微调:全套可复现的训练参数与验证逻辑
训练脚本我直接用transformers库的Trainer,它把梯度累积、学习率调度、评估循环都封装好了。但部署的时候有几个细节不能偷懒:保存策略要同时保存最优模型和最后一轮模型,防止验证集上过拟合导致最后一批权重反而变差;评估指标要同时计算宏观F1和微观F1,而不是只看accuracy,因为类别不平衡时accuracy会欺骗你。
from transformers import BertTokenizerFast, BertForSequenceClassification, Trainer, TrainingArguments tokenizer = BertTokenizerFast.from_pretrained('bert-base-chinese') model = BertForSequenceClassification.from_pretrained( 'bert-base-chinese', num_labels=len(label_list) ) training_args = TrainingArguments( output_dir='./legal_bert', learning_rate=3e-5, per_device_train_batch_size=8, per_device_eval_batch_size=16, gradient_accumulation_steps=4, num_train_epochs=6, warmup_ratio=0.1, evaluation_strategy='epoch', save_strategy='epoch', load_best_model_at_end=True, metric_for_best_model='f1_macro', fp16=True, logging_steps=50, ) trainer = Trainer( model=model, args=training_args, train_dataset=train_encodings, eval_dataset=val_encodings, compute_metrics=compute_f1_macro, ) trainer.train()这里的参数我逐个解释。learning_rate=3e-5是BERT微调常见的起点,法律文本作为领域数据与预训练分布差异不算极端,3e-5能稳定收敛;如果类别数很多或者文本特别长,可以降到2e-5。gradient_accumulation_steps=4配合单卡8的batch size,等效batch size是32,这是文本分类里精度和收敛速度的一个平衡点。warmup_ratio=0.1表示前10%的训练步数里学习率从零线性升到目标值,这个设置在多数NLP任务里都有效,不用调。fp16=True在支持混合精度卡上能省一半显存,但如果你用的是CPU推理或者老卡,训练时可关掉。
验证逻辑上我增加了一个小技巧:训练过程中保存所有epoch的模型快照,训练结束后在测试集上逐一加载对比,而不是只信Trainer自动选出的“最优”。因为metric_for_best_model用的是验证集,验证集和真实业务分布可能存在细微偏移,多跑几步手动对比能对冲这个偏差。
4.3 后处理:阈值校准与置信度过滤
模型输出的是每个类别的概率分布,直接取argmax作为最终分类在多数情况下没问题,但法律场景要求更高的精准率,宁可“拒分”也不“错分”。我给模型加了一个阈值置信度过滤:只有当最高概率超过0.6时才输出分类结果,否则标注为“待人工复核”。这个阀值范围的选择基于我在模拟项目X上的观察:置信度落在0.5到0.6之间的样本,人工复核后错误率显著高于0.6以上的样本。把这个区间样本拦截下来归入人工队列,能让最终进入业务系统的分类结果误判率下降近一半。
阈值校准还可以做得更精细:不要用全局阈值,而是按类别分别设阈值。头部类别的置信度普遍偏高,模型对“民间借贷纠纷”经常给出0.9以上的概率,但对“建设工程施工合同纠纷”最多只敢给0.5。如果全局阈值定0.6,大量尾部类别样本会被误伤。按类别分位数标定阈值,能保住尾部类别的召回率。我习惯在每个类别的验证集上分别计算置信度分布的20百分位,作为该类别的最小置信度门槛。
5. 法律文本分类的五个实战避坑记录
5.1 类别标签错位:把“原告诉请”当成了分类标签
现象:模型训练完,民间借贷纠纷的F1特别高,但点开错误样本一看,模型把“原告主张违约金过高,请求法院调减”这类文书分到了“劳动争议”。“违约金过高”这个表述在民间借贷里常见,在劳动争议的解除赔偿里也常见,模型根本没学到区分逻辑。
原因:标注数据时,标注者把“文书里出现了某关键词”当成“文书属于某案由”,没有严格按判决主文和争议焦点来定标。导致类别边界被关键词污染。
解决:我后来在标注规范里加了一条铁律:标签必须依据“本院认为”段落里对法律关系的定性,而不依据当事人的主张。代码层面,训练集构造时只从“本院认为”开始的文本块里提取特征,杜绝了这个问题。
5.2 长文本被截断后丢失决定性论据
现象:某版本的模型对“租赁合同纠纷”的召回率异常低,查了很多误召回样本后发现,模型把“承租人逾期返还房屋”判断成“民间借贷纠纷”。
原因:这条文书的“租赁关系”描述出现在“本院认为”段落的后半段,而前半段被大篇幅的“当事人举证质证”内容占据。裁剪策略把“本院认为”的前176字全留给了质证内容,真正定性的一句话落在被丢弃的下半部分。
解决:把裁剪逻辑改成“按句子而不是按字符截断”,并且在opinion部分先做一次句子级压缩:把证据罗列句、程序性套话(“上述事实,有……证据予以证实”)先删除,剩下的有效论述句子保留。这个改进拉升租赁合同纠纷的召回率约6个百分点。
5.3 类别极度不均衡导致模型“偷懒”
现象:训练到第3个epoch,验证集的整体损失还在下降,但“股权转让纠纷”的F1从0.5掉到0.3。
原因:模型发现只要把所有样本都预测成“民间借贷纠纷”,整体损失就会很低。类别权重在损失函数里贡献太小,压不住头部类别的惯性。
解决:一方面给损失函数加类别权重,另一方面对“股权转让纠纷”这类低频类别做SMOTE式文本过采样——不是复制原始文本,而是通过替换同义词和打乱论证顺序生成变体文本。这个方法直接让低频类别的F1回到0.5以上。注意过采样只用在训练集,验证集必须保持原始分布。
5.4 法庭记录的“你说的不算”陷阱
现象:测试集上表现很好的模型,跑到真实业务系统的历史文书中,准确率断崖下跌。
原因:公开数据集和真实文书之间存在分布偏移——公开的典型案例文书结构规范完整,真实历史文书有的缺段落、有的格式混乱、有的夹杂手写扫描件的OCR错误。模型没学会对“不完整结构”的鲁棒性。
解决:我在训练阶段给输入做三种数据增强——随机丢弃某一段落(模拟缺段)、随机打乱段落顺序(模拟扫描件错排)、把部分中文标点替换成英文标点(模拟OCR错误)。增强后的模型在真实下线样本上的表现明显更稳。
5.5 多标签场景误用单标签输出
现象:刑事判决书里被告人可能同时涉两个罪名,模型只输出一个,导致漏判。
原因:想当然把任务定成单标签多分类,明明业务场景是多标签多分类(一份文书可能同时对应危险驾驶和交通肇事两个罪名的分析)。用softmax输出天然限制了每样本只能激活一个类。
解决:把模型输出层从BertForSequenceClassification换成多标签版本,用sigmoid二分类交叉熵,每个类别独立做0/1预测。这个改动对民事案由场景影响不大,但对刑事罪名场景是必须的——我强烈建议在项目需求分析阶段就问清楚:一份文书能不能同时是多个类别。
6. 百案级文书批量预测:推理提速和评估的一个已验证技巧
模型训练完,最终要面对一批你没见过的文档。推理阶段有个容易被忽略的性能瓶颈:逐条送入BERT很慢,几百份文书的预测能拖到几分钟。两个工程化习惯能把速度提上来。第一是固定max_length=512并用padding=True,这样同一batch内序列等长,可以避免动态padding带来的额外计算浪费。第二是推理时关闭梯度计算,顺手把torch.no_grad()包在循环外,显存占用会明显下降。
import torch from torch.utils.data import DataLoader, TensorDataset def predict_batch(texts, model, tokenizer, batch_size=32, max_len=512): encodings = tokenizer( texts, padding='max_length', truncation=True, max_length=max_len, return_tensors='pt' ) dataset = TensorDataset(encodings['input_ids'], encodings['attention_mask']) loader = DataLoader(dataset, batch_size=batch_size) model.eval() preds = [] with torch.no_grad(): for batch in loader: input_ids, attention_mask = batch logits = model(input_ids, attention_mask=attention_mask)[0] probs = torch.softmax(logits, dim=-1) preds.extend(probs.argmax(dim=-1).cpu().numpy()) return preds评估时我习惯做“人工盲测十连抽”:从预测结果里随机抽十份文书,把模型预测的类别和业务人员点的类别并列展示,但不告诉业务人员哪个是模型预测。这个做法的价值在于,它能暴露出模型在“业务语言”上的偏差——比如模型能准确预测案由,但业务人员更关心的是“这个案由对应哪个审理庭”,两边的关心点可能错位。做一次盲测,比调十轮参数都更能告诉你模型离真正可用还差多远。
这套流程走下来,最大的教训是:不要一开始就把宝全押在模型上。数据和类目体系设计的优先级远高于调参。你花三天把类目层级理清、把裁剪策略调好,胜过花三周堆模型结构。我自己的习惯是:任何一次训练之前,先拿50条样本做人工跑通预测链路,确认文本处理和推理代码没有隐性bug后再上全量训练数据,这能拦住绝大多数“训练到一半发现是数据切分错了”的血泪事故。希望这个方向的经验对你做自己的法律文本分类项目有帮助。
本文还有配套的精品资源,点击获取