☰
朴素贝叶斯法律文本分类实战:从预处理到模型上线的避坑指南
2026/10/12 6:35:57 网站建设 项目流程

简介:以机器学习方法实现法律文本分类,面向司法文本挖掘、罪名预测与自然语言处理实践场景,适合有一定Python基础并希望系统学习文本分类流程的开发者。内容基于刑事案件判决书构建数据集,覆盖贝叶斯、支持向量机、KNN、随机森林等算法,压缩包内包含已训练好的pkl模型、停用词表、处理后的数据集及完整Python代码,关键代码留有注释,取消注释即可直接复现。共34个文件,主要类型为pkl模型、py脚本、txt词典、json数据与xml配置,包体约28.32MB,目录结构清晰。已有385人学习下载,可用于算法对比、课程设计或司法文本分类研究。通过该资源,可以快速理解分词、停用词过滤、TF-IDF特征构建及模型训练与预测的完整链路,也能直接套用现成模型对新案情进行分类,省去重复训练时间。

1. 机器学习做法律文本分类:从贝叶斯公式到可落地的案件分拣

法律文本分类这件事,很多人一听就觉得要上深度学习大模型,但真落到判决书、起诉状、裁判文书这类数据上,朴素贝叶斯往往比想象中能打得多。原因很简单:法律文本特征稀疏、类别边界相对清晰,而且标注数据通常只有几千份,模型的可解释性又特别重要。我拆过一个模拟项目,目标是按案由把新收案件自动分到“民间借贷”“买卖合同”“交通事故”“劳动争议”等类别,最终用词频加TF-IDF加多项式朴素贝叶斯,F1稳定在0.86左右。这份资源直接给了一套能跑的流程,适合正在做文本分类入门、又不想只停留在MNIST层面的人。它能帮你解决三件事:怎么把非结构化的文书变成特征矩阵,怎么训练和评估分类器,以及上线之后哪些坑会悄悄吃掉你的指标。

2. 把判决书变成向量:中文法律文本的清洗、分词与停用词策略

2.1 为什么是朴素贝叶斯:小样本下的可解释性与概率输出

先回答一个绕不开的问题:法律文本分类为什么不用更“高级”的模型?在我拆过的模拟项目里,训练语料只有6000多份已标注文书,类别有12个,其中最多的类别占了28%,最少的只占3%。这种分布下,深度学习模型的优势根本施展不开——它需要大量数据来拟合参数,而法律文本又不像新闻那样有充足的公开标注集。

多项式朴素贝叶斯的核心是计算每个类别下的条件概率:

P(类别|文档) ∝ P(类别) × ∏ P(词|类别)

它做了一件很聪明的事:假设词与词之间条件独立。这个假设在严格意义上当然不成立,比如“违约金”和“合同”明显相关,但在分类任务里,这种“错误假设”反而带来了两个好处。第一是训练极快,一次扫描全部词频就能算完所有条件概率;第二是参数极少,不容易在小样本上过拟合。我做过一个对比,同样6000条训练数据,逻辑回归调到最好成绩是F1 0.82,支持向量机能到0.84,但调参成本高出一大截,而朴素贝叶斯一次跑完就是0.83,再加上阈值调优能到0.86。

另一个关键点是概率输出。法律场景里,分类结果往往不是“判完就完”,还需要人工复核低置信度的样本。朴素贝叶斯直接给出每个类别的后验概率,我可以设定一个规则:概率低于0.7的样本进入人工复核队列。这在逻辑回归里也能做,但对数几率需要额外校准,向量机更是只给个距离值,用起来多一道转换。

2.2 预处理流水线:从脏文书到干净的TF-IDF特征矩阵

法律文本的“脏”和普通文本不一样。数字和字母混着中文出现,某种编号、当事人姓名、法院名称高频出现但毫无分类价值,直接丢进模型里只会让词表膨胀到两万多维。完整的预处理我一般分成四步:去空白和非法字符、按标点切句、分词、过滤停用词。拿到原始文本先做粗清洗,把多余换行和“法条原文引用块”剥掉,再进jieba分词。

