news 2026/7/4 3:38:21

垃圾短信识别项目深度复盘:中文文本分类全流程实战 + 3 个数据泄漏避坑指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
垃圾短信识别项目深度复盘:中文文本分类全流程实战 + 3 个数据泄漏避坑指南

一、问题引入:为什么垃圾短信越拦越多?

移动通信早已普及,但垃圾短信诈骗短信仍然泛滥。它们不仅打扰日常体验,有些还会直接带来财产风险。

传统的拦截方案依赖硬编码关键词规则——"中奖""领取""点击链接"写进黑名单,命中率看着不错,但维护成本极高:黑产改一个字、加一个表情、换一种谐音写法,规则就得跟着重写。这条路走到底,是永远在和黑产"打地鼠"。

本项目想做的,是让短信风控从"硬编码规则"走向"数据驱动识别":不靠固定关键词,而是让模型自己学习短信文本里的语义特征,自动判断一条短信是正常消息,还是营销、诈骗一类的垃圾短信。

核心目标四个:

  • 对中文短信做清洗、分词、预处理

  • 把文本转换成可训练的数字特征

  • 用模型自动完成二分类(正常 / 垃圾);

  • 支持批量测试,输出准确率、精确率等指标。

一句话总结:做一个端侧轻量化的短信过滤系统。


二、环境与版本说明

组件版本说明
Python3.9 +建议用 3.9 及以上,f-string、类型注解都更顺手
PyTorch2.0 +本项目只用基础nn.RNN,低版本也能跑
jieba0.42 +中文分词首选,工业界用得最多
scikit-learn1.2 +只用train_test_split做数据切分

安装一行搞定:

pip install torch jieba scikit-learn

⚠️ 如果你是 GPU 环境,记得按 PyTorch 官网 选对应的 CUDA 版本,CPU 版也能跑通本项目,只是训练慢一些。


三、核心原理浅析:RNN 为什么能"读懂"中文短信?

在写代码之前,先花一分钟搞清楚为什么用 RNN,而不是直接套个全连接网络。

中文短信有三个特点,决定了模型选型:

  1. 是序列,不是孤立的词——"验证码是 3812,请勿泄露"和"请勿泄露,验证码是 3812",词一样、顺序不同,但语义一致,模型得能捕捉上下文顺序

  2. 长度不固定——有 10 个字的验证码,也有 70 个字的营销长文,网络必须能处理变长输入

  3. 关键词决定性强但分布不均——"领取奖品""点击链接"这类词是强信号,但它们出现在文本的哪个位置不确定。

RNN(循环神经网络)正好满足这三点:它逐时刻读入词向量,用隐状态(hidden state)把之前读过的信息"记"下来,传到下一个时刻。读完整句话后,最后一个隐状态就相当于整句话的语义摘要,再接一个全连接层就能做分类。

完整链路一句话概括:

原始短信 → 清洗分词 → 词转ID → Embedding稠密化 → RNN提取时序语义 → 全连接 → 二分类输出

理解了这条链路,下面的代码每一行你都能对上号。


四、实战步骤(全流程代码 + 注释)

步骤 1:数据预处理与词表构建

神经网络只认数字,不认汉字。第一步必须把文本 → 数字 ID,而桥梁就是词表(Vocabulary)

import jieba from collections import Counter ​ def build_vocab(texts, min_freq=1): """从文本集合构建词表,返回 word→id 的映射字典。""" counter = Counter() for text in texts: # jieba 分词:中文必须先切词,否则模型学到的"词"其实是单字,语义信息会被打散 words = jieba.lcut(text) counter.update(words) ​ # 过滤低频词:低频词在训练样本里出现次数太少,模型学不到稳定表示,反而增加词表噪声 filtered = {w: f for w, f in counter.items() if f >= min_freq} ​ # 预留特殊 token:PAD 用于补齐长度,UNK 用于兜底未见词 # 没有它们,后续 DataLoader 拼批量时会因长度不一报错、推理时遇到新词会 KeyError vocab = {"<PAD>": 0, "<UNK>": 1} vocab.update({w: i + 2 for i, w in enumerate(filtered)}) return vocab ​ ​ # ---- 演示 ---- sample_texts = ["您的验证码是3812", "点击链接领取奖品", "明天十点开会"] vocab = build_vocab(sample_texts) print("词表大小:", len(vocab)) print("词表示例:", dict(list(vocab.items())[:8]))

