news 2026/10/9 20:50:33

垃圾短信文本识别实战:从数据清洗到BERT微调与阈值调优

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
垃圾短信文本识别实战:从数据清洗到BERT微调与阈值调优

简介:本资源面向计算机相关专业学生、科研人员及行业开发者,提供一套基于BERT模型的垃圾短信文本识别完整方案,源自CCF大数据竞赛实践。项目包含数据清洗、多模型对比与投票集成等核心环节,适合作为毕业设计、课程设计、竞赛复现或原型验证的参考素材。压缩包共47个文件,约8.46MB,以30个txt实验记录、7个Python源码、4个pyc编译文件、2个bat批处理脚本及2个csv结果文件为主,另附技术报告与数据集压缩包,覆盖从数据预处理到精度评估的完整流程。已有47人浏览学习。读者可获取可运行的源码、多组模型精度对比记录、数据转换与预测脚本,以及一份技术报告,便于快速理解BERT在中文短信分类中的落地方式,并在此基础上修改扩展,实现其他文本分类任务。

1. 垃圾短信文本识别:从数据清洗到 BERT 微调的完整落地路径

垃圾短信文本识别系统,本质上是一个中文短文本二分类任务:给一条短信,判断它是正常信息还是垃圾信息。这个方向在 CCF 大数据竞赛里出现过多次,核心难点不在模型结构,而在数据本身——短信文本短、噪声大、类别不均衡、对抗样本多。很多人拿到数据集直接上 BERT 微调,结果 F1 卡在 0.92 上不去,回头一看,问题全出在清洗环节。这篇文章面向想复现这套方案的从业者:不管你是刚接触文本分类的新手,还是做过几个 NLP 项目想看看别人怎么处理脏数据的老手,下面这套从数据清洗到 BERT 微调再到阈值调优的流程,都能直接照着跑。我会把每一步的参数、坑点和判断依据讲清楚,不绕弯子。

2. 数据清洗:垃圾短信识别里最容易被低估的一步

2.1 为什么原始短信数据不能直接喂给 BERT

短信文本和新闻、评论不一样,它的噪声来源非常集中。常见的有几类:第一,大量非文本内容混入,比如电话号码、网址、验证码数字串、表情符号的 Unicode 编码残留;第二,同一含义的不同表达,比如「加微信」写成「加V」「+V」「薇信」,这类变体在垃圾短信里出现频率极高;第三,正常短信里也有大量模板化内容,比如快递通知、银行验证码,它们和垃圾短信在字面特征上高度重叠。

如果直接把原始文本丢给 BERT,模型会学到大量与分类无关的表面特征。比如它可能发现「http」出现就是垃圾,但正常短信里的物流链接也会带 http,这就导致误杀。更隐蔽的问题是,BERT 的 WordPiece 分词对数字和符号的处理方式会把「13800138000」切成多个片段,这些片段在垃圾短信和正常短信里分布不同,模型会过度依赖这些碎片特征。

我一般会先做一轮统计,看看数据里数字占比、URL 占比、非中文字符占比分别是多少。如果非中文字符占比超过 15%,清洗就是必须的,不是可选项。

2.2 清洗流程的四个步骤与代码实现

下面这套清洗流程是我在多个中文短文本项目里反复用过的,顺序不能乱:先做字符级过滤,再做文本归一化,然后处理类别不均衡,最后划分数据集。

