简介:针对垃圾邮件识别场景,一份基于朴素贝叶斯的Python实现方案,适合Python开发者、NLP初学者以及信息安全相关从业者,用于理解概率分类模型与邮件过滤流程。资源以项目形式呈现,共44个文件,涵盖17个Python源码文件(含预处理、模型训练、预测及主程序)、14个pyc编译文件,以及前端展示所需的html/css/js、项目配置文件xml和SQLite数据库等,压缩包整体仅283KB,结构紧凑便于直接阅读与二次修改。目前已有256人学习下载。内容上,项目完整实现了邮件分词、停用词过滤、词干提取等预处理环节,并基于scikit-learn的MultinomialNB构建分类器,同时提供通过用户反馈持续更新模型的思路,对掌握朴素贝叶斯核心原理、动手搭建简易过滤系统都有直观参考价值。项目将后端逻辑与前端页面分离,可从数据清洗、特征提取到模型判别看清单条完整链路;读者也能替换数据集或调整特征规则,快速适配教学演示与小规模邮件分类实验。
1. 垃圾识别像评分:一封普通的促销邮件为什么被判成垃圾
一封标题和正文都很正常的会员日促销邮件,被模型判成了 99.7% 的垃圾;另一封满屏乱码的营销邮件,反而被安静地放进了收件箱。这种与直觉相反的结果,几乎是每个第一次做垃圾邮件过滤的人都会撞上的现象。朴素贝叶斯不按“有没有坏词”做判断,而是把整封邮件拆成词语证据,逐一比较“它属于垃圾邮件”和“它属于正常邮件”两类后验概率的比值,再由所有词语的投票共同决定最终落点。
这套方案放在今天的 Python 里只需要分词、词频统计、概率比较三个环节。训练成本是一次遍历,推理是查表求和,资源占用低、可解释性强,既适合邮箱服务场景做初筛,也适合刚接触自然语言处理的开发者当成第一个能完整跑通的文本分类项目。后面几章会直接从打分原理、数据准备、完整代码、高频翻车点到增量更新,一步步把该踩的坑提前标出来。
2. 朴素贝叶斯给邮件打分:概率公式与三个工程化简
在付诸代码之前,先把打分公式讲清楚。下面这几个式子不只是推导,它们直接决定你后面代码里该用加法还是乘法、该查表还是现算、该不该做平滑。搞懂这一章,后面写代码的过程基本就是填空。
2.1 贝叶斯公式在邮件场景怎么写:先验概率与条件概率
训练完成后,我们用贝叶斯公式计算一封新邮件属于垃圾类别的概率:P(spam | 邮件) = P(spam) × P(w1 | spam) × P(w2 | spam) × ... ÷ P(邮件)。其中P(spam)是训练集中垃圾邮件的占比,叫先验概率;P(wi | spam)是从垃圾邮件正文里统计出的词语条件概率;分母P(邮件)对哪一类都一样,实际工程中比较时直接忽略。
一个重要细节是这些条件概率怎么连乘起来。朴素贝叶斯在这里做了条件独立假设,认为 w1、w2 到 wn 在给定邮件类别后彼此独立,于是联合概率P(w1,w2,...,wn | spam)可以被拆成每个词的条件概率的乘积。严格讲这个假设在真实文本中几乎不成立——“发票”“代开”“加微信”这几个词常常同时出现,语义上高度相关。不过正是这种“在错误假设下依然有效”的表现,让它在文本分类里被沿用了这么久:独立性假设省去了特征相关性计算,把高维稀疏文本的处理成本压到了极低,代价换来了速度和稳定。
用数据说更直观。假设训练集有 10 万封正常邮件、1 万封垃圾邮件,先验P(spam)约等于 0.09。计算某个词 wi 的条件概率时,统计这个词在垃圾邮件中出现的次数,除以垃圾邮件总词数,再套一个拉普拉斯平滑。这里关键不是手算,而是理解分子分母的差别:分子看单个词频,分母看整类词数。所以标注质量和分词质量会直接传导到最终分数上,这也是后面第 5 章很多坑的根源。
2.2 日志赔率模型:把乘除变成加减,顺便避开下溢
现在到了工程上第一个必改动作。把一类中所有词的条件概率连乘,结果会非常小,当邮件里有几百上千个词时,浮点数直接变成 0,也就是计算机里的下溢。解决方式不是换算法,而是取对数。log(ab) = log(a) + log(b),原来的一串乘法变成一串加法,数值范围被压缩到对数量级,不会下溢。
为了比较两类,再把公式整理成对数赔率(log-odds)形式:score = log(P(spam)/P(ham)) + Σ [log(P(wi|spam)) - log(P(wi|ham))]。score 越大,越倾向垃圾;score 为负,则倾向正常。训练阶段把每个词的 log 条件概率提前算好存进字典,推理时只需要查表和累加,这就是朴素贝叶斯推理极快的根本原因。
我经常把这种形式解释成投票模型:每个词是一张选票,log(P(wi|spam)) - log(P(wi|ham))是这张选票的权重,正数投垃圾、负数投正常;最前面那一项log(P(spam)/P(ham))代表训练集里垃圾本身的占比。如果训练样本里垃圾邮件占了 70%,模型天生就会偏向垃圾,这是后面讲样本不平衡时要回头看的地方。
log 域还有一个隐藏好处:阈值可以外挂。预测阶段不必机械地把 0 当作分界线,可以取一个阈值 t,score > t才算垃圾。阈值从 0 移到 0.9 或者更低,相当于在误杀和漏判之间平移决策位置。很多工程上的“调模型”,本质都是在这根轴上找位置。
2.3 与 SVM、深度学习相比,为什么文本分类还常选朴素贝叶斯
那为什么不干脆用 SVM、TextCNN 或者 Transformer 呢?这是被问得最多的问题之一。对比一下工程成本就明白了。SVM 需要做特征标准化、调 C 和核函数,在文本高维稀疏特征上的训练速度并不快;深度学习模型能捕捉同义改写和上下文,效果上限最高,但它意味着更大的标注集、GPU 训练环境和更长的推理耗时。朴素贝叶斯的整个参数就是两个词频字典和一个先验值,训练时一次遍历搞定,推理时查表累加,无框架依赖,很轻。
| 方案 | 训练成本 | 推理成本 | 需要数据量 | 可解释性 |
|---|---|---|---|---|
| 朴素贝叶斯 | 一次遍历 | 查表求和 | 几千条可用 | 每个词的贡献可直接打印 |
| SVM | 依赖特征工程与调参 | 中等 | 需要验证集调参 | 一般 |
| 深度学习 | 高 | 高 | 大 | 差 |
这类选择也有明显的天花板。垃圾邮件是典型的对抗场景,发件人会故意把“免费”写成“f r e e”“免-费”“免 费”来绕过分词。词袋模型分词之后这些词会变成若干没意义片段,贝叶斯看不见原词,模型必然失效。遇到这种对抗强度高的流量时,我一般会先给贝叶斯加字符 n-gram 特征救火,仍然扛不住再考虑语义模型。它不是银弹,但在大部分预算有限的邮件过滤任务里,性价比优势很突出。
3. 把邮件变成向量:数据准备、分词与特征表达
原理清楚后,真正决定模型上限的是数据这一层。很多人的第一个朴素贝叶斯过滤器效果差,不是公式写错,而是数据没有整理好。这一章讲清楚从原始邮件到特征矩阵之间的每一步,以及每一步里容易踩的坑。
3.1 数据集约定:两列 CSV 的格式与标注注意点
先约法三章:数据格式用 CSV 两列就够了,一列是 text,一列是 label。text 存邮件或短信的正文,label 统一成 0 和 1,0 代表正常邮件,1 代表垃圾邮件。真实项目中 label 经常来自不同标注来源,有人写 spam,有人写垃圾,有人写 1,读取后如果不统一,模型会把不同写法当成不同类别,训练时直接报多类别错误或学出一个奇怪分布。我会在读入阶段处理一次,所有后续逻辑都只认 0/1:
import pandas as pd df = pd.read_csv("mail_dataset.csv", encoding="utf-8") df["label"] = df["label"].map( lambda x: 1 if str(x).strip().lower() in ("1", "spam", "垃圾") else 0 ) print(df["label"].value_counts())说明:映射表可以根据自己的标注习惯扩展,但处理前后各打印一次类别分布,确认 0 和 1 的数量没有反转。另一个高频问题在数据去重。营销活动邮件往往被重复采集,同一封邮件在数据里出现几十次,训练时它会反复强化同一批词,导致过滤器的预测被少数模板绑架。常见的做法是对 text 取哈希去重,保留第一篇。这一步在标注之前做比在标注之后做省事得多。
数据量方面,几千条能起步,效果要稳定通常需要上万条混合语料,但重点在于正负两类的覆盖度。宁要 5000 条覆盖不同活动主题的样本,不要 5 万条同一促销模板的复制品。
3.2 文本清洗:URL、邮件头、标点、数字、停用词怎么处理
清洗顺序有讲究。链接和数字用占位符而不是删除,是因为垃圾短信里大量使用短链和电话号码,保留“这里有一个 URL”这一事实,比知道具体网址更有判别价值;数字同理,发票号、验证码都是强信号,直接把原文数字作为特征则词表爆炸,每个具体号码都会变成单独一维特征。
import re def normalize_text(raw): text = raw.lower() # 全角转半角,避免中文标点被当成另一个字符 text = "".join( chr(ord(c) - 0xfee0) if 0xff01 <= ord(c) <= 0xff5e else c for c in text ) text = re.sub(r"<[^>]+>", " ", text) # 去HTML标签 text = re.sub(r"https?://\S+|www\.\S+", " URL ", text) # 链接占位 text = re.sub(r"\d+", " NUM ", text) # 数字占位 text = re.sub(r"[^\w\u4e00-\u9fa5]+", " ", text) # 其余符号转空格 return text说明:全角转半角这步常被忽略,它解决的是“,。”和“,.”被当成不同字符的问题。HTML 标签在网页抓取的邮件正文里很常见,不删干净会把 tag、属性名当成有效特征。占位符统一写成大写字符串,是为了让它们在词表里稳定存在,不会被分词器切成碎片。
停用词处理放在分词之后。网络上的通用中文停用词表可以直接用,但有两个词要特别小心:“退订”和“订阅”。订阅类正常邮件正文里频繁出现“回复 TD 退订”,垃圾邮件也常拿来伪装,如果按通用习惯把它们塞进停用词,等于删掉了最关键的判别证据。我一般会准备两份停用词表,通用表用于清理“的、了、吗、呢”,业务表单独维护,里面绝不轻易放带业务属性的词。
3.3 分词与向量化:jieba 切词、词袋与 TF-IDF 的取舍
中文没有空格,必须分词。常用的选择是 jieba。分词这一步的关键不是追求新词识别率,而是追求稳定性。同一条业务词,如果在训练时被切成 A、B,预测时被切成 A 和 B,特征就不一致,模型学到的权重也会被稀释。
import jieba jieba.load_userdict("business_terms.txt") # 公司名、产品名、业务术语,每行一个词 tokens = jieba.lcut(text, cut_all=False, HMM=False)参数说明:load_userdict加载自己维护的业务词典,每行一个词;cut_all=False表示用精确模式而不是全模式,避免把“北京大学”切成一堆组合;HMM我建议关掉,它会根据概率猜测未登录词,会让同一句话在不同上下文里被切成不同样子。对过滤这种追求稳定的场景,把词典喂全、HMM 关掉,比追求分词新词率更实际。
向量化阶段有三条路。第一种是CountVectorizer统计词出现次数;第二种是在它基础上开binary=True,只记录某个词是否出现过;第三种是 TF-IDF 加权。朴素贝叶斯和 TF-IDF 搭在一起效果并不总是更好,因为 TF-IDF 的全局逆文档频率权重改变了数值语义,再叠加条件独立假设,结果需要实测。工程上我的默认起点是binary=True,让每个词只投一票,避免一封邮件里同一个词重复出现把分数拉爆。
| 向量化方式 | 特征含义 | 与NB组合的表现 | 适用场景 |
|---|---|---|---|
| CountVectorizer(count) | 词频 | 长文档容易带偏 | 数据量大且已截断 |
| CountVectorizer(binary=True) | 是否出现 | 高频词不放大,较稳 | 小数据默认首选 |
| TfidfVectorizer | 全局加权 | 可压常见无义词但叠加独立性假设 | 需实测对比 |
参数建议:max_features取 20000 到 30000 之间,min_df设 2 或 3,把只在一个文档里出现一次的噪声词过滤掉。n-gram 从一元开始,业务里如果发现“不是垃圾”这类否定表达被拆碎,再上(1,2),词表会明显膨胀,但配合max_features限制问题不大。
3.4 训练测试划分:按时间而不是随机分
文本分类很容易在划分这一步做出虚高成绩。邮件天然带时间属性,垃圾邮件活动有潮汐性,同一次营销活动会在一周内集中出现。随机打乱后训练集和测试集会混入同一批活动的邮件,模型相当于见过未来,评估准确性自然好看,上线后遇到下一波新活动效果就缩水。这是离线指标和线上指标对不上的最常见原因之一。
做法很简单,有 time 列就先按时间排序,取前 80% 训练、后 20% 测试;没有时间戳就按发件人或主题做分组划分。至少也要随机分层划分并固定随机种子,保证实验可复现:
from sklearn.model_selection import train_test_split train_text, test_text, train_label, test_label = train_test_split( df["text"], df["label"], test_size=0.2, stratify=df["label"], # 分层,保证两类占比与整体一致 random_state=42 # 固定种子,实验结果可复现 )说明:stratify按 label 分层,尤其垃圾邮件占比很低时,不做这一步测试集可能只剩几条垃圾;random_state固定下来,后面才能在特征之间公平比较。如果你的数据量少,可以再配合交叉验证,但注意分组,别让同一模板泄漏到多个折里。
4. 从零训练并评估过滤器:可复现的 Python 实现
原理和数据都备齐,下面直接给两套实现。第一套不依赖 scikit-learn,把贝叶斯底层全部摊开;第二套用 scikit-learn,适合快速迭代实验。两套代码的输入输出约定一致,可以互相替换。
4.1 手写一个极简朴素贝叶斯分类器:再用几分钟理清楚数
import math import jieba from collections import defaultdict class NaiveBayesSpamFilter: def __init__(self, alpha=1.0): self.alpha = alpha self.vocab = set() # 词表,拉普拉斯平滑的维度依据 self.spam_freq = defaultdict(int) # 词在垃圾邮件中的出现次数 self.ham_freq = defaultdict(int) # 词在正常邮件中的出现次数 self.spam_total = 0 # 垃圾邮件总词数 self.ham_total = 0 # 正常邮件总词数 self.n_spam = 0 # 垃圾邮件数量 self.n_ham = 0 # 正常邮件数量 self.word_log_odds = {} def _tokenize(self, text): # 输入text建议已经过normalize_text清洗 return jieba.lcut(text) def fit(self, texts, labels): """训练:一次遍历统计词频,labels用0/1表示正常/垃圾""" for text, label in zip(texts, labels): tokens = self._tokenize(text) for w in tokens: self.vocab.add(w) if label == 1: self.spam_freq[w] += 1 self.spam_total += 1 else: self.ham_freq[w] += 1 self.ham_total += 1 if label == 1: self.n_spam += 1 else: self.n_ham += 1 self._build_word_table() def _build_word_table(self): """根据计数表重建所有词的对数赔率,预测前必须调用""" v = len(self.vocab) # 先验也用拉普拉斯平滑,避免某类样本为0时先验变成0 prior_spam = (self.n_spam + 1.0) / (self.n_spam + self.n_ham + 2.0) self.prior_log_odds = math.log(prior_spam) - math.log(1.0 - prior_spam) for w in self.vocab: p_spam = (self.spam_freq[w] + self.alpha) / (self.spam_total + self.alpha * v) p_ham = (self.ham_freq[w] + self.alpha) / (self.ham_total + self.alpha * v) self.word_log_odds[w] = math.log(p_spam / p_ham) def predict_score(self, text): """返回对数赔率分数,大于threshold判垃圾""" score = self.prior_log_odds for w in self._tokenize(text): if w in self.word_log_odds: # 未登录词跳过,不贡献分数 score += self.word_log_odds[w] return score def predict(self, text, threshold=0.0): return 1 if self.predict_score(text) > threshold else 0代码逻辑不复杂,但有两个地方容易写错。第一个是拉普拉斯平滑的分母:self.spam_total + self.alpha * v,其中 v 是词表大小。遗忘者通常分子加了 alpha 而分母不加 v,导致所有词的概率之和不等于 1,预测分数整体漂移。第二个是未登录词处理:if w in self.word_log_odds这一行不能省,否则新来的词会直接命中零概率,把整封邮件的分数压成负无穷。
alpha默认 1.0,相当于给每个没见过的词一个最小出现次数的缓冲。调大 alpha 会让所有词的概率趋向均匀,抑制小样本上的过拟合;调小则让词频差异更敏感。文本分类上 alpha 在 0.1 到 3 之间都算常见,建议先固定 1.0,等后面调阈值和特征时再回头动它。
4.2 用 Scikit-learn 快速实现:Pipeline 组合向量化与 MultinomialNB
如果不想自己维护计数表,scikit-learn 提供了现成的MultinomialNB,配合CountVectorizer一条流水线就能训练和预测。下面的代码把 3.3 节的参数选择直接落地:
from sklearn.pipeline import Pipeline from sklearn.feature_extraction.text import CountVectorizer from sklearn.naive_bayes import MultinomialNB def tokenize(text): # 放在模块级别而不是lambda,便于后续用joblib打包模型 return jieba.lcut(text) model = Pipeline([ ("vect", CountVectorizer( tokenizer=tokenize, binary=True, # 只记录词是否出现 max_features=30000, # 词表上限,控制维度 min_df=2, # 至少出现在2篇文档才保留 ngram_range=(1, 1) # 先跑一元词,后续按需开二元 )), ("nb", MultinomialNB(alpha=1.0, fit_prior=True)) ]) model.fit(train_text, train_label) pred = model.predict(test_text)CountVectorizer负责分词、构建词表、输出稀疏矩阵,MultinomialNB基于词频矩阵做多项分布估计。注意MultinomialNB没有class_weight参数,这是和 SVM、逻辑回归差别很大的地方。
如果训练集里垃圾比例很高,应该用class_prior参数给先验做个上限,或者直接在阈值上做纠正,不要靠重采样把假比例做出来。fit_prior=True表示先验从数据统计,如果你明确知道线上垃圾占比低于训练集,可以改成手动传入class_prior=[正常占比, 垃圾占比]。
4.3 评估指标别只看准确率:精确率、召回率、混淆矩阵怎么读
模型训练完,第一件事不是看准确率,而是打印分类报告和混淆矩阵。垃圾邮件过滤场景里,准确率很容易骗人:当正常邮件占大多数时,把所有邮件都判正常就能拿到很高的准确率,但这明显没有过滤价值。
from sklearn.metrics import classification_report, confusion_matrix pred = model.predict(test_text) print(classification_report(test_label, pred, target_names=["正常", "垃圾"])) print(confusion_matrix(test_label, pred))classification_report 里四行指标怎么读?精确率表示“被判成垃圾的邮件里,真正是垃圾的比例”,低说明误杀多;召回率表示“真正的垃圾邮件里,被成功拦下的比例”,低说明漏判多。两者天然冲突,拨动它们的旋钮就是阈值。实际业务里,误杀一封正常邮件比放漏一封垃圾邮件的代价大得多,所以我会更看重精确率,同时保证召回率不低于一个业务底线。
一个额外的小习惯:把预测分数分布打出来看一眼。手写版拿predict_score,sklearn 版拿predict_proba的第二列,统计 0.5 以上的占比。如果所有样本分数都挤在 0.5 附近,说明特征或分词还有问题;如果分数两极化,说明阈值调整空间很充裕。
5. 避坑与排查:垃圾邮件过滤现场的高频翻车点
贝叶斯公式本身简单,但上了真业务之后翻车点非常集中。下面这 5 个是我见过或踩过的现场,每条都按“现象 → 原因 → 解决”展开,几乎可以直接当排查手册用。
5.1 新词一票否决:未登录词的零概率问题
现象:一封正常邮件里出现训练集没见过的品牌词,被直接判成垃圾;或者反过来,垃圾邮件里刚出现的新网络热词,模型在相当长一段时间内对它完全无感。
原因:没有加平滑或平滑参数太小;预测时把未登录词也拿去乘,零概率会瞬间把整封邮件的分数压死。用 log 域计算后,log(0)直接是负无穷。
解决:拉普拉斯平滑的 alpha 至少给到 1.0;预测循环里跳过词表中不存在的词;训练时把min_df设置大于 1,把只出现过一次、特征不稳定的词剔掉。代码层面就是 4.1 节里predict_score中的if w in self.word_log_odds这一行,别企图用一个很大的人工词表一劳永逸,词表是长出来的,不是一次性建出来的。
5.2 模型把所有邮件都判成垃圾:样本不平衡与阈值漂移
现象:离线准确率 95%,上线后第一周垃圾箱里混进大量正常邮件。打开训练数据一看,垃圾样本被凑到了 70% 以上。
原因:先验概率被训练集的类别比例扭曲。回顾 2.2 节的 log-odds 公式,最前面那一项log(P(spam)/P(ham))会因为 70% 的垃圾占比变成明显正数,把许多正常邮件的得分推过阈值。
解决:让训练集的垃圾占比接近线上真实水平,一般控制在 30% 到 50% 比较稳妥。如果历史数据本身就不均衡,用class_prior或阈值纠正,不要靠重采样把假比例做出来。分层划分只能保证测试集和训练集比例一致,掩盖不了训练集本身的失真。
5.3 中文分词失败让特征质量崩盘:停用词与词典边界
现象:同一封邮件里的“代开发票”,有时被切成“代开发|票”,有时被切成“代|开发|票”。同一个语义在训练和预测时特征形态不一致,模型分数飘忽。
原因:jieba 默认词典不覆盖业务词,HMM 识别在上下文变化时拍板不一致;不同来源的文本在清洗阶段对同一形态处理也不一致。分词不稳定,后面向量化的词表就跟着不稳定。
解决:把高频业务词写进自定义词典;追求稳定时关掉 HMM;如果对抗流量里大量使用变体字,再叠加字符 bi-gram 特征兜底,即使分词不稳定,相邻双字特征也能把核心词形拼回来。停用词表按场景裁剪,前面 3.2 节提到的“退订”就是最典型的例子。
5.4 长邮件截断策略:长度会绑架分数
现象:一封附带说明书的正常邮件被判垃圾,一句话的垃圾短信反而被放行。
原因:词频统计下,长文档把正常词项的数量放大了,垃圾词在整篇文档里的相对占比被稀释;反过来,一句话短文本里如果有一个强垃圾词,分数会被瞬间拉高。分类器在词频尺度上被文档长度绑架了。
解决:训练和预测时统一做截断,只取正文前 200 到 300 个 token。垃圾邮件的营销话术通常集中在前几句,正常邮件的签名、版权声明落在尾部,截头比截尾稳。如果还想进一步压长度影响,可以接TfidfTransformer做归一化,但它不一定在每份数据上都优于纯词袋,需要实测对比。
5.5 阈值不是默认的 0.5:调阈值比调模型快
现象:模型训练完直接调用predict(),业务上要么误杀率超标,要么漏判太多。很多人的第一反应是换模型、加特征,折腾一圈回来发现没本质变化。
原因:0.5 是训练集分布下的平分线,不是业务目标的最优点。贝叶斯输出的概率只是一个“倾向垃圾”的连续量,0.5 本身没有任何业务含义。把 0.5 当成固定分界线,属于一种相当顽固的思维惯性。
解决:把阈值当超参扫一遍,在验证集上用predict_proba做扫描,而不是改模型重训:
import numpy as np from sklearn.metrics import precision_score, recall_score, f1_score proba = model.predict_proba(test_text)[:, 1] for t in np.arange(0.3, 0.96, 0.05): pred = (proba >= t).astype(int) print(f"threshold={t:.2f} " f"precision={precision_score(test_label, pred):.3f} " f"recall={recall_score(test_label, pred):.3f} " f"f1={f1_score(test_label, pred):.3f}")说明:如果发现某些垃圾邮件分数总在 0.4 到 0.6 之间,先别急着调阈值,去翻这些样本分词后的词表;如果特征全是低频词,大概率是训练样本对这种模式覆盖太少,补样本比调阈值有效。阈值是最后一道旋钮,不是止痛药。
6. 上线之后:阈值扫描、增量更新与灰度回放
模型能跑通只是开始,过滤器真正要命的是数据回流。垃圾邮件是不断演化的,上个月的中奖话术这个月就换成了补贴话术。朴素贝叶斯结构简单,它的参数本质就是一堆计数表,所以它天然适合做增量更新。收到用户标记“这不是垃圾”时,不需要全量重训,只需要把这一条样本的分词结果累加进对应计数,然后重建对数概率表:
def update_filter(filter_model, new_text, new_label): """在线更新一条样本,new_label为1表示垃圾,0表示正常""" tokens = jieba.lcut(new_text) filter_model.vocab |= set(tokens) if new_label == 1: filter_model.n_spam += 1 filter_model.spam_total += len(tokens) for w in tokens: filter_model.spam_freq[w] += 1 else: filter_model.n_ham += 1 filter_model.ham_total += len(tokens) for w in tokens: filter_model.ham_freq[w] += 1 filter_model._build_word_table()这段代码的时间成本只花在分词和重建词表上,几十毫秒级别,意味着线上可以每天定时把用户申诉样本回填一次,保持过滤器对近期话术的敏感度。这是朴素贝叶斯相比深度学习方案最实用的优势:模型参数就是计数,更新即重训。
6.1 上线验证:只标记不隔离的灰度回放
新模型上线前,我的习惯是先用“只标记不隔离”的方式跑灰度。新模型照常给每封邮件打分,但分数只写入日志,不真正把邮件移进垃圾箱。跑满一个完整活动周期,比如两周,统计误杀率:正常邮件被判成垃圾的比例。误杀率低于业务阈值,再逐步放开隔离动作。这个流程能避免很多模型一上线就把正常邮件吞掉的翻车事故。
阈值在灰度阶段也要跟着调。扫描验证集后挑出的那个阈值,要存成配置文件而不是写死在代码里。线上运行期间,用一个定时任务统计每天的分数分布,如果整体分数漂移超过预期,就触发人工检查。分数漂移往往比准确率下降更早暴露问题,因为它不需要等到用户投诉就能观察到。
6.2 一条关于克制的小建议
我现在做这类过滤器的流程是:先保守阈值上线,只标记不隔离跑两周,把这两周被标记但没有申诉的样本回填再重训,然后逐步放松阈值。这个流程在模拟项目 X 上线时帮我避开了两次因分布变化导致的误杀,算是教训换来的习惯。朴素贝叶斯门槛低,但数据回流、阈值策略和灰度验证这三件事缺一不可,希望帮到你。
本文还有配套的精品资源,点击获取