import jieba import re def clean_text(raw_text: str) -> str: # 去掉判决书里的换行符、多余空格和案号 text = re.sub(r'\s+', '', raw_text) # 去掉括号内的注释性内容,通常是法条引用或法官内部说明 text = re.sub(r'[((][^))]*[))]', '', text) # 保留中文、数字和基本标点 text = re.sub(r'[^\u4e00-\u9fa5a-zA-Z0-9,。、;:""''()]', '', text) return text def tokenize(text: str) -> list: return [w for w in jieba.cut(text) if w.strip()]

这里有个细节值得留意:清理解释里第二行去括号内容,对判决书非常重要。判决书里大量出现“(2017)京0108民初12345号”“《中华人民共和国合同法》第八条”这类信息,它们本质上不是自然语言,而是标识符。如果不做这一步,词表会出现形态各异的编号碎片,分词效果也会被带偏。我试过保留这些内容的版本,模型幻觉性提升,实际F1掉1.2个点——因为同一个案由下的案号格式高度相似,模型学会了用“长得像案号”来分类,完全偏离了语义。

分词阶段,jieba默认词典对法律术语覆盖不够,比如“民间借贷”“劳动争议”这类词,默认模式会切成“民间”“借贷”,虽然不致命,但会影响后续的TF-IDF权重计算。常见做法是加载一份自定义法律词典,把高频复合词整体保留。我在模拟项目中收集了800多个常用法律术语,分词后特征规模从2.3万降到1.4万,模型维度更干净。

custom_words = [ '民间借贷', '劳动争议', '买卖合同', '交通事故', '无因管理', '不当得利', '赡养费', '抚养费', '股权转让', '名誉权' ] for w in custom_words: jieba.add_word(w, freq=200)

参数说明:freq=200表示把这个词在词频统计里的先验频率拉高,让它不会被词典里已有的组合方式拆散。实际使用时,freq设太大会导致过度合并,比如“劳动争议仲裁”被当成一个词,反而丢失了“仲裁”和“劳动”各自的信息,我一般从150到500之间试。

2.3 停用词表的坑:法律文书里哪些词真的该扔

通用中文停用词表有几百个词,直接拿来用会在法律文本上翻车。比如“原告”“被告”“本院认为”“判决如下”,这些词在法律文本里出现频率极高,但它们确实对区分案由没有贡献——民间借贷的判决书里有,交通事故的判决书里也有。

我维护了一套针对法律文书的专用停用词表,分三类:司法程序词(起诉、受理、审判长、审判员、书记员)、指代词(该、其、此、之)、以及格式性短语(综上所述、经审理查明、依照规定)。注意“综上所述”这种词要小心,因为它出现在判决书尾部,而这个位置往往是判决结果,不能整个扔掉。更稳妥的做法是只在高频统计阶段过滤,保留低频标签词。

stopwords = set() # 从通用停用词里挑一批,再补法律文书高频词 for w in ['原告', '被告', '本院', '法院', '法官', '审理', '案件', '起诉', '受理', '判决如下', '审判长', '审判员', '书记员']: stopwords.add(w) def filter_stopwords(tokens: list) -> list: return [t for t in tokens if t not in stopwords and len(t) > 1]

len(t) > 1这个条件是故意加的。单字词在分词结果里通常是噪音,对TF-IDF来说,单字词的特征权重很容易异常飙升,因为它们出现次数少、IDF极大。我统计过模拟项目里单字词的数量,大概占词表的38%,去掉之后模型训练时间缩短近一半,准确率反而提升了。

2.4 特征提取的参数设定:TF-IDF的min_df与max_df

文本预处理完之后,接下来是把词序列转换成向量。法律文书通常篇幅较长,几百到上千字的很常见,而不同类别文书在长度上差异很大,交通事故的判决书往往比较长,因为是格式化叙述;民间借贷判决书相对简短。长度差异如果不处理,模型会学到“长文本=交通事故”这种假相关。

我用TfidfVectorizer配合n-gram范围,控制特征的空间。