预期控制台输出:

词表大小: 14 词表示例: {'<PAD>': 0, '<UNK>': 1, '您': 2, '的': 3, '验证码': 4, '是': 5, '3812': 6, '点击': 7}

步骤 2:Dataset 封装——让数据稳定喂给模型

光有词表还不够,PyTorch 训练要求数据以等长张量的形式按批送入。我们自定义一个Dataset,把"截断/补零/转张量"这三件事统一收口。

import torch from torch.utils.data import Dataset ​ class SmsDataset(Dataset): def __init__(self, texts, labels, vocab, max_len=32): self.texts = texts self.labels = labels self.vocab = vocab self.max_len = max_len ​ def __len__(self): return len(self.texts) ​ def __getitem__(self, idx): words = jieba.lcut(self.texts[idx]) # 词转 ID:训练阶段没见过的词统一映射成 UNK,避免推理时 KeyError 导致整条链路崩溃 ids = [self.vocab.get(w, self.vocab["<UNK>"]) for w in words] ​ # 截断或补零:RNN 要求同 batch 内序列等长,不补齐无法拼成 tensor if len(ids) >= self.max_len: ids = ids[: self.max_len] else: ids = ids + [self.vocab["<PAD>"]] * (self.max_len - len(ids)) ​ return torch.tensor(ids, dtype=torch.long), torch.tensor(self.labels[idx], dtype=torch.long)

这一步解决的不是"模型聪不聪明",而是"数据能不能稳定喂给模型"。很多新手卡在DataLoader报维度错误,根因就是这里没统一长度。

步骤 3:构建 RNN 二分类模型

模型结构很轻量:Embedding → RNN → 全连接。轻是轻,但每一层都有它必须存在的理由。

import torch.nn as nn ​ class SpamRNNClassifier(nn.Module): def __init__(self, vocab_size, embedding_dim=64, hidden_dim=128, num_classes=2): super().__init__() # Embedding 层:把稀疏的 one-hot ID 压缩成稠密向量,让语义相近的词在向量空间里也相近 # padding_idx=0:让 PAD 位置的向量始终为 0、不参与梯度更新,避免补零位干扰语义学习 self.embedding = nn.Embedding(vocab_size, embedding_dim, padding_idx=0) ​ # RNN 逐时刻读入词向量,用 hidden state 记忆上下文,最后时刻的 hidden 即整句语义摘要 self.rnn = nn.RNN(embedding_dim, hidden_dim, batch_first=True) ​ # 全连接把语义摘要映射成 2 维 logits,分别对应"正常/垃圾" self.fc = nn.Linear(hidden_dim, num_classes) ​ def forward(self, x): embedded = self.embedding(x) # [batch, seq_len, embedding_dim] output, hidden = self.rnn(embedded) # hidden: [1, batch, hidden_dim] # squeeze 掉第 0 维(num_layers),只保留 batch 和特征维送入全连接 logits = self.fc(hidden.squeeze(0)) # [batch, num_classes] return logits

步骤 4:训练、评估与保存

把前面的模块串成完整训练链路。