import re import unicodedata from collections import Counter def clean_sms_text(text): """ 短信文本清洗主函数 参数 text: 原始短信字符串 返回: 清洗后的字符串 """ if not isinstance(text, str): return "" # 第一步:Unicode 归一化,把全角字符转半角,统一编码形式 text = unicodedata.normalize("NFKC", text) # 第二步:去除 URL,保留一个占位符而不是直接删掉 # 直接删掉会丢失"是否含链接"这个信号,用占位符保留 text = re.sub(r"https?://\S+|www\.\S+", " [URL] ", text) # 第三步:去除电话号码和长数字串,同样用占位符 text = re.sub(r"\b1[3-9]\d{9}\b", " [PHONE] ", text) text = re.sub(r"\b\d{6,}\b", " [NUM] ", text) # 第四步:去除多余空白和特殊符号,但保留中文、英文、常用标点 text = re.sub(r"[^\u4e00-\u9fa5a-zA-Z0-9,。!?、:;()\[\] ]", " ", text) text = re.sub(r"\s+", " ", text).strip() return text # 测试几条典型样本 samples = [ "【某快递】您的包裹已到,请凭取件码123456到某驿站领取", "加V信13800138000,日赚500,详情点击 http://example.com", "验证码:8888,5分钟内有效,请勿泄露", ] for s in samples: print(clean_sms_text(s))

这段代码的逻辑说明:unicodedata.normalize("NFKC", text)解决的是全角半角混用问题,比如「138」和「138」在模型看来是不同 token,归一化后统一。URL 和电话号码用占位符而不是直接删除,是因为「是否包含链接」本身就是垃圾短信的强特征,删掉等于丢信息。最后的字符过滤只保留中文、英文、数字和常用标点,其他符号统一替换为空格。

参数方面,电话号码的正则\b1[3-9]\d{9}\b覆盖的是国内手机号段,如果你的数据里有其他格式,需要调整。长数字串的阈值设的是 6 位,这个可以按数据分布调,一般验证码是 4 到 6 位,如果设成 4 会把验证码也替换掉,反而丢失信号。

2.3 类别不均衡的处理策略与参数选择

垃圾短信数据集里,正常短信通常远多于垃圾短信,比例可能到 10:1 甚至更高。直接训练的话,模型会倾向于把所有样本判为正常,准确率看着高,但召回率极低。

常见做法有三种:过采样、欠采样、损失函数加权。我一般优先用损失函数加权,因为它不改变数据分布,实现也简单。在 PyTorch 里就是给 CrossEntropyLoss 传一个 weight 参数,weight 的值按类别频率的倒数来设。

import torch from torch import nn from collections import Counter # 假设 labels 是训练集的标签列表,0 为正常,1 为垃圾 label_counts = Counter(labels) total = sum(label_counts.values()) # 权重与频率成反比,频率越低权重越高 weights = torch.tensor([ total / (2 * label_counts[0]), total / (2 * label_counts[1]) ], dtype=torch.float) criterion = nn.CrossEntropyLoss(weight=weights)

这里total / (2 * label_counts[i])的 2 是类别数,这样算出来的权重均值约为 1,不会让整体损失尺度变化太大。如果垃圾短信占比不到 5%,我会把垃圾类的权重再乘一个 1.5 到 2 的系数,具体看验证集上的召回表现调。

注意:过采样虽然简单,但在文本任务里容易导致过拟合,尤其是垃圾短信模板重复率高的时候,复制样本等于让模型死记模板。如果非要用,建议配合 dropout 和早停。

3. BERT 微调:从预训练权重到可用的分类器

3.1 模型选型与中文预训练权重的选择依据

中文 BERT 有几个常见版本:bert-base-chinese、roberta-base-chinese、以及各种领域预训练版本。对于短信分类这种短文本任务,bert-base-chinese 已经够用,它的词表覆盖了大部分中文常用字和词。如果数据里有大量网络用语和变体表达,可以考虑用在社交语料上继续预训练过的版本,但要注意这些版本的质量参差不齐,选之前最好在验证集上对比一下。

模型结构上,就是在 BERT 的 [CLS] 向量后面接一个 dropout 加全连接层,输出维度为 2。不要加太复杂的头部,短文本分类不需要。

