ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

垃圾短信识别实战:中文文本分类从数据清洗到BERT微调

垃圾短信识别实战:中文文本分类从数据清洗到BERT微调 简介这份资源面向计算机相关专业的本科生与NLP入门学习者围绕中文文本分类实战展开以垃圾短信识别为具体场景帮助读者理解从数据到模型落地的完整流程。包内共8个文件包含3个txt数据与停用词文件、2个pkl模型文件、1个py训练脚本、1个jpg流程图及1个md说明文档压缩包约38.02MB结构紧凑、便于快速上手。数据采用「标签\t文本」格式正样本为垃圾短信、负样本为正常短信环境依赖Python3.6、jieba与Scikit-learn核心算法为SVM支持向量机也可按需替换为其他分类模型运行train.py即可完成训练。已有4450人学习下载适合作为毕业设计参考或课程实践素材。读者可借此掌握中文分词、TF-IDF特征提取、模型训练与评估的完整链路并参考流程图与说明文档梳理项目结构为后续调参与模型替换提供可复用的代码基础。1. 垃圾短信识别一个被低估的中文文本分类实战入口垃圾短信识别看起来像是自然语言处理里最没有想象力的任务——二分类标签干净数据量不大论文里早被刷到 99% 的准确率。但如果你真拿一份真实短信数据跑一遍就会发现99% 是幻觉80% 到 95% 之间的每一步都藏着中文文本分类的典型坑样本极度不均衡、对抗样本“加V”“薇”“威信”、短信长度极短导致上下文缺失、分词器把“退订”切成“退/订”之后语义漂移。本科毕业设计选这个题目好处是数据获取门槛低、任务定义清晰、工程链路完整坏处是如果只跑一个 TF-IDF 朴素贝叶斯就交差答辩时很难撑住“自然语言处理”这四个字。这篇笔记按一条可复现的路径走从数据清洗、分词、特征工程到传统模型基线再到轻量级预训练模型微调最后落到部署和踩坑记录。适合正在做 NLP 方向毕设、需要一条完整中文文本分类链路的同学也适合想快速验证短信场景分类方案的工程师。2. 数据从哪来、怎么洗垃圾短信识别的第一道分水岭2.1 公开数据集与自建数据的取舍中文垃圾短信没有像 IMDB、THUCNews 那样“开箱即用”的标准数据集。常见做法有三条路一是用 SMS Spam Collection 的中文翻译版或类似公开语料优点是标注干净缺点是语言风格偏书面和真实短信差距大二是从公开举报平台、论坛帖子中爬取优点是真实缺点是噪声极高、标注成本大三是用 THUCNews 或 ChnSentiCorp 做迁移把“广告类”新闻标题当作正样本缺点是领域漂移严重。我一般会建议毕设场景下走“公开语料 少量自建”的混合路线先用公开中文短信语料做基线再手工标注 300 到 500 条真实风格短信做验证集。这样既保证训练量又能暴露模型在真实分布上的问题。数据格式统一成两列label0 正常1 垃圾和text原始短信内容存为 UTF-8 编码的 CSV。import pandas as pd # 读取原始数据假设已有两份来源 df_public pd.read_csv(sms_public.csv, encodingutf-8) df_manual pd.read_csv(sms_manual.csv, encodingutf-8) # 统一列名 df_public.columns [label, text] df_manual.columns [label, text] # 合并去重 df pd.concat([df_public, df_manual], ignore_indexTrue) df df.drop_duplicates(subset[text]).reset_index(dropTrue) # 检查标签分布 print(df[label].value_counts()) print(总样本数:, len(df))这段代码的核心逻辑是合并多源数据并去重。drop_duplicates的subset参数只对text去重避免同一条短信因来源不同被重复计入。标签分布打印出来之后如果正负比超过 1:5就要在训练时考虑类别权重或过采样否则模型会倾向于全部预测为多数类。2.2 中文短信清洗的四个必做动作短信文本的噪声和新闻、评论完全不同。常见噪声包括运营商前缀“【XX银行】”、退订后缀“回T退订”、URL、电话号码、表情符号、全角半角混用。清洗不是越干净越好过度清洗会丢掉“退订”“回复”这类对分类有强指示性的词。我一般按四步走第一步去掉首尾空白和不可见字符第二步把连续数字、URL、电话号码替换为占位符而不是直接删除因为“有 URL”本身就是垃圾短信的强特征第三步统一全角转半角第四步保留中文、英文、数字和常用标点去掉其余符号。import re def clean_sms(text): # 去除首尾空白和不可见字符 text text.strip().replace(\u200b, ).replace(\xa0, ) # URL 替换为占位符 text re.sub(rhttps?://\S|www\.\S, __URL__ , text) # 电话号码替换 text re.sub(r1[3-9]\d{9}, __PHONE__ , text) # 连续数字如验证码、金额替换 text re.sub(r\d{4,}, __NUM__ , text) # 全角转半角 result [] for ch in text: code ord(ch) if code 0x3000: code 32 elif 0xFF01 code 0xFF5E: code - 0xFEE0 result.append(chr(code)) text .join(result) # 只保留中文、英文、数字、占位符和常用标点 text re.sub(r[^\u4e00-\u9fa5a-zA-Z0-9_\s。、], , text) # 合并多余空格 text re.sub(r\s, , text).strip() return text df[clean_text] df[text].apply(clean_sms) print(df[[text, clean_text]].head(5))参数说明\d{4,}只替换 4 位以上连续数字避免把“2 折”“3 天”这类短数字误伤。全角转半角的范围0xFF01到0xFF5E覆盖了常见全角字符。最后一步的正则里保留了_因为占位符__URL__需要它。清洗后建议人工抽看 20 条确认没有把关键信息洗掉。2.3 分词器的选择与一个容易翻车的细节中文文本分类绕不开分词。常见选择是 jieba、pkuseg、HanLP。毕设场景下 jieba 足够速度快、文档多、可自定义词典。但有一个容易翻车的细节短信里的“加V”“薇信”“威信”这类变体通用分词器会把它们切成单字或错误组合导致特征失效。解决办法是维护一个领域词典把已知变体统一映射为标准词。import jieba # 自定义词典把变体统一 jieba.load_userdict(sms_dict.txt) # 每行格式词 词频 词性 def tokenize(text): # 精确模式分词 tokens jieba.lcut(text, cut_allFalse) # 去掉空白 token tokens [t.strip() for t in tokens if t.strip()] return tokens df[tokens] df[clean_text].apply(tokenize) print(df[tokens].head(3))sms_dict.txt里我一般会放这些词加V、薇信、威信、扣扣、Q号、代购、刷单、返利、退订、回T。词频给一个较大值比如 10000保证分词器优先按整词切分。这一步做完后面 TF-IDF 或词向量才能拿到有意义的特征。如果跳过这步模型会把“加V”拆成“加”和“V”而“V”在正常短信里也可能出现特征区分度直接下降。3. 从 TF-IDF 到 BERT三条基线怎么选、怎么跑3.1 TF-IDF 线性模型的基线价值很多人觉得 TF-IDF 过时了但在短信场景下它仍然是最值得先跑的基线。原因有三短信短关键词权重高数据量通常不大深度模型容易过拟合训练和推理速度快方便快速迭代特征。我一般用 TF-IDF 加逻辑回归或线性 SVM先拿到一个可解释的基线分数。from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.linear_model import LogisticRegression from sklearn.model_selection import train_test_split from sklearn.metrics import classification_report # 用分词后的 token 拼接成字符串保留词序信息 df[token_text] df[tokens].apply(lambda x: .join(x)) X_train, X_test, y_train, y_test train_test_split( df[token_text], df[label], test_size0.2, random_state42, stratifydf[label] ) # TF-IDF 向量化 vectorizer TfidfVectorizer( max_features5000, # 控制特征维度 ngram_range(1, 2), # 一元和二元词组 min_df2, # 至少出现 2 次才保留 max_df0.95 # 出现在 95% 以上文档的词丢弃 ) X_train_tfidf vectorizer.fit_transform(X_train) X_test_tfidf vectorizer.transform(X_test) # 逻辑回归带类别权重 clf LogisticRegression(max_iter1000, class_weightbalanced) clf.fit(X_train_tfidf, y_train) y_pred clf.predict(X_test_tfidf) print(classification_report(y_test, y_pred, digits4))参数说明ngram_range(1,2)让模型能捕捉“加V”“退订回T”这类二元组合对短信场景很关键。min_df2过滤掉只出现一次的词减少噪声。class_weightbalanced自动按类别频率调整权重解决样本不均衡。跑完看classification_report重点看少数类垃圾短信的 recall如果 recall 低于 0.85说明特征还不够需要回到分词和词典那一步补。3.2 词向量 神经网络的中间路线如果 TF-IDF 基线已经跑到 90% 以上下一步可以上词向量加神经网络。常见做法是用 Word2Vec 或 FastText 在短信语料上训练词向量再接一个 TextCNN 或 BiLSTM。这条路线比 TF-IDF 多捕捉一点语序信息但提升通常有限适合作为论文里的对比实验。import numpy as np from gensim.models import Word2Vec from tensorflow.keras.preprocessing.text import Tokenizer from tensorflow.keras.preprocessing.sequence import pad_sequences from tensorflow.keras.models import Sequential from tensorflow.keras.layers import Embedding, Conv1D, GlobalMaxPooling1D, Dense, Dropout # 训练词向量 w2v_model Word2Vec( sentencesdf[tokens].tolist(), vector_size100, # 词向量维度 window5, # 上下文窗口 min_count2, # 忽略低频词 workers4, epochs10 ) # 构建 Keras Tokenizer tokenizer Tokenizer(num_words8000, oov_tokenOOV) tokenizer.fit_on_texts(df[tokens]) sequences tokenizer.texts_to_sequences(df[tokens]) max_len 60 # 短信一般不超过 60 个 token X_pad pad_sequences(sequences, maxlenmax_len, paddingpost, truncatingpost) # 构建嵌入矩阵 word_index tokenizer.word_index embedding_matrix np.zeros((8000, 100)) for word, idx in word_index.items(): if idx 8000 and word in w2v_model.wv: embedding_matrix[idx] w2v_model.wv[word] # TextCNN model Sequential([ Embedding(8000, 100, weights[embedding_matrix], trainableFalse), Conv1D(128, 3, activationrelu), GlobalMaxPooling1D(), Dropout(0.3), Dense(64, activationrelu), Dense(1, activationsigmoid) ]) model.compile(optimizeradam, lossbinary_crossentropy, metrics[accuracy]) model.summary()参数说明vector_size100是短信场景的常用值太大容易过拟合。max_len60覆盖绝大多数短信长度超过的截断不足的补零。trainableFalse表示冻结词向量如果数据量超过 1 万条可以改成True做微调。TextCNN 的卷积核大小 3 对应捕捉三元词组对“加V信”“刷单返利”这类模式有效。训练时记得用class_weight或过采样否则模型会偏向多数类。3.3 BERT 微调什么时候值得上、怎么省显存BERT 在中文文本分类上确实强但短信场景下不一定需要。判断标准很简单如果 TF-IDF 基线已经到 95% 以上BERT 提升空间有限如果基线卡在 90% 以下且数据量超过 5000 条可以试 BERT。毕设场景下显存通常有限建议用bert-base-chinese加冻结底层、只微调最后几层或者用更小的RoBERTa-wwm-ext的蒸馏版本。from transformers import BertTokenizer, BertForSequenceClassification, Trainer, TrainingArguments import torch from torch.utils.data import Dataset # 加载 tokenizer 和模型 tokenizer BertTokenizer.from_pretrained(bert-base-chinese) model BertForSequenceClassification.from_pretrained(bert-base-chinese, num_labels2) class SMSDataset(Dataset): def __init__(self, texts, labels, tokenizer, max_len64): self.texts texts self.labels labels self.tokenizer tokenizer self.max_len max_len def __len__(self): return len(self.texts) def __getitem__(self, idx): encoding self.tokenizer( self.texts[idx], max_lengthself.max_len, paddingmax_length, truncationTrue, return_tensorspt ) return { input_ids: encoding[input_ids].squeeze(), attention_mask: encoding[attention_mask].squeeze(), labels: torch.tensor(self.labels[idx], dtypetorch.long) } # 冻结底层参数只训练分类头和最后两层 for name, param in model.named_parameters(): if classifier not in name and layer.11 not in name: param.requires_grad False train_dataset SMSDataset(X_train.tolist(), y_train.tolist(), tokenizer) test_dataset SMSDataset(X_test.tolist(), y_test.tolist(), tokenizer) training_args TrainingArguments( output_dir./bert_sms, num_train_epochs3, per_device_train_batch_size16, per_device_eval_batch_size32, learning_rate2e-5, warmup_ratio0.1, logging_steps50, evaluation_strategyepoch, save_strategyepoch, load_best_model_at_endTrue ) trainer Trainer( modelmodel, argstraining_args, train_datasettrain_dataset, eval_datasettest_dataset ) trainer.train()参数说明max_len64对短信足够能省显存。冻结底层只训练最后两层和分类头显存占用能降一半左右适合单卡 8G 的场景。learning_rate2e-5是 BERT 微调的常用值太大容易破坏预训练权重。warmup_ratio0.1让学习率在前 10% 步数内线性上升稳定训练。如果显存还是不够把per_device_train_batch_size降到 8并开启梯度累积。4. 避坑与排查垃圾短信识别里最容易翻车的五件事4.1 准确率 99% 但线上全错样本泄漏与分布偏移现象离线评估准确率 99%但拿真实短信测试时大量误判。原因通常是训练集和测试集来自同一批数据且做了随机划分导致同一条短信的变体同时出现在两边。更隐蔽的是如果数据里“退订”只出现在垃圾短信中模型会直接学到“有退订就是垃圾”而真实场景里正常短信也可能有退订。解决按时间或来源划分训练测试集确保分布不同检查特征重要性去掉强泄漏词用真实场景数据做独立验证集。4.2 分词把关键变体切碎加V、薇信、威信全部漏掉现象模型对“加V”“薇信”这类变体识别率极低。原因通用分词器没有这些词切成单字后特征权重被稀释。解决维护领域词典把变体统一映射为标准词或者在清洗阶段直接用正则替换把“加V”“加薇”“加威信”统一替换为“WECHAT”占位符。后者更彻底但会丢失变体之间的细微差异适合对可解释性要求不高的场景。4.3 类别不均衡导致 recall 崩盘全预测为正常也有 90% 准确率现象模型准确率 90%但垃圾短信 recall 只有 0.3。原因垃圾短信占比低模型学到“全预测为正常”就能拿高准确率。解决训练时加class_weightbalanced或对少数类过采样评估时看 F1 和 recall不看准确率如果过采样注意在划分训练测试集之后再过采样避免样本泄漏。4.4 BERT 微调显存爆炸batch size 设 16 直接 OOM现象单卡 8G 跑bert-base-chinesebatch size 16 直接显存不足。原因BERT 参数量 1.1 亿加上优化器状态和梯度显存占用远超预期。解决冻结底层参数只训练分类头和最后两层把max_len从 128 降到 64用梯度累积模拟大 batch或者换用albert-base-chinese这类轻量模型。如果还不行用混合精度训练显存能再降 30% 左右。4.5 上线后模型退化新变体层出不穷没有后悔药现象模型上线一个月后准确率下降因为黑产不断更换话术比如从“加V”变成“加薇”“加威”“V”。原因模型训练数据是静态的无法覆盖新变体。解决建立定期更新机制每周或每月用新数据重新训练在推理层加规则兜底把已知变体用正则统一替换监控线上预测分布如果垃圾短信比例突然下降大概率是变体绕过模型需要人工介入补充样本。5. 把模型跑成服务一个可复现的推理接口与迭代习惯训练完模型只是开始毕设答辩时如果能演示一个可调用的接口说服力会强很多。我一般用 FastAPI 包一层加载训练好的 TF-IDF 逻辑回归或 BERT 模型对外提供/predict接口。下面是一个最小可运行版本用 TF-IDF 模型演示BERT 版本把加载和推理部分替换即可。from fastapi import FastAPI from pydantic import BaseModel import joblib import re app FastAPI() # 加载训练好的 vectorizer 和模型 vectorizer joblib.load(tfidf_vectorizer.pkl) clf joblib.load(lr_model.pkl) class SMSRequest(BaseModel): text: str def clean_sms(text): text text.strip().replace(\u200b, ).replace(\xa0, ) text re.sub(rhttps?://\S|www\.\S, __URL__ , text) text re.sub(r1[3-9]\d{9}, __PHONE__ , text) text re.sub(r\d{4,}, __NUM__ , text) result [] for ch in text: code ord(ch) if code 0x3000: code 32 elif 0xFF01 code 0xFF5E: code - 0xFEE0 result.append(chr(code)) text .join(result) text re.sub(r[^\u4e00-\u9fa5a-zA-Z0-9_\s。、], , text) text re.sub(r\s, , text).strip() return text app.post(/predict) def predict(req: SMSRequest): cleaned clean_sms(req.text) # 注意这里需要和训练时一样先分词再拼接 import jieba tokens jieba.lcut(cleaned) token_text .join(tokens) vec vectorizer.transform([token_text]) prob clf.predict_proba(vec)[0][1] label 垃圾短信 if prob 0.5 else 正常短信 return {label: label, probability: round(float(prob), 4)}这段代码的关键点是推理时的清洗和分词必须和训练时完全一致否则特征分布对不上预测结果会漂移。predict_proba返回的概率可以用来做阈值调整比如把阈值从 0.5 调到 0.6牺牲一点 recall 换更高的 precision具体看业务场景。启动命令是uvicorn main:app --host 0.0.0.0 --port 8000然后用 curl 或 Postman 测试。迭代习惯上我一般会做三件事第一每次重新训练后用同一个独立验证集跑一遍记录 F1 和 recall 的变化第二把线上误判的样本定期导出人工标注后加入训练集第三维护一个变体词典把新发现的“加V”变体及时加进去。这三件事坚持做模型才不会上线即巅峰、随后一路退化。希望帮到你。本文还有配套的精品资源点击获取
返回列表