资讯动态

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

发布时间:2026/10/9 20:50:47 来源:尧图企业网站定制
简介本资源面向计算机相关专业学生、科研人员及行业开发者提供一套基于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(rhttps?://\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, 验证码88885分钟内有效请勿泄露, ] for s in samples: print(clean_sms_text(s))这段代码的逻辑说明unicodedata.normalize(NFKC, text)解决的是全角半角混用问题比如「」和「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]) ], dtypetorch.float) criterion nn.CrossEntropyLoss(weightweights)这里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_classes2, dropout0.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_idsinput_ids, attention_maskattention_mask) # 取 [CLS] 位置的向量作为整句表示 cls_vector outputs.last_hidden_state[:, 0, :] cls_vector self.dropout(cls_vector) logits self.classifier(cls_vector) return logitsdropout 设 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(), lr2e-5, weight_decay0.01) total_steps len(train_loader) * epochs scheduler get_linear_schedule_with_warmup( optimizer, num_warmup_stepsint(0.1 * total_steps), num_training_stepstotal_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(fEarly stop at epoch {epoch}) breakevaluate 函数里用 sklearn 的 f1_score 和 recall_scoreaverage 参数设 binarypos_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_size64, threshold0.5): all_probs [] for i in range(0, len(texts), batch_size): batch_texts texts[i:ibatch_size] # 清洗后再 tokenize batch_texts [clean_sms_text(t) for t in batch_texts] encoded tokenizer( batch_texts, max_length64, paddingmax_length, truncationTrue, return_tensorspt ) 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 上很慢。解决最大序列长度压到 64batch 推理如果 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_high0.95, threshold_low0.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、以及清洗规则版本记到一个表格里。垃圾短信这个任务清洗规则和阈值的组合太多了不记录的话两周后根本想不起来哪个配置跑出过最好的结果。这个习惯帮我省了很多重复实验的时间。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