from transformers import BertModel, BertTokenizer import torch.nn as nn class SmsClassifier(nn.Module): def __init__(self, pretrained_path, num_classes=2, dropout=0.3): super().__init__() self.bert = BertModel.from_pretrained(pretrained_path) self.dropout = nn.Dropout(dropout) self.classifier = nn.Linear(self.bert.config.hidden_size, num_classes) def forward(self, input_ids, attention_mask): outputs = self.bert(input_ids=input_ids, attention_mask=attention_mask) # 取 [CLS] 位置的向量作为整句表示 cls_vector = outputs.last_hidden_state[:, 0, :] cls_vector = self.dropout(cls_vector) logits = self.classifier(cls_vector) return logits

dropout 设 0.3 是我在短文本任务里的默认值,如果训练集小于 1 万条,可以提到 0.4 到 0.5。分类层就是单层线性,不要加隐藏层,短文本的 [CLS] 向量已经足够表达。

3.2 训练参数配置与显存优化

BERT 微调的参数比较敏感,下面这套配置在单卡 8GB 显存、数据量 5 万条以内的场景下可以直接用。

参数推荐值说明
最大序列长度64短信很少超过 64 个 token,设大了浪费显存
batch size328GB 显存下 64 长度可以跑到 32
学习率2e-5BERT 微调的标准起点,不要超过 5e-5
权重衰减0.01防止过拟合
训练轮数3 到 5看验证集 loss,不降就停
预热比例0.1前 10% 步数做线性预热
梯度裁剪1.0防止梯度爆炸

最大序列长度设 64 是关键。很多人默认用 128 或 256,但短信平均长度也就 30 到 50 个字符,设 64 能省一半显存,batch size 就能翻倍,训练更稳。

from transformers import AdamW, get_linear_schedule_with_warmup optimizer = AdamW(model.parameters(), lr=2e-5, weight_decay=0.01) total_steps = len(train_loader) * epochs scheduler = get_linear_schedule_with_warmup( optimizer, num_warmup_steps=int(0.1 * total_steps), num_training_steps=total_steps ) # 训练循环里的关键几步 for batch in train_loader: input_ids = batch["input_ids"].to(device) attention_mask = batch["attention_mask"].to(device) labels = batch["label"].to(device) logits = model(input_ids, attention_mask) loss = criterion(logits, labels) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) optimizer.step() scheduler.step() optimizer.zero_grad()

梯度裁剪放在 backward 之后、step 之前,顺序不能错。预热比例 0.1 是经验值,数据量小的时候可以降到 0.05,避免预热阶段太长导致欠拟合。

3.3 验证集指标与早停策略

垃圾短信识别不能只看准确率。正常短信占多数时,模型全判正常也能有 90% 以上的准确率,但垃圾短信一条没抓到。我一般同时看三个指标:垃圾类的 F1、垃圾类的召回率、以及整体 AUC。

早停策略按验证集 F1 来,连续 2 个 epoch 不提升就停,同时保存 F1 最高的那个 checkpoint。不要按验证集 loss 早停,因为 loss 和 F1 在类别不均衡时经常不同步。

best_f1 = 0.0 patience = 2 no_improve = 0 for epoch in range(epochs): train_one_epoch(model, train_loader, optimizer, scheduler, criterion) val_f1, val_recall = evaluate(model, val_loader) if val_f1 > best_f1: best_f1 = val_f1 torch.save(model.state_dict(), "best_model.pt") no_improve = 0 else: no_improve += 1 if no_improve >= patience: print(f"Early stop at epoch {epoch}") break

evaluate 函数里用 sklearn 的 f1_score 和 recall_score,average 参数设 "binary",pos_label 设 1 表示垃圾类。如果验证集里垃圾短信少于 100 条,F1 波动会比较大,这时候可以把 patience 提到 3。

4. 推理部署与阈值调优:让模型在真实场景可用

4.1 分类阈值的选取方法