import torch.optim as optim from torch.utils.data import DataLoader ​ def train_model(dataset, vocab_size, epochs=10, batch_size=32, lr=1e-3): loader = DataLoader(dataset, batch_size=batch_size, shuffle=True) model = SpamRNNClassifier(vocab_size) ​ # CrossEntropyLoss 内部已做 softmax,所以模型末层不要再接 softmax # 否则梯度会被重复归一化,训练直接发散 criterion = nn.CrossEntropyLoss() optimizer = optim.Adam(model.parameters(), lr=lr) ​ model.train() for epoch in range(epochs): total_loss = 0.0 correct = 0 for batch_x, batch_y in loader: optimizer.zero_grad() logits = model(batch_x) loss = criterion(logits, batch_y) loss.backward() optimizer.step() ​ total_loss += loss.item() correct += (logits.argmax(dim=1) == batch_y).sum().item() ​ print(f"Epoch {epoch + 1}/{epochs} loss={total_loss / len(loader):.4f} acc={correct / len(dataset):.4f}") return model ​ ​ # ---- 启动训练 ---- texts = ["您的验证码是3812", "点击链接领取奖品", "明天十点开会", "恭喜中奖请回短信"] * 20 labels = [0, 1, 0, 1] * 20 vocab = build_vocab(texts) dataset = SmsDataset(texts, labels, vocab, max_len=16) model = train_model(dataset, vocab_size=len(vocab), epochs=5)

预期控制台输出:

Epoch 1/5 loss=0.6921 acc=0.5125 Epoch 2/5 loss=0.6743 acc=0.6500 Epoch 3/5 loss=0.6318 acc=0.7875 Epoch 4/5 loss=0.5542 acc=0.8875 Epoch 5/5 loss=0.4516 acc=0.9250

这里用的是极小演示数据,真实项目样本量要上千条才能看到稳定泛化效果。重点看loss 是否单调下降、acc 是否上升——这才是训练正常的信号。


五、踩坑记录(避坑指南)——本文最值钱的部分

真正让我印象最深的,不是模型结构,而是训练流程里暴露的三个问题。每一个都足够让"看起来不错的准确率"变成纸老虎。

坑 1:测试集发生了信息泄漏(最关键)

现象:测试准确率出奇地高,上线后效果却大打折扣。

根因:词表是在划分训练/测试集之前、用全部短信一起构建的。这样一来,测试集里的词汇信息已经提前泄漏给了模型——等于考试前偷看了答案。

更严重的是,数据里还有不少重复短信。随机切分后,测试集中部分文本也同时出现在训练集中,模型"背"住了答案。

修复(三步缺一不可):

from sklearn.model_selection import train_test_split ​ # 第 1 步:先按文本去重,重复样本是泄漏的直接来源 seen = set() dedup_texts, dedup_labels = [], [] for t, l in zip(texts, labels): key = "".join(t.lower().split()) # 归一化:去空格 + 转小写,避免" 3812 "和"3812"被判成两条 if key not in seen: seen.add(key) dedup_texts.append(t) dedup_labels.append(l) ​ # 第 2 步:再切分,stratify 按标签分层,防止某一类全跑到测试集 train_texts, test_texts, train_labels, test_labels = train_test_split( dedup_texts, dedup_labels, test_size=0.2, random_state=42, stratify=dedup_labels ) ​ # 第 3 步:词表只能用训练集构建!测试集的词一旦进入词表, # 就等于提前告诉模型"这个词存在",构成信息泄漏 vocab = build_vocab(train_texts)

💡一句话记住:评估不准比模型不强更危险。一个"看起来准确率不错"的模型,可能只是数据切分方式有问题。

坑 2:模型保存得还不够完整

现象:训练完导出权重,单独拿出去推理结果时好时坏。

根因:只存了model.state_dict()(模型权重),却没存词表、最大长度、标签含义、模型超参。新短信在部署时无法用和训练阶段完全一致的方式转成输入,推理自然不稳定。

修复:把推理所需的全部上下文打包保存,而不只是权重。