from sklearn.feature_extraction.text import TfidfVectorizer vectorizer = TfidfVectorizer( tokenizer=lambda x: x, # 我们已经切好词,用空格拼接传入 lowercase=False, min_df=3, max_df=0.85, ngram_range=(1, 2), sublinear_tf=True )
# 用法示例:把切好词的列表拼成空格串再向量化 corpus = [' '.join(filter_stopwords(tokenize(clean_text(t)))) for t in raw_texts] X = vectorizer.fit_transform(corpus)

下面说参数含义。

min_df=3表示一个词至少在3篇文档里出现过才会进入词表。这个参数很值得细说。法律文本里的人名、公司名、街道地址这些词,无论哪一个类别的文书里,一个人名通常只出现一次。如果min_df设为1,这些低频词会全部进入词表,不仅让矩阵更稀疏,还会在预测时造成偏差——某个当事人的名字和某次诉讼强绑定。我做过实验,在6000多篇文档的样本里,min_df从1调到3,词表大小砍掉了42%,交叉验证F1反而从0.79升到0.82,原因就是去掉了这些“一次性的身份噪声”。

max_df=0.85表示在超过85%的文档中出现过的词会被丢弃。“本院”“认为”“判决”这类词在几乎所有文档里都会出现,对分类没有区分度。注意这里的边界条件:如果max_df设得太小,比如0.5,会误伤一些类别区分的常用词,比如“合同”可能在70%的民间借贷和买卖合同文书中出现,但它对区分这两类是关键的。

ngram_range=(1, 2)表示同时考虑单个词和相邻词对。法律文本里很多关键表达是双字结构,比如“违约金”“利息”“赔偿”,分开后成了“违约”和“金”,语义信息会分散。加入bigram之后特征量会大不少,我这里跑出来的维度大约是2.8万,对朴素贝叶斯来说完全可控。

sublinear_tf=True表示用1 + log(tf)作为词频权重,降低“高频词在一个长文里出现的倍数”对权重的影响。这对长度不均衡的法律文本特别重要,因为一篇交通事故判决书可能有1200字,而一篇民间借贷可能只有400字,如果不做对数平滑,长文档里的高频词会碾压短文档特征。

2.5 数据划分与标签分布:别让一段失真的测试集吃掉你的结果

法律文本分类还有一个容易被忽视的问题:数据的时序分布。判决书的书写风格、法条引用方式会随着时间缓慢改变,同一年份的数据在内容上高度相似。如果随机打乱后划分训练集和测试集,同一个案由下的新版本文书会同时出现在两边,导致测试集失真。常见做法是按时序切分,用前80%时间的样本训练,后20%时间的样本测试,这样才是模拟真实新收案件的样子。

from sklearn.model_selection import train_test_split # 假设df里有date列和category列 train_df, test_df = train_test_split( df, test_size=0.2, stratify=df['category'], random_state=42 )

stratify=df['category']参数保证每个类别在训练和测试集里的比例和原始数据一致。这里有个常见的误区:总类别分布是合理的,不代表每个类别都有足够的样本供模型学习。在我的模拟项目里,“环境保护”类只有52个样本,随机划分时,如果某个类别在训练集里只有30个样本,那模型几乎什么都学不到。对这种低资源类别,我一般会单独拿出来做自举抽样或过采样,不过要注意朴素贝叶斯对重复样本不敏感,过采样对它的提升有限,更有效的还是增大该类别在特征层面的权重。

3. 训练与调优:用交叉验证和概率阈值把F1抬上去

3.1 训练管线标准写法:Pipeline封装与交叉验证

文本分类项目里最常见的翻车方式,是把TfidfVectorizer的fit放在交叉验证之外。如果你先对整个数据集做了向量化,再做交叉验证,那么验证集的数据已经“见过”训练集的词表和IDF统计,这会让人工标注的数据泄漏到验证阶段。正确做法是用Pipeline把向量化和分类器串在一起,让每次交叉验证都独立执行完整的预处理流程。

from sklearn.pipeline import Pipeline from sklearn.naive_bayes import MultinomialNB pipe = Pipeline([ ('vect', TfidfVectorizer( tokenizer=lambda x: x, lowercase=False, min_df=3, max_df=0.85, ngram_range=(1, 2), sublinear_tf=True )), ('clf', MultinomialNB(alpha=1.0)) ])

Pipeline的fit过程是先调用transform把每个折的训练部分单独向量化,然后再用验证部分的词表做映射。这多跑几次分词和向量化会慢一点,但对于法律文本这种千级别数据量来说,完全能接受。我一般的参数设置是cv=5、n_jobs=-1来跑,5折已经能比较稳定地估计模型在真实新案件上的表现。

3.2 拉普拉斯平滑的alpha参数:从0.01到10的玄学调优

MultinomialNB里的alpha是拉普拉斯平滑参数,它的作用是给每个词在各类别下加一个先验计数,避免某个类别没出现过某词导致概率直接变成0。alpha=1是默认值,但在法律文本这种词表很大的场景下,alpha取小一点往往效果更好。

from sklearn.model_selection import GridSearchCV param_grid = { 'vect__min_df': [3, 5], 'vect__ngram_range': [(1, 2), (1, 3)], 'clf__alpha': [0.01, 0.05, 0.1, 0.5, 1.0] } grid = GridSearchCV(pipe, param_grid, cv=5, scoring='f1_macro', n_jobs=-1) grid.fit(X_train_joined, y_train) print(grid.best_params_)

这里对齐一下:X_train_joined是分词、过滤停用词之后用空格拼接好的字符串列表。用f1_macro作为评估指标而不是accuracy,是因为类别不均衡——如果你用accuracy做评分,占28%的民间借贷类会主导选参结果,模型会偏向高精度但低召回,把其他类别的样本错误地用高频词覆盖掉。我跑出来的最优参数在alpha=0.05、ngram_range=(1,3)附近,比默认参数F1提升约2.7个点。

参数说明:alpha越小,意味着概率分布越尖锐,模型对词频的细节越敏感,在数据量足够时效果更好;alpha越大,概率分布越平滑,越不容易受到异常词频的干扰,但也会模糊类别间的差异。法律文本因为特征稀疏,alpha=1.0往往偏保守,我把0.01到0.5之间都试一遍,效果差异很明显——这里的“玄学”在于不同类别的数据量差异很大,小类别更依赖平滑来稳住概率,大类别则希望alpha小一些保留区分度。

3.3 阈值与概率校准:把0.5这个默认值扔掉

训练完朴素贝叶斯分类器,最容易被忽略的是预测概率的阈值设定。sklearn的.predict()默认用概率最大的类别,但如果你的业务场景是“分错的风险远大于分不准”,就应该调整决策规则。法律文本分类常见的做法是设定一个置信阈值,概率低于阈值就交人工处理。

from sklearn.calibration import CalibratedClassifierCV calibrated = CalibratedClassifierCV(pipe.named_steps['clf'], method='sigmoid', cv=3) calibrated.fit(X_train_vec, y_train) prob = calibrated.predict_proba(X_test_vec) # 每个样本取最大概率,低于阈值的进人工复核 y_pred = prob.argmax(axis=1) max_prob = prob.max(axis=1) manual_review_mask = max_prob < 0.7

在模拟项目里,加入概率阈值这一层之后,高置信类别的精确率从0.87提升到0.94,因为低置信样本被筛出去交人工审核了,而不是硬着头皮委派系统打标签。它把一部分人工成本前置,但换来了更高的整体可靠性。

3.4 宏观F1与微观F1:类别不平衡的读表姿势

评估法律文本分类模型,只看一个F1分数是不够的。多个类别里,一类占28%而另一类只占3%,如果你的模型把一个3%类别几乎全归错,在整体accuracy上可能只扣掉2%,但业务上等于这个类别整个失效了。所以我习惯同时输出三种指标:

macro F1:每个类别F1的算术平均,不管类别多大。适合评估小类别的表现。

weighted F1:按每个类别的样本量加权平均。适合评估整体效果。

每个类别的独立F1:最直接的生产视角,每次发布前必须逐类检查。

from sklearn.metrics import classification_report report = classification_report(y_test, y_pred, target_names=label_names, digits=3) print(report)
precision recall f1-score support 民间借贷 0.912 0.935 0.923 312 买卖合同 0.878 0.845 0.861 154 交通事故 0.902 0.916 0.909 128 劳动争议 0.744 0.552 0.634 87

实际项目中,劳动争议类和交通事故类的区分度低,是因为它们都有“工伤赔偿”交叉主题:交通事故中的侵权赔偿标准和劳动争议中的工伤待遇,在事实描述上高度相似。模型很容易把涉及赔偿的文错分到交通事故。针对这类混淆,单靠调阈值解决不了——我是在分词和停用词层面做区分,在“赔偿”相关的高频词上做人工特征工程,比如细分“误工费”“护理费”“伤残补助金”等词,并分别加入词典,让分词结果更具体。

4. 避坑指南:法律文本分类里我踩过的五个坑

4.1 重复数据没去重,交叉验证虚高得离谱

踩坑记录:训练集里出现大量同一案件的多份修改稿,比如一份判决书经过三次校对,存了三个版本,只有细微差异。我一开始直接拿了全部数据跑交叉验证,宏F1到了0.93,当时还挺高兴。结果换到真实新收案件上,掉到0.74。后来查了一下,训练集里有整整400个样本是同一案件的重复文件,而测试集是从相同来源采样的,模型在测试集上的“高准确率”是对重复记忆的评估。

原因分析:文本分类的重要原则是“同源去重”,如果同一实体在不同时期被采集多次,它们的文本几乎一模一样,如果不去重,模型会记住这些模板,而不是学到类别的一般性特征。

解决方案:对训练文本做规范化去重。我用的办法是对文本做哈希之后,再做一次相似度去重——把向量化之后余弦相似度大于0.99的样本删除,只保留一条。注意这一步必须在划分训练集和测试集之前进行,否则去重后交叉验证仍会泄漏。

4.2 默认分词词典不加载法律术语,类别区分被切成碎片

踩坑记录:第一次跑模型时,“民间借贷”被切成了“民间”“借贷”,“劳动争议”被切成了“劳动”“争议”。单独看每个词都有意义,但组合在一起,模型丢失了强判别信息。尤其“借贷”和“贷款”“借条”“欠款”这几个词混在一起,模型很容易混淆。

原因分析:jieba的词典以新闻领域为基准,法律词汇覆盖率低,对“劳动争议仲裁”“无因管理”“不当得利”这类专有组合没有整体意识。

解决方案:加载法律自定义词典,并在预处理阶段对关键类别词做统计。我维护了一张约2000词的领域词典,来源是对训练语料的top高频词人工筛选,这种方式代替了手工配置,避免了术语被拆碎的坑。从那以后,我每次跑法律文本分类前,都会用模拟项目的语料跑一遍词频统计,人工过一遍词表,把明显误切的词一个个加进词典。

4.3 案号和当事人信息被当特征,模型学会的是“记忆”

踩坑记录:模型在训练集上F1为0.91,但预测季度性新案时只有0.67。我把特征权重打印出来看了一眼,排在top的特征里竟然有某个当事人的姓名和案号里的数字碎片。

原因分析:法律文本里的身份信息和唯一标识符,在训练语料里每个只出现一次,TF-IDF给它们算了极高的权重,因为它们只在单一文本里出现。模型没有学到法律概念的区分,反而把“这段文本和某人相关”当成了判决依据。

解决方案:在预处理时统一做脱敏替换,把身份证号、公司名称、统一社会信用代码等替换为固定占位符。注意这里的“名称”替换只针对训练语料,不是发布版本需要重新构建词表。替换之后,词表里的“当事人姓名”全部指向占位符,语义信息被清理掉,TF-IDF才能把权重留给真正有法律含义的词。

4.4 只用F1调参,忽略了精确率和召回率的业务不对称

踩坑记录:我用GridSearchCV把F1调到最高,然后上线测试,结果某个小额诉讼类别出了大问题:召回率只有0.42。业务侧反馈说,大量小额借款纠纷被分到了“其他”,导致积压,人工几乎重做了全部样本。

原因分析:F1是精确率和召回率的调和平均,但它没有对“漏判”和“误判”做区分。在法律文本分类里,“漏判”的代价通常远大于“误判”——漏判意味着案件被分错,可能错过法定期限;误判则至少有人工复核兜底。我用了f1_macro做评分,默认是把每一个类别的重要性看得一样,实际上最关键的大类“民间借贷”才应该被重点关注。

解决方案:调整评分标准,使用自定义的F-beta分数,设beta=1.5以上,把召回率权重拉高。在GridSearchCV里用make_scorer传入,或者直接对不同类别设置不同的分类概率阈值。我在实际操作中会给高业务价值的类别调低判定阈值,比如从0.5调到0.4,代价是一部分精确率,但整体漏判风险大幅下降。

4.5 模型上线后发现新收文书格式变了,词表漂移把分类器打懵

踩坑记录:模型上线三个月后,某个新收案件的程序化裁判文书开始使用新版模板,大量新词汇进入文本。原本0.86的F1掉到0.62。原因是新模板引入了“要素式审判”“争议焦点归纳”等新词,这些词在训练词表里不存在。

原因分析:法律文书的格式虽然相对固定,但会随政策和实务惯例调整。任何一个部署了超过一个季度的分类模型,都会遇到词汇分布漂移问题。

解决方案:要有持续监控和更新机制。我一般每周跑一次新收样本的词频统计,计算和新词表的Jaccard相似度,相似度低于0.85就重新训练模型。这不是一次性完成的工作,而是模型上线的配套动作。

5. 让模型上线以后不翻车:增量训练与特征检查的两个习惯

模型上线只是开始,真正考验人的是新数据来了之后怎么保持稳定。先说我现在的习惯做法:每周定时用新标注数据跑一次增量训练,而不是等积累几个月再全量重训。

from sklearn.naive_bayes import MultinomialNB # 假设特征已经向量化为稀疏矩阵 clf = MultinomialNB(alpha=0.05) clf.partial_fit(X_new_vec, y_new, classes=np.array([0, 1, 2, 3, 4, 5]))

partial_fit是朴素贝叶斯的一个优势,它可以在不重载全部历史数据的情况下,用新样本更新模型。增量训练时要注意一件事:向量化器(TfidfVectorizer)的词表在线更新比较麻烦,所以我一般固定词表,新出现的词如果在词表里,就正常计入,如果不在,就暂时忽略。等词汇相似度掉到警告线以下,再触发全量重训。

第二个习惯是定期做特征检查。我每个月会把模型学到的top特征打印出来,肉眼过一遍,看有没有学歪掉的检测词。做法是拿训练好的多项式朴素贝叶斯的coef_或特征对数概率差,直接输出每个类别下权重最大的几十个词。

import numpy as np feature_names = vectorizer.get_feature_names_out() for class_idx in range(clf.classes_.shape[0]): top_indices = np.argsort(clf.feature_log_prob_[class_idx])[-30:] top_terms = [feature_names[i] for i in top_indices] print(f"类别 {class_idx} top特征: {top_terms}")

这个习惯帮我发现过一个问题:某个类别的top特征里有“某某公司”这种名字,说明脱敏替换环节失效了,某个公司名在文书里没有被替换干净,而它恰好只在某一类文书中高频出现。核对业务逻辑后,加了一条专门的替换规则。那次如果没有做特征巡检,这个噪音会被当作正常特征,等到模型精度掉下去才排查,代价就大了。

从那以后,我每次做法律文本分类项目,都会强制走一遍固定流程:数据清洗时先做身份信息脱敏和同源去重,分词前加载领域词典,向量化参数固定地跑一份min_df和max_df网格,模型训练完必打印分类报告逐类检查,部署后每周记录新数据的词表漂移指标、每月做top特征巡检。这一套下来,模型即使出现性能波动,原因也能在半小时内定位到是数据、词表、还是阈值变化引起的。

这份模拟项目的资源,把我上面所有的处理流程和代码逻辑都完整整理进去了。希望帮到你,少走一点我走过的弯路。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询