模型输出的是 logits,经过 softmax 后得到概率。默认阈值是 0.5,但在垃圾短信场景里,0.5 往往不是最优的。如果业务上更不能容忍漏判垃圾短信,阈值要往下调,比如 0.3;如果更不能容忍误杀正常短信,阈值往上调,比如 0.7。

阈值的选择不能拍脑袋,要在验证集上画 P-R 曲线,找到满足业务约束的点。比如要求召回率不低于 0.95,那就找召回率 0.95 时精确率最高的那个阈值。

import numpy as np from sklearn.metrics import precision_recall_curve # probs 是模型对垃圾类的预测概率,labels 是真实标签 precision, recall, thresholds = precision_recall_curve(labels, probs) # 找召回率 >= 0.95 时精确率最高的阈值 target_recall = 0.95 valid_idx = np.where(recall >= target_recall)[0] best_idx = valid_idx[np.argmax(precision[valid_idx])] best_threshold = thresholds[best_idx] print(f"阈值: {best_threshold:.3f}, 精确率: {precision[best_idx]:.3f}, 召回率: {recall[best_idx]:.3f}")

注意 precision_recall_curve 返回的 thresholds 长度比 precision 和 recall 少 1,索引的时候要小心。另外这个阈值是在验证集上选的,上线前最好再留一个测试集确认一下,避免过拟合验证集。

4.2 批量推理与性能优化

线上推理时,短信是一条条来的,但如果做离线批量处理,可以用 batch 推理提升吞吐。关键是把 tokenize 和模型前向都做成批量的,不要一条条循环。

from transformers import BertTokenizer import torch tokenizer = BertTokenizer.from_pretrained("bert-base-chinese") model.eval() def batch_predict(texts, batch_size=64, threshold=0.5): all_probs = [] for i in range(0, len(texts), batch_size): batch_texts = texts[i:i+batch_size] # 清洗后再 tokenize batch_texts = [clean_sms_text(t) for t in batch_texts] encoded = tokenizer( batch_texts, max_length=64, padding="max_length", truncation=True, return_tensors="pt" ) with torch.no_grad(): logits = model( encoded["input_ids"].to(device), encoded["attention_mask"].to(device) ) probs = torch.softmax(logits, dim=-1)[:, 1].cpu().numpy() all_probs.extend(probs.tolist()) return [1 if p >= threshold else 0 for p in all_probs]

padding 用 "max_length" 而不是 "longest",是为了让所有 batch 的序列长度一致,GPU 利用率更稳定。如果显存紧张,可以改成 "longest" 加动态 padding,但那样每个 batch 的长度不同,速度会慢一些。

4.3 模型置信度校准的简单做法

BERT 微调后的模型经常出现过度自信的问题,预测概率集中在 0.9 以上,导致阈值调不动。一个简单的校准方法是在验证集上做温度缩放:用一个标量温度 T 去缩放 logits,让 softmax 后的概率更接近真实分布。

import torch.nn.functional as F def calibrate_temperature(logits, labels, device): """在验证集上搜索最优温度 T""" logits = torch.tensor(logits).to(device) labels = torch.tensor(labels).to(device) best_T = 1.0 best_nll = float("inf") for T in np.arange(0.5, 3.0, 0.1): scaled = logits / T nll = F.cross_entropy(scaled, labels).item() if nll < best_nll: best_nll = nll best_T = T return best_T

温度 T 大于 1 会让概率分布更平缓,小于 1 会更尖锐。一般 BERT 微调后 T 在 1.5 到 2.5 之间。校准后再选阈值,P-R 曲线会更合理。

5. 避坑与排查:垃圾短信识别里那些血泪经验

5.1 清洗过度导致信号丢失

现象:清洗后模型 F1 反而比不清洗低,尤其是垃圾短信召回率下降明显。

原因:把 URL、电话号码直接删掉而不是用占位符,导致「含链接」「含电话」这两个强特征消失。另外,有些清洗脚本会把所有数字都删掉,但验证码短信和垃圾短信的数字模式不同,删掉后模型分不清。

