简介:本资源面向计算机相关专业学生、科研人员及行业开发者,提供一套基于BERT模型的垃圾短信文本识别完整方案,源自CCF大数据竞赛实践,可用于毕业设计、课程设计、项目立项演示或技术研究。压缩包共47个文件,约8.46MB,以30个txt实验记录、7个Python源码、4个pyc编译文件、2个bat批处理脚本、2个csv结果数据及技术报告md为主,覆盖数据清洗、模型训练、多分类器对比与精度评估等环节。源码上传前经过多环境测试,可稳定复现,并附有技术报告供参考学习。目前已有47人浏览学习。读者可获取完整赛题实现思路、数据预处理脚本、多种投票与集成模型对比结果,以及可修改扩展的代码框架,便于快速搭建原型、验证算法效果,遇到环境配置问题还可获得远程指导与技术支持。
1. 垃圾短信文本识别系统:从 BERT 微调到数据清洗,一套能跑通的完整方案
垃圾短信文本识别系统这个题目,看起来是典型的文本二分类任务,但真正动手做过的人都知道,难点从来不在模型结构本身,而在数据。CCF 大数据竞赛这类赛题给出的原始短信数据,往往夹杂着大量重复样本、标签噪声、特殊符号和极短文本,直接丢进 BERT 微调,F1 大概率卡在 0.9 上下上不去。我见过太多人把精力全花在换模型、调学习率上,结果回头一看,训练集里同一条短信出现了十几次,正负样本比例接近 1:10,模型学到的其实是「长度越短越像垃圾短信」这种伪特征。
这套方案的核心思路很明确:先用一套可复现的数据清洗流程把原始短信整理干净,再用 BERT 做微调,最后用阈值搜索和错误分析把边界样本捞出来。适合谁?适合已经跑过 HuggingFace 基础微调、想在一个真实赛题数据上把完整链路走一遍的工程师,也适合需要快速搭一个短信审核原型的小团队。下面按数据清洗、模型微调、避坑、进阶技巧的顺序展开,每一步都给可抄的代码和参数说明。
2. 数据清洗:垃圾短信识别的地基怎么打
2.1 先看清原始数据长什么样
CCF 这类赛题的数据通常是一个 CSV 或 TSV,字段大致是label和message两列,label 用 0/1 或 ham/spam 表示。拿到数据第一件事不是写模型,是写一个统计脚本,把下面这些指标全部打出来:总样本数、正负比例、文本长度分布(min/median/max)、重复文本数量、含 URL 的样本比例、含连续数字超过 8 位的样本比例、纯符号样本数量。
import pandas as pd import re df = pd.read_csv("sms_train.csv") df.columns = ["label", "message"] # 基础统计 print("总样本:", len(df)) print("标签分布:\n", df["label"].value_counts()) print("长度分位:\n", df["message"].str.len().describe()) # 重复文本检测 dup = df[df.duplicated(subset=["message"], keep=False)] print("重复文本条数:", len(dup)) # 含 URL 比例 url_pat = re.compile(r"https?://|www\.|\.com|\.cn") print("含URL比例:", df["message"].str.contains(url_pat).mean()) # 纯符号或极短文本 print("长度<3的样本:", (df["message"].str.len() < 3).sum())这段脚本的作用是给你一张「体检报告」。如果重复文本占比超过 5%,就必须去重;如果正负比超过 1:8,就要考虑加权或重采样;如果长度中位数只有 20 来个字符,那 BERT 的 max_length 设 64 就够了,设 128 纯属浪费显存。我一般会把这些统计结果存成一个 markdown 表格,后面每次改清洗策略都回头对一遍。
2.2 清洗规则的设计与实现
清洗不是越狠越好。垃圾短信里恰恰有很多「噪声」是强信号,比如「加V」「点击链接」「退订回T」这些词,你如果把特殊符号全删了,反而把信号删没了。我的做法是分三层处理:第一层做无害归一化,第二层做去重,第三层做标签校验。
import re import pandas as pd def normalize_text(s: str) -> str: if not isinstance(s, str): return "" # 全角转半角 s = s.replace("\u3000", " ") # 统一空白 s = re.sub(r"\s+", " ", s).strip() # 保留中文、英文、数字和常见标点,去掉控制字符 s = re.sub(r"[\x00-\x1f\x7f]", "", s) return s def clean_pipeline(df: pd.DataFrame) -> pd.DataFrame: df = df.copy() df["message"] = df["message"].map(normalize_text) # 去掉空文本 df = df[df["message"].str.len() > 0] # 去重:同文本同标签只留一条 df = df.drop_duplicates(subset=["message", "label"]) # 冲突标签处理:同文本不同标签,按多数投票,票数相同则丢弃 conflict = df.groupby("message")["label"].nunique() conflict_msgs = conflict[conflict > 1].index if len(conflict_msgs) > 0: vote = df[df["message"].isin(conflict_msgs)] \ .groupby("message")["label"].agg(lambda x: x.mode().iloc[0] if len(x.mode()) == 1 else None) vote = vote.dropna() df = df[~df["message"].isin(conflict_msgs)] vote_df = vote.reset_index() vote_df.columns = ["message", "label"] df = pd.concat([df, vote_df], ignore_index=True) return df.reset_index(drop=True) train = clean_pipeline(pd.read_csv("sms_train.csv")) print("清洗后样本:", len(train))normalize_text里我特意没有去掉标点和数字,因为「回复T退订」「拨打10086」这类模式对分类很有用。去重时用message + label联合去重,而不是只按 message,因为同一句话在不同标签下出现,说明标注本身有问题,需要单独处理。冲突标签用众数投票,票数相同直接丢弃——这种样本留着就是给模型喂毒。清洗完记得把正负比例再打一次,如果变化超过 10%,说明原始数据里重复样本严重偏向某一类。
2.3 划分训练集与验证集时别踩的坑
很多人用train_test_split随机划分,结果验证集 F1 虚高。原因是短信数据里存在大量「模板相似」的样本,比如「【某平台】您的验证码是1234」和「【某平台】您的验证码是5678」,随机划分会把它们分到训练和验证两边,模型其实在背模板。正确做法是按「模板」分组划分,或者至少用GroupShuffleSplit按文本的归一化前缀分组。
from sklearn.model_selection import GroupShuffleSplit import re def get_template(s: str) -> str: # 用去掉数字后的前20个字符作为模板粗粒度标识 return re.sub(r"\d+", "#", s)[:20] train["group"] = train["message"].map(get_template) gss = GroupShuffleSplit(n_splits=1, test_size=0.2, random_state=42) train_idx, val_idx = next(gss.split(train, groups=train["group"])) train_set = train.iloc[train_idx].reset_index(drop=True) val_set = train.iloc[val_idx].reset_index(drop=True) print("训练集:", len(train_set), "验证集:", len(val_set))这样划分后验证集 F1 通常会比随机划分低 1 到 3 个点,但那个数字才是真实的。如果你拿随机划分的验证集去调阈值,上线后必然翻车。
3. BERT 微调:参数怎么设、训练怎么盯
3.1 模型选型与输入长度
中文短信场景,我一般直接上bert-base-chinese或者更小的hfl/rbt3。短信文本短,rbt3 这种 3 层模型在 1 万条数据上微调,效果和 base 差不了 0.5 个点,但推理速度快 3 倍。如果数据量超过 5 万条,再考虑 base。max_length 按清洗后长度分布的 95 分位来设,通常 64 足够,少数长短信截断即可。
from transformers import BertTokenizer, BertForSequenceClassification model_name = "hfl/rbt3" tokenizer = BertTokenizer.from_pretrained(model_name) model = BertForSequenceClassification.from_pretrained(model_name, num_labels=2) def encode(texts, labels=None): enc = tokenizer( texts, padding="max_length", truncation=True, max_length=64, return_tensors="pt" ) if labels is not None: enc["labels"] = labels return encmax_length=64是短信场景的甜点值。我试过 32,长尾样本损失明显;试过 128,显存翻倍但 F1 只涨 0.1。padding="max_length"在训练时配合动态 batch 效率一般,如果追求速度可以改成padding="longest"加DataCollatorWithPadding。
3.2 训练参数与类别不平衡处理
垃圾短信数据正负比经常是 1:5 到 1:10,直接训练模型会偏向多数类。两种处理方式:一是用WeightedTrainer给少数类加权,二是在 loss 里用 focal loss。我一般先用加权,简单可控。
import torch from torch import nn from transformers import Trainer, TrainingArguments class WeightedTrainer(Trainer): def __init__(self, class_weights, *args, **kwargs): super().__init__(*args, **kwargs) self.class_weights = class_weights def compute_loss(self, model, inputs, return_outputs=False, **kwargs): labels = inputs.pop("labels") outputs = model(**inputs) logits = outputs.logits loss_fct = nn.CrossEntropyLoss(weight=self.class_weights.to(logits.device)) loss = loss_fct(logits, labels) return (loss, outputs) if return_outputs else loss # 按训练集分布算权重 import numpy as np counts = train_set["label"].value_counts().sort_index().values weights = torch.tensor([len(train_set) / (2 * c) for c in counts], dtype=torch.float) args = TrainingArguments( output_dir="./sms_bert", learning_rate=2e-5, per_device_train_batch_size=32, per_device_eval_batch_size=64, num_train_epochs=4, weight_decay=0.01, warmup_ratio=0.1, evaluation_strategy="epoch", save_strategy="epoch", load_best_model_at_end=True, metric_for_best_model="f1", logging_steps=50, fp16=torch.cuda.is_available(), )学习率 2e-5 是 BERT 微调的标准起点,短信数据量小,超过 3e-5 容易震荡。epoch 设 4 是因为小数据 2 到 3 轮就收敛,第 4 轮是给 early stopping 留余量。warmup_ratio=0.1对小数据集很重要,能避免前几十步把预训练权重带偏。权重计算用总样本/(类别数*该类样本数),这是 sklearn 的 balanced 公式,比手动拍一个 5.0 靠谱。
3.3 评估指标与阈值搜索
训练时盯 f1,但 f1 默认是 0.5 阈值下的。垃圾短信场景,漏判(把垃圾判成正常)和误判(把正常判成垃圾)代价不同,通常漏判更严重,所以阈值要往下压。训练完在验证集上做一轮阈值搜索。
from sklearn.metrics import f1_score, precision_recall_curve import numpy as np def predict_probs(model, tokenizer, texts, batch_size=64): model.eval() probs = [] for i in range(0, len(texts), batch_size): batch = texts[i:i+batch_size] enc = tokenizer(batch, padding=True, truncation=True, max_length=64, return_tensors="pt").to(model.device) with torch.no_grad(): logits = model(**enc).logits probs.extend(torch.softmax(logits, dim=-1)[:, 1].cpu().numpy()) return np.array(probs) val_probs = predict_probs(model, tokenizer, val_set["message"].tolist()) best_t, best_f1 = 0.5, 0 for t in np.arange(0.1, 0.9, 0.01): pred = (val_probs >= t).astype(int) f1 = f1_score(val_set["label"], pred) if f1 > best_f1: best_f1, best_t = f1, t print(f"最佳阈值 {best_t:.2f}, F1 {best_f1:.4f}")阈值搜索范围我一般从 0.1 扫到 0.9,步长 0.01。如果最佳阈值落在 0.3 以下,说明模型对正类信心不足,要回头检查是不是正样本太少或者清洗时把强信号删了。搜完阈值别急着高兴,拿这个阈值去验证集上单独看 precision 和 recall,如果 recall 涨了 5 个点但 precision 掉了 10 个点,那这个阈值不能要。
4. 避坑与排查:那些让 F1 卡住不动的真实原因
4.1 验证集 F1 高但测试集崩了
现象:本地验证集 F1 0.96,提交到评测平台只有 0.88。原因几乎总是数据泄漏——验证集和训练集有重复或模板重叠。解决:用第 2.3 节的 GroupShuffleSplit 重新划分,并且检查训练集和验证集的模板重合率,超过 5% 就说明分组没做好。
4.2 模型把所有样本都预测成多数类
现象:训练 loss 正常下降,但验证集上正类 recall 接近 0。原因通常是类别权重没生效,或者 tokenizer 把短信截断得太狠,正类信号全在截断部分。排查:先打印训练集正负比,再检查compute_loss里权重张量是否真的传到了 GPU 上,最后看几条被截断的正样本,确认 max_length 是否设小了。
4.3 清洗后样本量骤降
现象:原始 10 万条,清洗完只剩 6 万。原因可能是去重规则太激进,或者冲突标签丢弃太多。解决:把去重前后的样本按标签分别统计,如果某一类掉得特别多,说明该类重复率高,这时候应该保留一条而不是全删。冲突标签如果超过总样本 2%,要回头查标注规范,而不是简单丢弃。
4.4 推理时显存溢出
现象:训练时 batch_size 32 没事,推理时 batch_size 64 就 OOM。原因是推理没有梯度累积,但 attention 的中间激活在长文本上更占显存。解决:推理 batch_size 降到 32,或者用torch.cuda.amp.autocast()做半精度推理。另外确认model.eval()和torch.no_grad()都加了,少一个显存就多占一倍。
4.5 阈值在验证集上最优但线上效果差
现象:验证集搜出来的阈值 0.35,上线后误判率飙升。原因是验证集和线上数据的分布不一致,比如线上短信更长、含更多营销词。解决:不要死磕一个阈值,按短信长度分桶,每个桶单独搜阈值;或者用precision_recall_curve选一个 recall 不低于 0.95 的前提下 precision 最高的点,而不是单纯最大化 F1。
5. 进阶技巧:用错误分析和模型融合再挤两个点
5.1 错误分析怎么做才有用
训练完别只看 F1,把验证集里预测错误的样本全部导出来,按「假阳性」和「假阴性」分两组,每组随机抽 30 条人工看。我一般会重点看三类:极短文本(长度小于 8)、含大量数字的文本、含「退订」「回复」等中性词的文本。看完你会发现,很多错误是标注本身模棱两可,这种样本应该从验证集里剔除,而不是硬调模型。
val_set["prob"] = val_probs val_set["pred"] = (val_probs >= best_t).astype(int) fp = val_set[(val_set["pred"] == 1) & (val_set["label"] == 0)] fn = val_set[(val_set["pred"] == 0) & (val_set["label"] == 1)] print("假阳性示例:\n", fp["message"].head(10).tolist()) print("假阴性示例:\n", fn["message"].head(10).tolist())5.2 模型融合的轻量做法
单模型卡在 0.95 左右时,可以试两个轻量融合:一是 BERT + TextCNN 概率平均,二是不同随机种子的 BERT 做投票。TextCNN 用清洗后的文本训一个 3 层卷积,训练成本极低,和 BERT 的错误样本重合度通常只有 60% 左右,融合能涨 0.5 到 1 个点。投票用 3 个种子,每个种子跑 3 epoch,取概率均值,比单模型稳。
# 概率融合示例 final_prob = 0.7 * bert_probs + 0.3 * cnn_probs final_pred = (final_prob >= best_t).astype(int) print("融合后 F1:", f1_score(val_set["label"], final_pred))权重 0.7/0.3 是经验值,如果 CNN 单独 F1 能到 0.93 以上,可以调到 0.6/0.4。融合前记得确认两个模型用的是同一份验证集划分,否则数字不可比。
5.3 一个我常犯的错
早期做这类赛题,我总想把清洗做到极致,结果把「加V」「点击链接」这些词归一化没了,模型反而学不到东西。后来养成一个习惯:每改一版清洗规则,先跑一版基线模型,对比 F1 变化,涨了才保留。清洗规则不是越多越好,能提升验证集 F1 的才是好规则。另外,阈值别在训练完立刻定,等错误分析做完、确认验证集干净了再搜,否则你搜到的只是噪声上的最优。希望帮到你。
本文还有配套的精品资源,点击获取