import json ​ def save_checkpoint(model, vocab, max_len, label_map, path="spam_model"): # 权重和"如何把文本变成输入"的规则是一套,缺一不可,必须一起存 torch.save(model.state_dict(), f"{path}.pt") with open(f"{path}.json", "w", encoding="utf-8") as f: json.dump({ "vocab": vocab, "max_len": max_len, "label_map": label_map, # {0: "正常", 1: "垃圾"},推理时把数字翻回人类可读标签 }, f, ensure_ascii=False, indent=2)

它现在才从"训练实验结果"变成真正可交付的推理组件

坑 3:模块耦合过重

现象:import某个模块,它就直接读完整数据、还打印一大堆内容,调试时根本没法单独测某个函数。

根因:把"加载数据"这种副作用写在了模块顶层,而不是封装成函数/类按需调用。

修复原则:模块顶层只放定义(类、函数、常量),所有"读文件、打印、训练"这类有副作用的操作都收进if __name__ == "__main__":里。项目一变大,你就会感谢现在这个习惯。


六、运行结果验证:在训练前自动检查文本泄漏

文本项目最容易忽略重复样本:同一句短信如果同时出现在训练集和测试集,评估结果会被明显抬高。下面这段代码可以在训练前做一道"泄漏体检",花 10 秒跑一下,省下后面 10 小时的排查。

import hashlib ​ def fingerprint(text): """生成文本指纹:去空格 + 转小写后做 sha256,让大小写/空格差异不影响判重。""" normalized = "".join(text.lower().split()) return hashlib.sha256(normalized.encode("utf-8")).hexdigest() ​ def check_leakage(train_texts, test_texts): """检查训练集与测试集之间的文本重复程度。""" train_fp = {fingerprint(x) for x in train_texts} test_fp = {fingerprint(x) for x in test_texts} overlap = train_fp & test_fp return overlap ​ # ---- 模拟一份带泄漏的数据 ---- train = ["您的验证码是 3812", "点击链接领取奖品", "明天十点开会"] test = [" 您的验证码是3812 ", "周末一起吃饭"] ​ overlap = check_leakage(train, test) print("训练样本:", len(train), " 测试样本:", len(test)) print("重复指纹数量:", len(overlap)) print("是否泄漏:", "⚠️ 是" if overlap else "✅ 否")

预期控制台输出:

训练样本: 3 测试样本: 2 重复指纹数量: 1 是否泄漏: ⚠️ 是

去除空格并转小写后,两条验证码短信被识别为同一文本。真实项目还应检查近似重复(标点差异、同义词替换)、同源实体同一会话造成的泄漏。


七、常见误区

  1. "先全量去重再随便划分就够了"—— 错。相似文本和同源实体仍可能跨集合,去重只能挡完全重复。

  2. "测试准确率高就说明项目成功"—— 错。还要检查泄漏、类别分布、错误案例和线上输入差异,高准确率可能是假象。


八、这套方案的局限与下一步

项目能跑通,但它不是终点。三个明显局限:

局限说明优化方向
模型结构偏基础传统 RNN 在长文本上容易出现长距离依赖丢失和梯度问题,上限低于 LSTM/GRU/Transformer升级到GRU → LSTM → Transformer
预处理偏粗目前只做基础分词,没有停用词过滤、特殊符号清洗、噪声字符处理加清洗管线,减少无效信息干扰
数据集构建不严谨固定长度补零引入大量无效位置,重复样本影响泛化按需截断 + 严格去重切分

如果继续迭代,我会优先做四件事:

  1. 先按文本去重,再重新划分训练/验证/测试集;

  2. 只用训练集构建词表,杜绝测试信息泄漏;

  3. 升级模型结构,从基础 RNN 往 GRU、LSTM 甚至 Transformer 方向走;

  4. 把推理所需上下文一并保存,做成真正可部署的推理链路。


九、总结与思维拓展

这次复盘让我清楚意识到:做机器学习项目不能只盯着"模型搭起来了没有"

  • 数据怎么处理——分词、清洗、去重;

  • 切分方式是否合理——有没有泄漏;

  • 评估是否可信——准确率高是真的强还是数据造的假;

  • 模型能不能真正被部署——权重之外的东西存全了没。