解决:URL 和电话用占位符保留,数字只替换超长串,短数字保留。清洗前后各跑一次基线,对比验证集指标,确认清洗是正向收益再继续。

5.2 验证集泄露

现象:验证集 F1 很高,上线后效果差一大截。

原因:划分数据集之前做了全局清洗或全局统计,比如用全量数据算的均值方差做归一化,或者清洗规则里用到了验证集的分布信息。更隐蔽的是,同一条短信的变体被分到了训练集和验证集,模型其实见过类似样本。

解决:先划分再清洗,清洗规则只在训练集上拟合。对于模板化严重的垃圾短信,按模板去重后再划分,避免同模板样本跨集。

5.3 学习率设太大导致训练发散

现象:训练 loss 在前几百步就飙到 nan,或者验证集指标剧烈震荡。

原因:BERT 微调的学习率比从头训练小一到两个数量级,设成 1e-3 肯定发散。另外,如果用了 AdamW 但没设 weight_decay,或者 warmup 步数太少,也会不稳定。

解决:学习率从 2e-5 起步,最大不超过 5e-5。warmup 比例设 0.1,梯度裁剪设 1.0。如果还是发散,检查数据里有没有空文本或超长文本,空文本经过 tokenizer 后全是 padding,会产生异常梯度。

5.4 忽略推理速度导致上线受阻

现象:离线指标很好,但线上 QPS 上不去,单条推理超过 50ms。

原因:序列长度设了 128 甚至 256,但实际短信平均只有 40 个 token,大量计算浪费在 padding 上。另外,没有用 ONNX 或 TensorRT 做推理优化,PyTorch 原生推理在 CPU 上很慢。

解决:最大序列长度压到 64,batch 推理,如果 QPS 要求高就导出 ONNX。导出时注意把 tokenizer 的输出也固化进去,避免 Python 端的 tokenize 成为瓶颈。

5.5 类别权重设得过大导致误杀

现象:垃圾短信召回率很高,但正常短信被大量误判为垃圾,精确率暴跌。

原因:损失函数里垃圾类的权重设得太大,模型为了降低损失,倾向于把边界样本判为垃圾。

解决:权重从频率倒数起步,不要额外乘系数。如果召回率已经达标但精确率低,优先调阈值而不是继续加权重。阈值调高比调权重更可控,因为阈值不影响模型本身。

6. 进阶技巧:用对抗验证和伪标签把 F1 再推两个点

当你的模型在验证集上卡在某个 F1 上不去时,可以试试对抗验证。思路很简单:把训练集和验证集混在一起,训练一个分类器去区分样本来自哪个集。如果这个分类器 AUC 很高,说明训练集和验证集分布差异大,模型在验证集上的表现不能代表真实泛化能力。这时候要么重新划分数据,要么对训练集做重采样。

具体做法是把训练集和验证集的文本分别打上 0 和 1 的域标签,用同一个 BERT 结构训练一个二分类器。如果域分类器的 AUC 超过 0.7,就说明分布差异显著。我一般会检查是不是清洗规则在训练集和验证集上不一致,或者验证集里混入了不同来源的样本。

另一个技巧是伪标签。先用训练好的模型对未标注数据做预测,把置信度高于 0.95 的垃圾短信和低于 0.05 的正常短信加入训练集,重新微调。这一步能把 F1 再推 1 到 2 个点,但要注意置信度阈值不能太低,否则会把噪声引入训练集。伪标签的轮数不要超过 2 轮,多了会自我强化错误。

# 伪标签流程的关键步骤 def pseudo_labeling(model, unlabeled_texts, threshold_high=0.95, threshold_low=0.05): probs = batch_predict_probs(unlabeled_texts) # 返回垃圾类概率 pseudo_samples = [] for text, p in zip(unlabeled_texts, probs): if p >= threshold_high: pseudo_samples.append((text, 1)) elif p <= threshold_low: pseudo_samples.append((text, 0)) return pseudo_samples # 把伪标签样本和原始训练集合并,重新微调 # 注意伪标签样本的损失权重可以设成原始样本的 0.5,降低噪声影响

