news 2026/10/24 4:20:35

法律文本分类实战:朴素贝叶斯与TF-IDF特征工程链路

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
法律文本分类实战:朴素贝叶斯与TF-IDF特征工程链路

简介:「机器学习实现法律文本分类」是一套面向自然语言处理初学者、司法文本挖掘实践者及课程设计学生的项目源码,围绕刑事案件判决书构建语料,使用贝叶斯、支持向量机、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 + grams

normalize里的三次替换,本质上是在人为降维:把“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 out

fit里最值得看的是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 是多少。这个数字是地板,任何模型必须显著高于它才算学到了东西。如果模型只比先验基线高两三个点,说明它基本在靠分布投票,特征层一定有问题。这个检查只花五分钟,却能省掉几天的弯路。

从那以后我每次启动一个文本分类项目,都强制先跑三件事:先验基线、分组切分、以及停用词消融。这三步走完,后面的模型选择反而变得轻松——因为你已经知道数据里到底有多少信号。希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/24 4:20:34

D7-D12.2 安装 XLSReadWriteII 6.02.01 避坑

简介&#xff1a;这是面向 Delphi 开发者的 XLSReadWriteII 6.02.01 第三方 Excel 读写组件包&#xff0c;适用于需要在 Object Pascal 项目中直接创建、读取和修改工作簿的中高级开发者&#xff0c;可解决 Delphi 应用与 .xlsx 等表格格式之间的数据互通问题。压缩包共 505 个…

作者头像 李华
网站建设 2026/10/24 4:20:28

.NET WebSocket 长连接实战:连接管理、心跳重连与广播

简介&#xff1a;这份围绕 .NET WebSocket 的示例资料面向具备 C# 基础、希望在 WinForms 中实现实时通信的开发者&#xff0c;重点解决客户端与服务端连接建立、消息收发、异常关闭及与网页测试端联调等问题。压缩包共 174 个文件&#xff0c;约 984KB&#xff0c;以 47 个 cs…

作者头像 李华
网站建设 2026/10/24 4:19:01

Visual Studio Win32五子棋:C++ GUI工程与AI实战

简介&#xff1a;本资源是一个基于Visual Studio开发的五子棋桌面游戏完整工程&#xff0c;面向C/C#初学者与图形界面编程学习者&#xff0c;解决从零实现人机对战与人人对战游戏的核心难点。项目采用Windows Forms框架&#xff08;含EasyX图形库调用&#xff09;&#xff0c;涵…

作者头像 李华
网站建设 2026/10/24 4:17:40

VS2003安装配置指南:XP SP3虚拟机与ASP.NET 1.1环境

简介&#xff1a;面向.NET初期开发者的Visual Studio .NET 2003简体中文安装包&#xff0c;适合需要回顾微软早期开发平台、维护旧项目或研究IDE演进的技术人员。该版本以.NET Framework 1.1为核心&#xff0c;提供C#、VB.NET、C .NET等多语言支持&#xff0c;并集成智能感知、…

作者头像 李华
网站建设 2026/10/24 4:17:38

MQTTnet 同进程 Broker/Client 联调与落盘实践

简介&#xff1a;这是一份面向.NET桌面开发者的MQTT通信示例代码包&#xff0c;围绕WinForm与MQTTnet展开&#xff0c;重点演示服务端与客户端之间的连接、订阅/发布流程&#xff0c;以及将订阅到的消息保存到文件。包内包含服务端与客户端两个独立工程&#xff0c;8个cs源码文…

作者头像 李华
网站建设 2026/10/24 4:17:34

大模型推理框架源码实战:KV Cache、连续批处理与压测调参

简介&#xff1a;VLLM-0.7.3 源码包面向大语言模型推理引擎的学习者、研究者与工程实践者&#xff0c;聚焦超大规模模型在高速令牌生成、高效内存管理与企业级稳定推理上的性能优化问题&#xff0c;适合具备一定 Python 与深度学习基础、希望深入理解推理调度与算子实现的读者。…

作者头像 李华