简介:「机器学习实现法律文本分类」是一套面向自然语言处理初学者、司法文本挖掘实践者及课程设计学生的项目源码,围绕刑事案件判决书构建语料,使用贝叶斯、支持向量机、KNN与随机森林等方法完成罪名分类和案情判决预测。压缩包共34个文件,约28.32MB,主要由5个Python脚本、15个pkl模型文件、若干txt停用词与词表、json格式数据集以及xml配置文件组成,覆盖文本清洗、特征提取、模型训练与测试评估的完整链路。已有385人学习下载。包内提供已训练模型、停用词表和处理后的数据集,可直接复现实验并观察不同算法的分类效果;部分代码保留注释,运行前需按说明去除,适合用来理解法律语料构建、特征工程与模型调用关系,也便于对比不同模型在司法语料上的表现,并在此基础上调整特征与参数,可作为相关课程项目或毕业设计的参考底稿。
1. 法律文本分类的反直觉起点:简单模型为什么常常赢
做机器学习法律文本分类,最先要接受的一个反直觉结论是:在几万条量级的语料上,一个平滑系数调对的朴素贝叶斯,经常把参数量大它三个数量级的模型按在地上摩擦。原因并不神秘——这类任务的信号密度极高,“本院认为”“驳回诉讼请求”“不当得利”这些词组本身就是强判据,模型要做的是稳稳当当地把它们累加起来,而不是去学复杂的组合方式。真正决定成败的是链路:文本怎么变成向量、条件独立假设错在哪里却为什么够用、先验概率如何悄悄替你做决定。这条链路一旦读懂,指标掉下来时你至少知道该拧哪颗螺丝,而不是盲目换模型、加数据、调学习率,把玄学当方法论。这份资源把整条链路拆成了可运行的代码,值得逐行看一遍。
2. 从判决书到特征向量:分词、归一化与法律术语的边界
原始文书是一段几千字的连续汉字,模型看不见它。中间要经过三步:归一化把“具体”抹成“抽象”,分词把字符串切成离散符号,向量化把符号映射成稀疏矩阵里的下标。这三步任何一步做歪,后面的模型再强也只是在拟合噪声。很多人把精力全放在选模型上,血泪经验是——特征层的收益远大于模型层,尤其在法律这种术语封闭、句式高度模板化的领域。
2.1 通用分词器为什么会在“不当得利”上翻车
通用分词器的词表来自通用语料,“不当得利”作为一个整体出现的频次,远低于“不当”和“得利”相邻出现的频次,于是它大概率被切成两段。切碎之后,特征空间里就多出两个语义不完整的碎片,模型只能靠这两个碎片和其他词的共现去间接推断,判别力被稀释。法律术语大多是封闭集合——案由名称、程序性用语、责任形态,这些是可以在项目启动时穷举出来的,所以挂一份领域词典是投入产出比最高的一步。
但词典不能贪多。把整部法律条文里的长短语都塞进用户词典,会触发过切分:本来靠“认为”这个通用词泛化到其他句式的信号,被“本院认为”这个整体 token 吃掉了,而“本院认为”在训练集里可能只出现几百次,不足以支撑统计。我一般只把2 到 6 个字、且通用分词必错的术语放进去,比如“不当得利”“表见代理”“无因管理”,长短语交给后续的 n-gram 去覆盖。
中文还有个绕不开的选择:词粒度还是字粒度。词粒度精度高,但严重依赖分词质量,遇到新案由或生僻表述直接 OOV;字粒度(尤其是字符 2-gram)召回高,因为任何词都由字组成,未登录词也能留下部分信号,代价是特征维度暴涨、可解释性变差。工程上的稳妥做法是两路拼接:词特征保证精度,字符 2-gram 兜底。下面的预处理函数就是按这个思路写的。
import re import jieba # 只装"通用分词必错"的短术语,避免过切分 jieba.load_userdict("legal_terms.txt") # 停用词做减法:只删真正无信息量的虚词,否定词一个都不能删 STOPWORDS = {"的", "了", "和", "及", "其", "该", "本", "之"} def normalize(text: str) -> str: """把高基数的事实性片段替换成占位符,主动做一次特征抽象""" text = re.sub(r"\d{4}年\d{1,2}月\d{1,2}日", " <DATE> ", text) text = re.sub(r"\d+(\.\d+)?元", " <MONEY> ", text) text = re.sub(r"[((]\d{4}[))][^\s,。]{1,20}号", " <CASENO> ", text) return text def tokenize(text: str, use_char_ngram: bool = True): text = normalize(text) words = [w for w in jieba.lcut(text) if w.strip() and w not in STOPWORDS] if not use_char_ngram: return words # 字符 2-gram 兜底:新案由、生僻术语不在词表里时仍能留下信号 chars = re.sub(r"\s+", "", text) grams = [chars[i:i + 2] for i in range(len(chars) - 1)] return words + gramsnormalize里的三次替换,本质上是在人为降维:把“2021年3月5日”和“2019年7月12日”映射到同一个<DATE>符号,模型就不会去记具体日期,而是专注在句式和术语上。use_char_ngram是一个开关,字符 2-gram 让特征维度大约翻三到五倍,样本少于一万条时能明显抬高召回,样本充足时反而拖慢训练、引入噪声,所以做成可切换的参数。STOPWORDS集合故意写得很短,理由在下一节。
注意:归一化是有代价的。如果任务本身与时间相关(比如判断某类文书属于哪个程序阶段),把日期抹成占位符等于把最有判别力的特征扔了,这时候应该保留年份、只抹月和日。
2.2 停用词表是这条链路上最大的陷阱
通用停用词表是为检索系统设计的,目标是把“的、了、不、无、未”这类高频虚词删掉以压缩索引。搬到法律文本分类上,这是一个典型的翻车点:法律文书里最关键的判据恰恰藏在否定词里。“承担”和“不承担”、“履行”和“未履行”、“有异议”和“无异议”,在删除否定词之后落进特征空间的位置几乎重合,模型的决策边界直接塌掉。
这个坑非常隐蔽,因为训练集上的准确率可能还挺好看——毕竟同类文书里否定表达的比例稳定,模型学到了“这类文书里‘承担’出现得多”,只是学到的是相关性而非因果。等换一批分布稍有不同的文书,指标断崖式下跌。
正确的做法是把停用词表当成一个需要验证集的超参数,而不是从网上抄一份。具体操作是:初始只放十几个绝对无信息量的虚词,然后逐组往里加候选词,每加一组就在验证集上看一次宏平均 F1,只有指标不降才保留。这个过程慢,但它能挡掉 80% 的“模型明明很努力但就是不涨”的困惑。
2.3 向量化:稀疏矩阵与词表规模的控制
分词输出的 token 列表要变成定长向量。词袋模型建一张token -> 下标的词表,每篇文书表示成一个长度等于词表大小的稀疏向量。法律语料的词表规模很容易失控:十万篇文书、词粒度加字符 2-gram,词表能到几十万维,而每篇文书实际命中的只有几百到几千维,稀疏度超过 99%。
这个稀疏性不是缺点,是这类任务能跑起来的前提——它让朴素贝叶斯的一次预测只需要遍历文档里实际出现过的token,而不是整个词表。控制词表规模的手段有三个:min_df过滤掉出现次数少于阈值的 token(通常是 2 到 3,把错别字和偶然噪声挡在外面)、max_df过滤掉在超过 80% 文档里都出现的 token(作用类似停用词,但数据驱动)、以及可选的卡方检验保留 top-k 特征。min_df=2这个参数看起来无害,实际上它决定了模型对长尾术语的态度:设成 1,词表膨胀且模型开始记忆只出现一次的噪声;设成 3,很多真实但少见的法律术语被误删。
3. 朴素贝叶斯:一个明显错误的假设,为什么反而更稳
3.1 条件独立假设错得离谱,但 argmax 只需要冠军正确
朴素贝叶斯的核心假设是:给定类别,文档里每个词的出现相互独立。这在法律文本里显然不成立——“驳回”后面跟着“诉讼请求”的概率,远高于跟着“不当得利”。所以它输出的概率值几乎不可信,经常给出 0.999 这种极端值。
但它能 work,原因在于分类任务只要求argmax P(c|d)正确,不要求概率校准。把每个词的对数似然累加起来,真正起作用的只是那几十个强判据词——它们的方向一致,累加之后就把正确类别顶了上去。条件独立假设带来的误差,主要体现在概率的绝对值上,而不是排序上。换句话说,朴素贝叶斯像一个只记关键词、不看语序的书记员,判对案由够用,但你不能拿他的“确信度”去做风险定价。
从偏差—方差的角度看更清楚:这个假设是强先验,模型容量被压得很低,方差小、偏差大。法律语料的标注成本极高,几千条标注数据是常态,这种“高偏差低方差”的模型在小样本上比容量大的模型更稳。
3.2 对数域与拉普拉斯平滑:两个必须自己实现的细节
直接按公式连乘会立刻出问题。一篇三千字的文书,几百个 token 的概率值都在 0.001 量级,连乘下来远小于浮点数能表示的最小值,结果下溢成 0,所有类别得分都是 0,argmax退化成随机选。解决办法是全程在对数域计算,把连乘变成连加:log P(c|d) = log P(c) + Σ log P(w|c)。因为对数单调递增,比较大小的结果不变,而预测时根本不需要归一化分母,省掉一次全类别求和。
第二个必须处理的是零概率。如果某个词在训练集里从未在类别 c 下出现过,P(w|c) = 0,一条对数项变成负无穷,整篇文档直接判死。拉普拉斯平滑的做法是给每个词的计数加 α,同时分母补上α * |V|保持概率和为 1。α 取 1 是最常见的默认值,但在法律文本上我一般从 0.1 到 1.0 之间扫一遍——α 越小模型越自信,长尾词的影响越大;α 越大分布越平滑,接近均匀分布,小类别受益。
import numpy as np from collections import defaultdict class MultinomialNB: def __init__(self, alpha=1.0): self.alpha = alpha # 平滑系数,法律文本上常在 0.1~1.0 之间扫 def fit(self, docs, labels, vocab): self.vocab = {w: i for i, w in enumerate(vocab)} self.classes = sorted(set(labels)) V = len(self.vocab) counts = {c: np.zeros(V) for c in self.classes} doc_counts = defaultdict(int) for doc, y in zip(docs, labels): doc_counts[y] += 1 for w in doc: j = self.vocab.get(w) if j is not None: counts[y][j] += 1 # 多项分布:只累计出现次数 n = len(labels) self.log_prior, self.log_likelihood = {}, {} for c in self.classes: self.log_prior[c] = np.log(doc_counts[c] / n) # 先验就是案由占比 num = counts[c] + self.alpha den = counts[c].sum() + self.alpha * V # 分母补 alpha*|V| self.log_likelihood[c] = np.log(num / den) return self def predict(self, docs): out = [] for doc in docs: best_c, best_s = None, -np.inf for c in self.classes: # 对数域累加:避免上千个小于 1 的概率连乘下溢成 0 s = self.log_prior[c] for w in doc: j = self.vocab.get(w) if j is not None: s += self.log_likelihood[c][j] if s > best_s: best_c, best_s = c, s out.append(best_c) return outfit里最值得看的是den那一行:counts[c].sum()是类别 c 下所有 token 的总出现次数,加上alpha * V之后,每个词的平滑增量在归一化后恰好互相抵消,概率分布仍然合法。predict里的内层循环只在vocab.get(w)命中时才累加,这就是稀疏性带来的效率——词表几十万维,一次预测实际只算几百次加法。
3.3 多项式模型与伯努利模型:长文档上的差别巨大
同一个朴素贝叶斯,事件模型选错会差出十几个点。多项式模型只看词出现了几次,没出现的词不参与计算;伯努利模型把每个词当成一个二元特征,没出现也要计入(1 - p)的对数项。
在法律文书这种长文档上,词表动辄十万维,一篇文书里没出现的词有九万多个。伯努利模型会把这些“没出现”的证据全部累加进去,负向项彻底压过正向项,判别力被淹没。所以长文本、大词表的场景下应该用多项式模型;伯努利模型适合短文本(短信、标题、短评),每条文本的维度接近词表规模时它才有优势。
4. 先验、阈值与评估口径:指标为什么会骗你
4.1 先验概率就是案由分布,它会替你投票
log_prior那一项的值等于log(类别文档数 / 总文档数)。语料里买卖合同纠纷占 40%、劳动争议占 5%,先验项就给前者加了约 2 分的底分,后者是 -3 分,一进一出差了 5 分。而一个中等强度的证据词贡献的对数似然往往只有 1 到 2 分,也就是说先验足以推翻证据。
这在类别不均衡的语料上是致命的:模型会倾向于把所有模糊样本推给大类,宏平均 F1 被小类别拖垮,但准确率看起来还不错。修法有两条路,一条是在训练时按类别频率给损失加权,另一条是在预测后调整决策阈值——后者更可控,因为你可以直接按业务代价设定“宁可多报不可漏报”的偏置。做法是对每个类别单独算一个偏置项b_c加到得分上,用验证集网格搜索。这个方法的好处是模型主体不动,可以反复调,不像重训那样每次都换一颗骰子。
4.2 宏平均与微平均:95% 的准确率是怎么来的
一个十类、每类样本量差十倍的数据集,全部预测成最大类,准确率就能到 40% 以上;如果最大类占 95%,准确率就是 95%,而模型实际上什么都没学到。这就是为什么法律文本分类不能只看 accuracy。
宏平均 F1 对每个类别一视同仁,先算每类的 F1 再求平均,小类别表现差会立刻暴露;微平均 F1 按样本量加权,大类主导,数值上接近准确率。两个都看,差距越大说明类别不均衡越严重。除此之外还要看每类的召回率——在法律场景里,漏判(把 A 案由判成 B)和误判的代价往往不对称,业务方真正关心的是某个特定案由的召回,而不是一个笼统的平均数。
4.3 按案件分组切分:数据泄漏最隐蔽的一种形式
随机切分训练集和测试集,是这类任务最经典的翻车方式。同一个案件的一审和二审判决书、同一份文书被切成几段后分别标注、批量生成的模板化文书,都会在随机切分下同时落进训练集和测试集。模型在测试集上看到的句子,训练时已经逐字见过了,指标虚高到 0.98,上线后掉到 0.6。
正确的做法是按案件 ID 分组切分:一个案件的所有文书只能整体落在同一折里。GroupKFold就是干这个的。如果数据带时间戳,更严格的做法是按时间切——用过去的文书训练,用未来的文书测试,这才模拟了真实的上线环境。
from sklearn.model_selection import GroupKFold, cross_val_predict from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.naive_bayes import MultinomialNB from sklearn.pipeline import make_pipeline from sklearn.metrics import f1_score, classification_report # groups 传案件 ID:同一案件的一审/二审必须待在同一折 gkf = GroupKFold(n_splits=5) pipe = make_pipeline( # analyzer 用恒等函数,因为入参已经是 token 列表,不再二次分词 TfidfVectorizer(analyzer=lambda x: x, sublinear_tf=True, min_df=2), MultinomialNB(alpha=0.5), ) y_pred = cross_val_predict(pipe, X_tokens, y, groups=case_ids, cv=gkf) print(f1_score(y, y_pred, average="macro")) # 宏平均:每类权重相同 print(classification_report(y, y_pred, digits=3))cross_val_predict返回的是每个样本在其所在折被预测的结果,等价于一次完整的“离线模拟上线”。sublinear_tf=True把词频换成1 + log(tf),抑制长文档里重复出现的模板句;analyzer=lambda x: x是因为前面的tokenize已经切好了,不能再让向量化器切一遍。alpha=0.5是在验证集上扫出来的,不要照抄。
提示:分组切分之后指标通常会掉 10 到 30 个点,这是好事——掉下去的那部分本来就是你之前骗自己的。
5. 排查清单:五条踩过的坑与对应改法
5.1 数据层的两个坑
坑一:训练集 F1 0.98,上线 0.6。现象是离线指标漂亮得不像话,换一批新文书立刻原形毕露。原因是模板化文书和重复样本造成泄漏——某类案由的文书由同一套模板批量生成,句式完全一致,随机切分后训练集和测试集里各有一份。解决办法是先做近似去重(对 token 集合算 Jaccard 相似度,超过 0.9 的只留一条),再改用GroupKFold按案件 ID 切分。改完之后指标掉到 0.72,那才是真实水平。
坑二:某个案由的样本被过度降采样。现象是训练时为了让类别均衡做了随机欠采样,大类从两万条砍到三千条,结果这个案由下的细分类型全被削平了。原因是欠采样是随机丢的,丢掉的样本里恰好包含了区分细分类型的关键句式。解决办法是改用类别权重而不是丢样本,或者在欠采样时按子类型分层抽样。样本是稀缺资源,能加权重就不要删数据。
5.2 特征与模型层的两个坑
坑三:加了字符 n-gram 之后指标不升反降。现象是特征维度从八万涨到四十万,验证集 F1 掉了三个点。原因是小样本下高维特征让模型开始记忆噪声,且min_df=2在新特征上过滤不掉长尾组合。解决办法是先做卡方检验保留 top-50000 特征,或者把字符 n-gram 的权重整体乘一个小于 1 的系数再拼接。特征不是越多越好,维度和样本量要匹配。
坑四:预测概率全是 0.999 或者全是一样的值。现象是predict_proba输出的分布极端,没法拿来做人工复核的排序。原因通常有两个:一是没在对数域计算导致下溢后做了错误的归一化;二是alpha设得太小,长文档里几百个 token 的对数项累加后把分布推到了边界。解决办法是把alpha调大、检查是否在 log 域累加,并且明确一件事——朴素贝叶斯的概率值本来就不该当作置信度用,要排序的话用得分差(top1 减 top2)比用概率更靠谱。
5.3 评估与上线层的一个坑
坑五:换一批文书,准确率断崖式下跌,OOV 率飙到 30%。现象是新数据里大量 token 不在训练词表里,模型实际上是在用先验做预测。原因是分布漂移——新增了案由类型、文书格式改版、或者数据来源换了一个辖区。解决办法分三层:短期用字符 n-gram 兜底,让未登录词至少留下部分信号;中期监控每次推理的 OOV 率和预测类别分布,偏了就报警;长期建立定期重训机制,并且把新出现的 token 按频次沉淀进领域词典。上线不是终点,是一个需要持续观测的系统。
6. 取舍:法律文本分类的瓶颈从来不在模型容量
把整条链路走完,会发现真正的决策点只有几个,而且都跟模型容量无关。
| 方案 | 标注需求 | 训练成本 | 可解释性 | 增量更新 |
|---|---|---|---|---|
| 朴素贝叶斯 + TF-IDF | 千条级 | 秒级 | 强,可逐词给出贡献 | 增量计数即可 |
| 线性 SVM | 千条级 | 分钟级 | 中等,需要看权重 | 需重训 |
| 字符级 CNN | 万条级 | 小时级 | 弱 | 需重训 |
| 预训练模型微调 | 万条级 | 小时到天级 | 弱,需额外归因 | 需重训 |
这张表里最该注意的是“可解释性”这一列。法律场景对判错原因的追溯是硬需求,业务方要问“为什么这条被判成劳动争议”,你得能说出是哪几个词起了作用。朴素贝叶斯的对数似然贡献可以直接拆到 token 级别,一行代码就能输出 top-10 正负证据词;黑盒模型要靠事后归因,归因结果还不稳定。这就是为什么在这个领域,简单模型的生命周期往往比想象中长。
另一个常被忽略的取舍是标注成本与特征工程成本的换算。多标五千条数据的成本,可能远高于花两天认真做一遍归一化和停用词验证。我见过太多团队一上来就冲模型,把 80% 的时间花在调参上,最后发现瓶颈是停用词表里删掉了“不”。
验证方法上,我习惯做一次“先验基线”测试:完全不看文本,只用类别频率做预测,看宏平均 F1 是多少。这个数字是地板,任何模型必须显著高于它才算学到了东西。如果模型只比先验基线高两三个点,说明它基本在靠分布投票,特征层一定有问题。这个检查只花五分钟,却能省掉几天的弯路。
从那以后我每次启动一个文本分类项目,都强制先跑三件事:先验基线、分组切分、以及停用词消融。这三步走完,后面的模型选择反而变得轻松——因为你已经知道数据里到底有多少信号。希望帮到你。
本文还有配套的精品资源,点击获取