伪标签样本的损失权重设 0.5 是我常用的做法,这样即使有少量错误标签,也不会主导梯度。合并后重新训练时,学习率可以降到 1e-5,因为模型已经有一个不错的起点了。

最后说一个我自己的习惯:每次调完参数,不管指标涨没涨,我都会把当次的配置、验证集 F1、以及清洗规则版本记到一个表格里。垃圾短信这个任务,清洗规则和阈值的组合太多了,不记录的话,两周后根本想不起来哪个配置跑出过最好的结果。这个习惯帮我省了很多重复实验的时间。希望帮到你。

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

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

软件测试模拟卷拆解:从流程规范到项目实战与自动化面试备考

这套《软件测试模拟试卷二_hyj》我完整做了一遍&#xff0c;还拉了几个正在准备跳槽的小伙伴一起刷了几天。整体感受是&#xff1a;它不是一份“背答案”就能过的卷子&#xff0c;而是把软件测试流程、测试用例设计、自动化测试、面试八股文、项目实战经验这些散点全揉进了一张…

作者头像 李华
网站建设 2026/10/9 20:48:52

OpenClaw 零基础部署指南:Windows 与 macOS 全流程避坑详解

前阵子有位朋友在群里发消息&#xff0c;说自己照着 OpenClaw 的 README 装&#xff0c;三步就卡住了。不是网络问题&#xff0c;不是电脑太老&#xff0c;就是卡在终端里报了一个不算复杂的错误。我说你把报错发来看看&#xff0c;结果发现是连最基本的路径和权限概念都没理顺…

作者头像 李华
网站建设 2026/10/9 20:48:45

Python脚本优雅处理Ctrl+C:从信号原理到多线程与asyncio方案

写Python脚本&#xff0c;最大的欣慰就是程序能自己把身后事安排好。但现实往往是&#xff1a;一个脚本跑着跑着&#xff0c;数据库连接还挂着&#xff0c;临时文件写到一半&#xff0c;日志缓存没刷&#xff0c;这时候有人按下CtrlC&#xff0c;进程直接死了&#xff0c;留下一…

作者头像 李华
网站建设 2026/10/9 20:45:51

Tesseract-OCR中文识别实战:安装包与语言包配置及Python调用指南

简介&#xff1a;本资源面向需要做文字识别的开发者与人工智能方向学习者&#xff0c;提供 tesseract-ocr 安装包及配套中文语言包&#xff0c;可用于 Python 环境下的 OCR 文字提取、图像转文本等任务&#xff0c;帮助解决中文识别缺少训练数据、环境搭建繁琐的问题。压缩包共…

作者头像 李华
网站建设 2026/10/9 20:45:47

分数傅里叶变换做chirp参数估计:从原理到Python实现

简介&#xff1a;这份资源围绕分数阶傅里叶变换&#xff08;FRFT&#xff09;在chirp信号参数估计中的应用展开&#xff0c;面向信号处理方向的初学者与工程技术人员&#xff0c;帮助理解分数域分析的基本原理与实现思路。仿真覆盖单分量、多分量、强弱分量共存以及含噪声等多种…

作者头像 李华
网站建设 2026/10/9 20:41:17

SQL Server索引查找退化为索引扫描的典型场景与排查方法

简介&#xff1a;这份PDF资料聚焦SQL Server查询优化中的典型性能问题&#xff0c;系统梳理了执行计划从索引查找&#xff08;Index Seek&#xff09;退化为索引扫描&#xff08;Index Scan&#xff09;的多种成因&#xff0c;适合数据库开发、DBA及性能调优人员参考。内容结合…

作者头像 李华