ARTICLE DETAIL

资讯详情

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

基于NLP的敏感文本识别分类实战:从数据清洗到模型落地

基于NLP的敏感文本识别分类实战:从数据清洗到模型落地 简介基于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(rhttps?://\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, encodingutf-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, indexFalse, headerFalse, encodingutf-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, distance3): 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, distance3) print(f去重前 {len(df)} 行 - 去重后 {len(df_dedup)} 行)逻辑说明SimHash 生成的是文本指纹用 4-gram 做 token 对短文本足够。比较时算两个指纹的海明距离距离小说明文本高度相似。它比完全去重更灵活又比编辑距离逐字比较快得多几万条样本跑起来不心疼。参数说明distance3是我常用的起步值。设太大会把正常和敏感两种不同文本也当重复设太小又漏掉替换了一个近义词的变体。建议先跑一遍打印所有样本两两之间的最小距离分布再看拐点不要拍脑袋。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_df2, # 至少出现 2 次过滤纯噪音 max_df0.95, # 95% 以上文档都出现一般是停用词或公共词 ngram_range(1, 2), # 保留单个词和相邻双词 sublinear_tfTrue, # 用 1log(tf) 压平长文档词频 token_patternr\b\w\b ) X vectorizer.fit_transform(corpus) y df_dedup[label_id].astype(int).values逻辑说明ngram_range(1, 2)是敏感短文本的关键。很多违规表述是复合动作“加我微信”“点击链接”。单看“微信”在正常聊天里到处都是但“加我微信”这个组合已经有明显的引流意图。双词组合特征在逻辑回归里解释性很强。参数说明max_df0.95不能设成 1.0否则“你好”“有的”这类句首词会混进特征干扰后续逻辑回归权重排序。sublinear_tfTrue用 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_size0.2, random_state42, stratifyy ) clf LogisticRegression(max_iter200, class_weightbalanced) 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(flabel {i}:, [feature_names[j] for j in top_idx])逻辑说明多分类时 coef_ 形状是 (类别数, 特征数)所以要按类别循环打印。class_weightbalanced是应对类别不平衡的第一招它会自动放大少数类样本的权重代价是误杀率升高所以这里只作为基线参考。参数说明max_iter200在中小语料上通常够用。如果日志里报“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_size128, window5, min_count5, sg1, epochs10, workers8, seed42 ) for word in [代开, 客服, 加微]: try: print(word, w2v.wv.most_similar(word, topn5)) except KeyError: print(word, 不在词典里)逻辑说明sg1表示用 skip-gram 训练。像“代开”“客服”这种短词组skip-gram 对上下文的变化更敏感比 CBOW 更适合挖掘变体词。min_count5是为了过滤只出现一两次的噪音词但敏感场景里有些长尾违规词本来就少所以不建议设到 20。参数说明vector_size128在几十万到几百万量级的业务语料里够用。几万条的语料不要直接上 300 维维度越高需要的数据越多训出来的向量反而不稳。epochs10可以先跑一遍观察 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, axis0, weightsweights)逻辑说明vectorizer.vocabulary_[token]能拿到词在 TF-IDF 矩阵里的列号再用idf[idx]取逆文档频率。这个加权平均向量维度固定 128可以直接丢进逻辑回归或者随机森林比单纯平均所有词向量更看重敏感词。参数说明1e-8是防止 weights 全为 0 的兜底常数。注意np.average的axis0表示对向量逐维加权平均不是按行拼接别写错。实际项目中我会把 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, encodingutf-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, encodingutf-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( inputdata/processed/train_ft.txt, lr0.2, epoch25, wordNgrams2, dim50, losssoftmax, bucket200000, thread8, minCount2, ) model.save_model(models/sensitive_ft.bin) result model.test(data/processed/valid_ft.txt) print(fP{result.precision:.4f} R{result.recall:.4f})逻辑说明wordNgrams2对应前面 TF-IDF 里的双词组合FastText 会在训练时把相邻双词也哈希进特征这一项对短文本敏感词命中帮助特别大。bucket200000是给未登录 n-gram 预留的哈希桶数量中文里各种变体词组合远超词表桶太小会互相碰撞。参数说明lr0.2是 fasttext 文档推荐的起点太小收敛慢太大容易震荡。epoch25对几万条短文本一般够用如果验证集 loss 还在下降就往上加。minCount2让只出现 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_recall0.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_division0) if r min_recall: continue p precision_score(y_true, pred, zero_division0) if p best_precision: best_precision p best_t t return best_t这段代码的逻辑说明遍历 0.5 到 0.99 的阈值先保证敏感类召回率不低于 95%再选择精确率最高的点。这个参数直接决定了数据库里 threshold 字段的值需要和模型版本一起写进配置表。参数说明里有一点很关键min_recall0.95不是每个业务都适用做金融违规识别我甚至会要求 0.99做营销内容则可以放宽到 0.9你必须在精确率和误杀率之间找平衡点。回测跑完我还会做一件看起来很笨的事把验证集里所有误杀样本单独导出成一个 HTML 文件人工扫一遍。第一次做时发现大量正常文本里含有“加V”这个词但其实是正规博主在留联系方式业务上不算违规。这种判断只有人肉看才能确认模型和指标都看不出来。后来我把这类样本加进训练集并更新标签定义模型才真正收敛。我的习惯是每次发版前都跑一遍这套回测把阈值校准脚本放进源码目录和训练脚本放在同级这样数据库里的分数才不会变成一堆无法解释的黑匣子。敏感文本识别项目最难的不是模型而是让你自己和运营都相信这个结果可解释、可追溯。希望这些踩坑经验能帮你少走点弯路。本文还有配套的精品资源点击获取
返回列表