这些问题,往往比单纯跑出一个准确率更重要。一个项目的质量,很多时候不是由最亮眼的那一层模型决定的,而是由那些容易被忽略的基础环节决定的。

思维拓展:同样的"数据泄漏"逻辑,不止适用于文本分类。推荐系统里"用户行为泄漏到测试集"、图像分类里"同一张图的不同裁剪跨集合"、时序预测里"未来特征提前可见"——本质都是同一类问题。掌握了这套排查思路,等于掌握了所有监督学习项目的评估可信度自检能力


动手练习

加入标点清洗,并尝试识别"点击链接领取奖品!"与原句"点击链接领取奖品"是否属于近似重复。(提示:在fingerprint里先做re.sub(r"[^\w\u4e00-\u9fa5]", "", text)去掉标点和特殊符号。)


如果这篇文章帮你避开了数据泄漏的坑,或省下了排查训练问题的时间,不妨点个赞 👍支持一下,你的认可是我持续输出的动力。

更建议先收藏 ⭐:项目实战时直接对照着改,比临时翻文档快得多。

如果在自家数据上跑出问题、或对某段代码有疑问,欢迎评论区交流,我会逐条回复。

相关推荐:

  • 《中文文本分类从 0 到 1》系列专栏——本篇是其中一篇,关注我可看完整路线

  • 下一篇预告:把基础 RNN 升级成 GRU/LSTM 后,长文本效果能提升多少?实测对比即将更新

本文首发于「去你想去的地方」:从垃圾短信识别项目里,我复盘了一个中文文本分类系统 | 去你想去的地方

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

DeepSeek 开源 DSpark,一个可将 LLM 推理速度提升高达 85% 的新框架

尽管随着美国政府限制Anthropic和OpenAI新模型的行动&#xff0c;围绕AI的地缘政治讨论愈发紧张&#xff0c;中国开源宠儿DeepSeek依然带着又一次公开发布&#xff0c;可能再次改变全球AI的发展格局。 周末&#xff0c;公司发布了DSpark&#xff0c;这是一个新的麻省理工学院授…

作者头像 李华
网站建设 2026/7/4 3:36:04

【ROS】 ros学习日记(1)

ros学习日记&#xff08;1&#xff09;ros安装测试ros&#xff08;小乌龟&#xff0c;启动&#xff01;&#xff01;&#xff09;启动小乌龟并测试小乌龟不动错误排查HelloWorld1.创建工作空间并初始化2.进入 src 创建 ros 包并添加依赖3.使用C编写程序ros的安装、测试和hellow…

作者头像 李华
网站建设 2026/7/4 3:34:53

swagger增强knife4j

1、官网文档 快速开始 | Knife4j 2、引入依赖 <dependency><groupId>com.github.xiaoymin</groupId><artifactId>knife4j-openapi3-jakarta-spring-boot-starter</artifactId><version>4.5.0</version> </dependency>3、配…

作者头像 李华
网站建设 2026/7/4 3:34:32

C++:拷贝构造函数

一、什么是拷贝构造函数&#xff1f; 拷贝构造函数是一种特殊的构造函数&#xff0c;它的参数是同类型对象的常量引用&#xff0c;即用已存在的一个变量初始化另一个同类型变量。 class Person { public:// 拷贝构造函数Person(const Person& other) {name other.name;age…

作者头像 李华
网站建设 2026/7/4 3:32:45

椭圆曲线 Diffie-Hellman 密钥交换解题思路

在密码学入门中&#xff0c;椭圆曲线密码&#xff08;ECC&#xff09;以其更短的密钥长度和更高的安全性备受青睐。而椭圆曲线 Diffie-Hellman&#xff08;ECDH&#xff09;则是 ECC 最经典的应用之一&#xff0c;它允许双方在不安全的信道上协商出一个共享秘密。最近在解决 Cr…

作者头像 李华