简介:基于NLP的敏感文本识别分类项目,主要面向计算机相关专业学生、老师及企业开发者,适用于毕业设计、课程设计或初期立项演示。项目围绕自然语言处理中的文本预处理、特征提取与分类模型展开,包含敏感不良文本分类、非法信息收集脚本及配套SQL数据库,可帮助读者理解敏感词识别、文本过滤与分类的完整流程。压缩包共6个文件,以Python源码、说明文档和SQL数据库为主,整体大小仅3.12MB,轻量易部署;其中.py文件为核心程序,.txt为运行说明文档,.sql提供初始数据库表与示例数据。目前已有132人学习下载,适合入门到进阶者参考;若基础较好,还可在此代码基础上扩展敏感词库、优化分类算法,或接入真实业务场景实现更丰富的文本审核功能。
1. 基于NLP的敏感文本识别分类项目:先定义边界,再谈模型
真正动手做过一次基于NLP的敏感文本识别分类项目之后,我最大的体会是:这不是一个纯模型问题,而是一个从数据边界一直延伸到落库审计的工程问题。尤其当你手里拿到一份 python源码+数据库.zip,别急着把里面的模型扣出来跑一遍,先把“什么算敏感、什么算误杀、拦截之后怎么追溯”定义清楚。这个项目能帮后端或算法工程师解决三件事:给敏感文本打上可解释的标签,把分类结果写进数据库形成审计链路,以及让运营在收到拦截记录时能说清楚为什么。下面按我自己搭建这套系统的顺序来写,尽量把参数和踩到的坑都留在原处。
2. 先把数据关过了:敏感文本的边界定义与脏数据清洗
2.1 敏感标签体系:先做三级分类,不做一刀切
很多新手拿到敏感文本分类,第一反应就是把 label 设计成 0/1 二值。但在真实内容场景里,“正常”和“违规”之间有一条很宽的灰色地带。比如一条“加我微信领资料”和一条“今晚一起吃饭”,前者可能是营销导流,后者如果出现在陌生人私聊里也可能擦边。直接用二分类,模型在灰色地带犯错时没有任何缓冲,只能硬拦或硬放,运营来投诉时你连解释的抓手都没有。
我一般会在项目一开始就设计三级标签:0 正常、1 疑似、2 违规。这样模型训练时不需要把模糊样本硬塞进某一类,推理时“疑似”还可以转人工复审。源码包里建议把标签定义单独放一个配置文件,别写死在 Python 代码里,方便标注团队和开发团队共同维护。
# data/label_schema.yaml - label_id: 0 name: 正常 action: 放行 - label_id: 1 name: 疑似 action: 转人工 - label_id: 2 name: 违规 action: 拦截这段配置的逻辑说明:label_id 是给模型和数据库用的数字主键,name 是给业务方看的,action 是后续消息处理服务要执行的动作。三者分离后,即使某天把“疑似”改成“待复审”,只需要改 YAML,不需要改训练代码。
参数说明:这里故意不写 confidence 阈值,因为阈值和模型强绑定,模型换一版阈值就要跟着变,放进标签配置里会让数据库记录和模型版本对不上,后面第 5 章会详细说这个坑。
2.2 敏感文本的清洗:先保留上下文,再归一化特殊格式
做敏感文本识别最忌讳把疑似词直接从样本里删掉。比如“代开发票,加微信”这条文本,如果你把“代开”删掉只留“发票”,模型就学不到“代开+发票+加微信”这个完整组合。我一般只做三类清洗:全半角统一、URL/数字占位符替换、不可见字符清理。这样既保留词语顺序,又避免模型把“123456”这种数字当敏感信号。
import re def normalize_text(text: str) -> str: # 统一全半角与大小写,避免同一个词被拆成多种写法 text = text.replace('\u3000', ' ').lower() # URL 和 IP 替换成占位符,保留位置信息 text = re.sub(r'https?://\S+|www\.\S+|\d+\.\d+\.\d+\.\d+', '[url]', text) # 连续数字替换成占位符,手机号、QQ、微信号不再干扰模型 text = re.sub(r'\d{4,}', '[num]', text) # 零宽字符与不可见字符替换成空格,防止恶意拆分词汇 text = re.sub(r'[\u200b-\u200d\ufeff]', ' ', text) # 把多余空白合并,让每条文本变成干净记录 text = re.sub(r'\s+', ' ', text).strip() return text这段代码的逻辑说明:URL 替换成[url]而不是删除,是让模型知道这里出现过外链,但不记住那串具体字符。数字同理,保留一个“这里有长数字”的信号,但不去拟合具体号码。
参数说明:\d{4,}只替换 4 位及以上数字,是为了保留“2023年”这种年份信息。如果你的业务语料里手机号是主要敏感载体,也可以改成\d{5,},自己跑一遍看哪些样本被影响,别盲目上 8 位。
清洗之后要马上落一份干净的中间语料,后续所有模型迭代都从这份文件开始:
import pandas as pd df = pd.read_csv('data/raw_sentences.csv', sep='\t', encoding='utf-8') df.columns = ['text', 'label_id'] df['clean_text'] = df['text'].map(normalize_text) df = df[df['clean_text'].str.len() >= 2] print(df['label_id'].value_counts()) df[['label_id', 'clean_text']].to_csv( 'data/processed/corpus.tsv', sep='\t', index=False, header=False, encoding='utf-8' )逻辑说明:输出时不写表头,是为了后面转 fasttext 训练格式方便。fasttext 要求每行文本前缀是__label__2,所以不要在清洗阶段就把格式写死,保留一份干净二元组后续随便转。
参数说明:str.len() >= 2过滤掉空串、单字符和纯标点。敏感文本里经常出现“V我50”这种两个字符的短句,所以不能为了省事设成 10,要根据你业务语料的长度分布去定。
2.3 样本去重:用 SimHash 去除近似重复,防止模型自我催眠
敏感文本语料有个很明显的特点:同一条违规话术会被水军换几个字反复发。“代开发票”和“代 开 发 票”清洗后未必完全一样,但语义几乎重合。如果原样送进训练集,模型会反复见到同一段局部特征,验证集分数虚高,一上真实环境就崩。
from simhash import Simhash def simhash_text(text: str) -> int: # 用 4-gram 切出文本指纹,4 个字符以下直接整体计算 tokens = [text[i:i + 4] for i in range(max(0, len(text) - 3))] return Simhash(tokens).value df['simhash'] = df['clean_text'].map(simhash_text) def drop_near_duplicates(df, distance=3): seen = [] keep = [] for idx, row in df.iterrows(): h = row['simhash'] # 只和已有样本比较,海明距离小于阈值视为重复 if all(bin(h ^ s).count('1') >= distance for s in seen): seen.append(h) keep.append(idx) return df.loc[keep] df_dedup = drop_near_duplicates(df, distance=3) print(f'去重前 {len(df)} 行 -> 去重后 {len(df_dedup)} 行')逻辑说明:SimHash 生成的是文本指纹,用 4-gram 做 token 对短文本足够。比较时算两个指纹的海明距离,距离小说明文本高度相似。它比完全去重更灵活,又比编辑距离逐字比较快得多,几万条样本跑起来不心疼。
参数说明:distance=3是我常用的起步值。设太大会把正常和敏感两种不同文本也当重复,设太小又漏掉替换了一个近义词的变体。建议先跑一遍,打印所有样本两两之间的最小距离分布,再看拐点,不要拍脑袋。
3. 特征选择:TF-IDF、词向量与敏感文本的上下文分类
3.1 先不要上来就上神经网络:TF-IDF 是敏感文本的基线
很多项目一上来就直奔 BERT,结果机器资源不够,训练一轮跑一天。敏感文本识别其实是一个典型的“关键词+上下文”问题,线性模型加 TF-IDF 往往能到 90% 以上。TF-IDF 的好处是它把“代开发票”里的“代开”和“发票”作为相邻双词特征保留下来,而不是只按单个词频打分。
from sklearn.feature_extraction.text import TfidfVectorizer corpus = df_dedup['clean_text'].tolist() vectorizer = TfidfVectorizer( min_df=2, # 至少出现 2 次,过滤纯噪音 max_df=0.95, # 95% 以上文档都出现,一般是停用词或公共词 ngram_range=(1, 2), # 保留单个词和相邻双词 sublinear_tf=True, # 用 1+log(tf) 压平长文档词频 token_pattern=r'\b\w+\b' ) X = vectorizer.fit_transform(corpus) y = df_dedup['label_id'].astype(int).values逻辑说明:ngram_range=(1, 2)是敏感短文本的关键。很多违规表述是复合动作:“加我+微信”“点击+链接”。单看“微信”在正常聊天里到处都是,但“加我微信”这个组合已经有明显的引流意图。双词组合特征在逻辑回归里解释性很强。
参数说明:max_df=0.95不能设成 1.0,否则“你好”“有的”这类句首词会混进特征,干扰后续逻辑回归权重排序。sublinear_tf=True用 log 变换压平长文本里反复出现的词频,如果你的样本都是短句,这个参数影响不大,但混合长短文本时必须开。
3.2 用逻辑回归解释每条拦截,至少能被运营理解
特征工程之后,我习惯先跑一把逻辑回归,不图它效果最好,图它能给出每个特征的真实权重。运营问“为什么拦截这条”,你能直接说出是“加我微信”和“代开”两个组合特征起了作用。敏感文本场景里,可解释性比一味追求准确率更重要。
from sklearn.linear_model import LogisticRegression from sklearn.model_selection import train_test_split X_train, X_val, y_train, y_val = train_test_split( X, y, test_size=0.2, random_state=42, stratify=y ) clf = LogisticRegression(max_iter=200, class_weight='balanced') clf.fit(X_train, y_train) feature_names = vectorizer.get_feature_names_out() coef = clf.coef_ # 多分类下每个标签打一份 top 特征 for i in range(coef.shape[0]): top_idx = coef[i].argsort()[-10:][::-1] print(f'label {i}:', [feature_names[j] for j in top_idx])逻辑说明:多分类时 coef_ 形状是 (类别数, 特征数),所以要按类别循环打印。class_weight='balanced'是应对类别不平衡的第一招,它会自动放大少数类样本的权重,代价是误杀率升高,所以这里只作为基线参考。
参数说明:max_iter=200在中小语料上通常够用。如果日志里报“ConvergenceWarning”,不要盲目调到 1000,先看特征是不是没做归一化,或者考虑换liblinear求解器,尤其适合高维稀疏特征。
3.3 用 Word2Vec 扩展变体词,顺着语义把漏词捞回来
敏感文本最大的敌人是变体。“代开发票”变“代开fp”,“联系客服”变“联 系 客 服”。TF-IDF 无法知道“客服”和“客 服”是同一个东西,而 Word2Vec 能通过上下文把意思相近的词聚在一起。我会在业务语料上训练一个 128 维的词向量模型,然后专门去找和黑名单词距离最近的候选。
from gensim.models import Word2Vec from gensim.models.word2vec import LineSentence # corpus_words.txt 每行是一条已经切好词、并且用空格连接的句子 sentences = LineSentence('data/processed/corpus_words.txt') w2v = Word2Vec( sentences, vector_size=128, window=5, min_count=5, sg=1, epochs=10, workers=8, seed=42 ) for word in ['代开', '客服', '加微']: try: print(word, w2v.wv.most_similar(word, topn=5)) except KeyError: print(word, '不在词典里')逻辑说明:sg=1表示用 skip-gram 训练。像“代开”“客服”这种短词组,skip-gram 对上下文的变化更敏感,比 CBOW 更适合挖掘变体词。min_count=5是为了过滤只出现一两次的噪音词,但敏感场景里有些长尾违规词本来就少,所以不建议设到 20。
参数说明:vector_size=128在几十万到几百万量级的业务语料里够用。几万条的语料不要直接上 300 维,维度越高需要的数据越多,训出来的向量反而不稳。epochs=10可以先跑一遍,观察 loss 是否还在明显下降,再决定要不要加。
3.4 把 TF-IDF 和 Word2Vec 拼起来:文本向量能兼容未见过的变体
Word2Vec 的词向量单独用,还需要考虑怎么把整条文本拼成一个向量。常见做法是:对每条文本分词后,查每个词的向量,再用该词的 TF-IDF 权重做加权平均。这样低频的敏感词因为 IDF 高,会在向量里被放大。
import numpy as np def text_to_avg_vector(text, w2v_model, vectorizer, idf): tokens = text.split() vectors = [] weights = [] for token in tokens: if token in w2v_model.wv: vectors.append(w2v_model.wv[token]) # 取该词的 tf-idf 权重作为加权系数 try: idx = vectorizer.vocabulary_[token] weights.append(idf[idx]) except KeyError: weights.append(1.0) if not vectors: return np.zeros(w2v_model.vector_size) weights = np.array(weights) weights = weights / (weights.sum() + 1e-8) return np.average(vectors, axis=0, weights=weights)逻辑说明:vectorizer.vocabulary_[token]能拿到词在 TF-IDF 矩阵里的列号,再用idf[idx]取逆文档频率。这个加权平均向量维度固定 128,可以直接丢进逻辑回归或者随机森林,比单纯平均所有词向量更看重敏感词。
参数说明:1e-8是防止 weights 全为 0 的兜底常数。注意np.average的axis=0表示对向量逐维加权平均,不是按行拼接,别写错。实际项目中,我会把 TF-IDF 和 Word2Vec 两个版本都跑一遍,选择验证集表现更好的那个,再决定要不要做特征拼接。
4. 模型训练与数据库落地:把预测结果和审计打通
4.1 模型选型:FastText / TextCNN / BERT 在敏感场景的实际取舍
敏感文本分类不是非得追最新模型。我用过三套方案,各有各的适用位置。做一个选型对比,方便你快速判断从哪个开始:
| 模型 | 训练速度 | 需要样本量 | 可解释性 | 适合场景 |
|---|---|---|---|---|
| FastText | 秒级到分钟级 | 几千条可跑 | 中,依赖 n-gram 哈希 | 快速基线、短文本、冷启动 |
| TextCNN | 分钟到小时级 | 几万条起步 | 低,只能看 saliency | 中等规模、需要提升精度的阶段 |
| BERT / 中文预训练 | 小时到天级 | 十万条以上 | 低,依赖 attention 可视化 | 数据充足、对复杂上下文要求高 |
我的选择原则是:先用 FastText 把数据链路跑通,如果业务上发现长句语境问题太多再升级 TextCNN,最后才考虑 BERT。敏感文本识别毕竟不是开放问答,很多违规表达就是固定话术,FastText 的 n-gram 哈希反而能扛住一些未登录词。
4.2 用 FastText 训练第一版模型:超参调到什么程度算入门
FastText 的好处是命令极短,但超参对结果影响不小。先把语料转成它要的格式:
with open('data/processed/train_ft.txt', 'w', encoding='utf-8') as f: for label, text in zip(y_train, X_train_text): f.write(f'__label__{label} {text}\n') with open('data/processed/valid_ft.txt', 'w', encoding='utf-8') as f: for label, text in zip(y_val, X_val_text): f.write(f'__label__{label} {text}\n')然后训练模型:
import fasttext model = fasttext.train_supervised( input='data/processed/train_ft.txt', lr=0.2, epoch=25, wordNgrams=2, dim=50, loss='softmax', bucket=200000, thread=8, minCount=2, ) model.save_model('models/sensitive_ft.bin') result = model.test('data/processed/valid_ft.txt') print(f'P@{result.precision:.4f} R@{result.recall:.4f}')逻辑说明:wordNgrams=2对应前面 TF-IDF 里的双词组合,FastText 会在训练时把相邻双词也哈希进特征,这一项对短文本敏感词命中帮助特别大。bucket=200000是给未登录 n-gram 预留的哈希桶数量,中文里各种变体词组合远超词表,桶太小会互相碰撞。
参数说明:lr=0.2是 fasttext 文档推荐的起点,太小收敛慢,太大容易震荡。epoch=25对几万条短文本一般够用,如果验证集 loss 还在下降就往上加。minCount=2让只出现 2 次的词也参与训练,敏感场景长尾词很重要,不建议用默认的 5。
4.3 数据库建表与落库:从预测结果到可追溯的敏感事件表
训练完模型,下一步是把预测结果和数据库打通。标题里的“+数据库”不是摆设,而是要解决审计问题:这条消息是什么时候被拦截的、模型给的分数是多少、用的哪一版模型、阈值是多少。这样才能在误杀发生后复盘。
-- data/schema.sql CREATE TABLE IF NOT EXISTS sensitive_hit ( id INTEGER PRIMARY KEY AUTOINCREMENT, msg_hash TEXT NOT NULL, content_masked TEXT, model_version TEXT NOT NULL, score REAL NOT NULL, threshold REAL NOT NULL, final_label TEXT NOT NULL, created_at TEXT DEFAULT (datetime('now', 'localtime')) ); CREATE INDEX IF NOT EXISTS idx_hit_created_at ON sensitive_hit(created_at);逻辑说明:msg_hash存文本 SHA-256,用来关联原始消息但不在数据库里明文存全量原文,隐私压力小。content_masked存脱敏后的文本,也就是把手机号、微信 ID 换成占位符的版本。threshold必须落库,否则不同模型版本的分数没有可比性。
参数说明:final_label是最终业务动作,可能是“放行”“转人工”“拦截”之一,它等于模型输出经过阈值判断后的结果。数据库里存 final_label 而不是直接存模型 label,是为了让运营系统不用关心模型内部标签定义。
落库脚本:
import sqlite3 import hashlib import json def mask_pii(text: str) -> str: return normalize_text(text) # 实际项目中再对手机号、二维码做进一步脱敏 def save_prediction(text, model_version, score, threshold, final_label): db = sqlite3.connect('data/app.db') h = hashlib.sha256(text.encode('utf-8')).hexdigest() db.execute( 'INSERT INTO sensitive_hit ' '(msg_hash, content_masked, model_version, score, threshold, final_label) ' 'VALUES (?,?,?,?,?,?)', (h, mask_pii(text), model_version, score, threshold, final_label) ) db.commit() db.close()逻辑说明:这里把清洗函数复用成脱敏函数,默认逻辑一致。model_version建议用训练时间戳加样本量,比如20250601_18000,比 v1/v2 更直观,数据库里查得到是哪一批数据训出来的。
参数说明:数据库连接每次打开关闭,在高并发场景会成瓶颈,但对这种审核落库场景够用。如果 QPS 高,把连接改成线程本地存储,或者直接换 MySQL,表结构抄上面这个就行。
5. 敏感文本分类的5个常见问题排查:现象、原因、解决办法
5.1 模型准确率 98%,运营却高呼误杀太多
现象:模型报告在验证集上准确率 98%,一上线运营每天投诉几十次,说正常用户消息被拦截。 原因:样本不平衡时,准确率被多数类主导。假设 95% 是正常,模型全部判正常就已经有 95% 准确率,但敏感文本一条没拦住,反而被少量误杀搞得口碑崩坏。 解决办法:不要只看准确率,要看每个标签的 precision 和 recall,尤其是“违规”类别的召回率。我一般还会打印混淆矩阵观察正常被误判成违规的量,如果这个量超过业务容忍线,就上调阈值,把灰色地带让给人工复审。
5.2 新增变体词一出现,老模型直接漏放明显违规
现象:前一天还能拦住的“代开 fapiao”,第二天运营给出一条“DaiKaiFP”的新写法,模型完全没反应。 原因:模型训练时没见过这种拼写变体,FastText 的字符 n-gram 对字母大小写和缩写无能为力,Word2Vec 也没法把它映射到已有词。 解决办法:把 Word2Vec 找出的近义词变体增加到数据库里的同义词表,同时在模型前面加一层轻量规则:命中同义词表就提高基础得分。更重要的是把这类新样本捞回训练集,第二天增量跑一版。
5.3 数据库导入乱码与重复数据
现象:拿到的 zip 解压后,SQLite 文件在本地打开中文全是问号,或者同一批样本被导入了两次。 原因:源数据可能是 GBK 编码导出的 CSV,被直接塞进了 UTF-8 的 SQLite;重复导入则是因为没有对 msg_hash 建唯一索引。 解决办法:不要用可视化工具硬导,写 Python 转码入库;给msg_hash加唯一约束,插入前先查一次,重复记录直接跳过。
CREATE UNIQUE INDEX IF NOT EXISTS idx_hit_hash ON sensitive_hit(msg_hash);5.4 长文本 max_len 截断把敏感句直接截没了
现象:一条 800 字的聊天记录,敏感句在最后一句,模型却判成正常。 原因:BERT 和 TextCNN 都要求固定长度输入,直接用前 500 个字符截断,后半段被截没了。 解决办法:改用“首尾拼接”策略,保留文本前 200 字和后 200 字,中间用省略标记连接。对大多数内容安全场景,敏感句要么在开头引流,要么在结尾留联系方式,首尾拼接比单纯截头更稳。
5.5 阈值拍脑袋,调一次模型运营就崩一次
现象:模型从 v3 换成 v4,分数整体下降,原来 0.8 阈值拦得住,现在拦不住,但数据库里没有存旧阈值,无法对比。 原因:v4 输出的概率分布和 v3 不一样,模型文件没有和阈值一起做版本管理。 解决办法:每训练一版模型就把阈值存进配置表,数据库表里也带上 threshold 字段。后面每次发版,回放验证集重新选阈值,不要沿用旧值。
6. 用回测脚本校准阈值,再做一次全量回归
上线前最后一步,我会把最近两周线上日志里的文本全部捞出来,用训练好的模型打一遍分,然后按不同阈值看误杀量和漏放量。这一步能直接回答“模型到底能不能用”。
import numpy as np from sklearn.metrics import precision_score, recall_score def pick_threshold(y_true, y_score, min_recall=0.95): best_t = 0.5 best_precision = 0.0 for t in np.arange(0.5, 0.99, 0.01): pred = (y_score >= t).astype(int) r = recall_score(y_true, pred, zero_division=0) if r < min_recall: continue p = precision_score(y_true, pred, zero_division=0) if p > best_precision: best_precision = p best_t = t return best_t这段代码的逻辑说明:遍历 0.5 到 0.99 的阈值,先保证敏感类召回率不低于 95%,再选择精确率最高的点。这个参数直接决定了数据库里 threshold 字段的值,需要和模型版本一起写进配置表。参数说明里有一点很关键:min_recall=0.95不是每个业务都适用,做金融违规识别我甚至会要求 0.99,做营销内容则可以放宽到 0.9,你必须在精确率和误杀率之间找平衡点。
回测跑完,我还会做一件看起来很笨的事:把验证集里所有误杀样本单独导出成一个 HTML 文件,人工扫一遍。第一次做时发现大量正常文本里含有“加V”这个词,但其实是正规博主在留联系方式,业务上不算违规。这种判断只有人肉看才能确认,模型和指标都看不出来。后来我把这类样本加进训练集,并更新标签定义,模型才真正收敛。
我的习惯是每次发版前都跑一遍这套回测,把阈值校准脚本放进源码目录,和训练脚本放在同级,这样数据库里的分数才不会变成一堆无法解释的黑匣子。敏感文本识别项目最难的不是模型,而是让你自己和运营都相信这个结果可解释、可追溯。希望这些踩坑经验能帮你少走点弯路。
本文还有配套的精品资源,点击获取