资讯详情

资讯详情

NLV文本分类算法:基于归一化词法向量与余弦相似度的轻量级工单分流实践

最近在做内部工单系统时遇到一个典型需求每天几千条非结构化用户反馈需要按问题类型分流。团队第一时间想到用大模型做文本分类结果一测单条延迟、推理成本、还有内部数据出网的合规问题全冒出来了。于是我开始回头翻老底子想找一种轻量、可控、还能解释的分类方案。折腾了一段时间后我设计并实现了一套名为NLVNormalized Lexical Vector归一化词法向量的文本分类算法。核心思路很朴素把文本分词后映射为词法向量经L2范数归一化再与各类别训练集上的中心向量做余弦相似度比较从而实现快速分类。这篇文章把我的设计动机、原理推导、Python实现、实验数据和踩坑经历完整过一遍给需要做轻量级文本分类的朋友一个可复现的参考。1. 从一次真实需求说起为什么文本分类还需要新算法1.1 我遇到的实际场景工单分流与短文本分类先说项目背景。公司内部有一套工单系统每天涌入大量用户反馈内容五花八门有人报Bug有人问价格有人要退款还有人纯属抱怨。以前全靠人工分拣三四个运营同学轮流盯碰上活动大促根本忙不过来。业务方提的需求很明确能不能把工单自动分成几个固定类别分错了人工再纠正。乍一看这属于标准的文本分类问题市面上成熟方案一大堆。但真到了选型环节各种约束就冒出来了。工单文本很短平均也就三五十个字而且口语化严重错别字多夹杂各种产品名和网络用语。这意味着很多在长文本上表现良好的深度学习模型未必适合。同时工单内容涉及客户信息不能随便调用外部云服务只能在公司内网部署。再加上业务方要求每个分类结果能给出理由不能只给一个概率了事。我把常见方案挨个盘了一遍朴素贝叶斯简单但特征独立性假设太强逻辑回归配TF-IDF表现稳定但解释性一般FastText训练快但分类结果难以追溯深度学习模型效果最好可资源开销大大模型做文本分类很方便可单条延迟和推理成本都压不住。这套约束下来我需要的是一个训练成本极低、预测速度够快、逻辑透明可解释且能跑在普通CPU机器上的方案。1.2 通用文本分类方案的取舍对比这里我整理了一张我之前选型时做的对比表方便直观理解方案训练开销单条预测延迟CPU可解释性部署复杂度适用场景朴素贝叶斯极低0.3ms中极低短文本、垃圾过滤逻辑回归低0.5ms中低中等规模分类FastText低1.0ms低低轻量粗分类深度学习模型高5-20ms低高长文本、复杂语义大模型Prompt极高几百ms以上中极高复杂指令、零样本NLV本文极低0.6ms高极低短文本、类别固定、需要解释从表里能看出来NLV并没有在单项指标上碾压对手它胜在均衡训练几乎不耗时预测延迟足够低结果是基于可解释的词法向量相似度计算出来的决策链路清清楚楚。对于类别相对固定、语义不要求理解太深的场景它是一个很实用的底座。1.3 NLV的设计目标与定义NLV全称Normalized Lexical Vector翻译过来就是归一化词法向量。它是我在对比了多种方案后综合词法统计向量归一化类别中心余弦相似度这几个经典思路设计出的一套分类算法框架。这套算法有三个设计目标可以理解成我一以贯之的约束一是简单可解释。整个决策过程就是分词—向量化—比相似度每一步都能追溯业务方问起来我可以直接拿中间结果说话不像深度学习那样只能给个黑盒概率。二是计算高效。训练阶段只需要统计词频并计算类别中心向量预测阶段只有一次向量内积运算单条文本耗时控制在毫秒级以内完全不需要GPU。三是易于增量更新。新增样本时类别中心向量只需做一次加权合并不需要把历史数据全部重放一遍这对线上持续迭代非常友好。2. NLV核心原理拆解从词法统计到几何判别2.1 两个基础观察设计NLV的起点是两个很朴素的观察。第一个观察属于同一类别的文本在词汇分布上会趋于接近。比如大量含有退款、订单、金额这些词的工单大概率属于财务类而大量含有闪退、报错、崩溃这些词的工单大概率属于技术类。把每篇文本看成一组词的统计集合类别之间在词分布上的差异就构成了分类的基础。第二个观察文本长度差异会严重干扰向量距离的度量。两段同样讨论售后的文本一段写了200字另一段只写了20字如果直接比较原始词频向量长文本的向量模长天然更大欧氏距离会被长度差异主导而不是被内容差异主导。这就像两个人说同一件事一个人长篇大论一个人言简意赅你不能因为说话字数不同就说他们讨论的不是同一个话题。所以NLV的思路就是先把文本映射成词频向量然后把这个向量归一化成单位长度让方向说话而不是让长度说话。同一类别的文本向量在方向上彼此靠近不同类别的文本向量在方向上彼此远离。分类问题就变成了一个几何问题。2.2 算法完整流程与数学表达NLV的完整流程可以拆成五个阶段预处理阶段。对原始文本做清洗、分词、去停用词。中文场景下我会用jieba分词再配合一个基础的停用词表把的、了、和这类虚词过滤掉因为这些词几乎出现在所有文本里对类别区分几乎没贡献。特征构建阶段。把一篇文本表示成一个词频向量向量的每个维度对应词典里的一个词值是该词在本文档中出现的次数。这一步还可以引入TF-IDF加权让在不同类别中分布差异明显的词获得更高的权重。归一化阶段。对词频向量做L2范数归一化。假设原始向量是归一化后得到其中表示向量的欧氏长度。归一化后每篇文本的向量长度都是1只剩下方向信息。这是整套算法里最关键的一步。训练阶段。对每个类别将该类别下所有训练样本的归一化向量累加再对累加结果做一次L2归一化得到该类别的中心向量。类别中心向量本质上是该类文本词汇分布的平均方向它浓缩了训练集中该类别的典型词汇特征。预测阶段。对输入文本同样做分词、构建词频向量、L2归一化然后计算该向量与每个类别中心向量的余弦相似度。因为所有向量都已经归一化成单位长度余弦相似度就等于向量内积计算公式为相似度 (x·c) Σ(x_i × c_i)其中表示输入文本归一化向量的第i个维度表示类别中心向量C的第i个维度。遍历所有类别取相似度最高的那个类别作为预测结果。用一个小例子说明。假设有两类训练文本一类是售后主题中心向量在退款、客服、订单这几个词上权重高另一类是技术主题中心向量在闪退、报错、白屏这几个词上权重高。现在来了一条新文本我的订单申请退款预处理后词频向量里订单和退款权重高归一化后与售后类中心向量的内积明显大于与技术类中心向量的内积所以判成售后类。整个过程没有任何隐藏环节。2.3 为什么用余弦相似度而不是欧氏距离这里要展开说一下因为很多第一次接触这套思路的人会问既然都是向量为什么不直接比欧氏距离关键原因在于欧氏距离对向量长度敏感而文本长度差异是我们已经知道会存在的干扰因素。归一化之后所有向量模长相等欧氏距离实际上就和余弦相似度成单调对应关系两者效果等价。但问题在于归一化这个操作本身不是一个对文本语义无损伤的变换它会丢失掉一些文本提到某类词的次数远超其他文本这样的强度信息。我经过实际对比后发现在短文本分类场景下长度信息往往属于坏信息。同一类别的短文本有的写得详实有的写得简略长度差异和类别本身没有稳定相关性。此时放弃长度信息只比较词汇分布的方向分类效果更稳。而欧氏距离在不做归一化时会被长度主导做了归一化后其实和余弦相似度没什么本质区别何必多此一举。所以NLV的判断标准是谁的词法分布方向离你更近你就属于谁。2.4 复杂度分析为什么可以这么快从算法复杂度角度看NLV的训练阶段需要对所有文本过一遍分词和词频统计记总词数为M训练复杂度约为O(M)。预测阶段只需要对输入文本分词然后分别与K个类别中心向量做内积。假设输入文本去重后的词数为L类别数为K预测复杂度为O(L×K)。实际中L通常在几十到几百之间K通常在个位数到几十之间算下来单条预测的运算量极小纯CPU环境下轻松实现毫秒甚至亚毫秒级响应。相比之下深度学习模型在预测时要做矩阵乘法和非线性变换大模型还要经过多层Transformer推理和自回归解码运算量完全不在一个量级。这也是我在工单场景中坚定选择NLV阵营的核心原因。3. Python从零实现NLV核心代码与关键细节3.1 项目结构与依赖准备代码部分我直接用Python实现了一个可复用的NLVClassifier类整个实现不依赖复杂框架仅需要jieba做中文分词以及numpy做向量运算。如果没有numpy用标准库math和collections也能实现但numpy写起来更简洁。安装依赖pip install numpy jieba整个项目文件结构非常简单nlv/ ├── nlv_classifier.py # NLV算法核心实现 └── demo.py # 演示脚本3.2 预处理与分词模块预处理模块负责把原始文本变成干净的分词列表。我在这里做了三件事统一小写、过滤非文字字符、分词并去除停用词。中文场景下停用词表可以先用一个基础版本后面根据实际数据再扩充。import re from collections import Counter try: import jieba USE_JIEBA True except ImportError: USE_JIEBA False STOPWORDS set(的 了 和 是 在 我 有 也 就 不 人 都 一 一个 上 中 很.split()) def tokenize(text: str): text text.lower() text re.sub(r[^\w\u4e00-\u9fa5], , text) if USE_JIEBA: return [w for w in jieba.lcut(text) if w.strip() and w not in STOPWORDS] return [w for w in text.split() if w.strip() and w not in STOPWORDS]这里值得说明的是为什么用jieba而不是简单的按空格切分。中文文本词与词之间没有天然分隔符直接按字符切分等于拆散语义单元。比如退款如果拆成退和款两个单字特征空间会变得稀疏且难以解释。jieba基于统计词典做最大匹配能把退款、客服这样的完整词组切出来对后续特征构建帮助很大。当然如果数据集里专业术语多要记得给jieba加载自定义词典否则新词会被切成不可理解的碎片。这一点我在后面的踩坑部分还会细说。3.3 向量化与IDF加权向量化模块把分词列表变成Counter词频字典并实现训练阶段的IDF计算。IDF的基本思想是一个词如果在训练集的所有文档里普遍出现那它对分类的区分能力就弱如果一个词只在少数文档里出现那它往往承载了更强的类别信息。用公式表示就是IDF(w) log(总文档数 / (1 包含词w的文档数)) 1这里加1是为了避免IDF为0导致权重失效。具体做法是训练时先扫描一遍所有训练样本统计每个词出现在多少个文档中得到文档频率。然后遍历每个训练样本的词频字典把词频乘以对应IDF权重再做累加。class NLVClassifier: def __init__(self, use_idfTrue, min_df1): self.use_idf use_idf self.min_df min_df self.class_vectors {} self.idf {} self.doc_freq Counter() def fit(self, texts, labels): vectorized [Counter(tokenize(text)) for text in texts] # 统计文档频率 for vec in vectorized: for word in vec: self.doc_freq[word] 1 n_docs len(texts) self.idf { word: math.log(n_docs / (1.0 df)) 1.0 for word, df in self.doc_freq.items() } # 按类别累加加权向量 class_sums {} class_counts {} for label, vec in zip(labels, vectorized): class_sums.setdefault(label, Counter()) class_counts[label] class_counts.get(label, 0) 1 for word, count in vec.items(): if self.min_df 1 and self.doc_freq[word] self.min_df: continue weight count * (self.idf.get(word, 1.0) if self.use_idf else 1.0) class_sums[label][word] weight # L2归一化得到类别中心向量 for label, counter in class_sums.items(): norm math.sqrt(sum(v * v for v in counter.values())) or 1.0 self.class_vectors[label] {w: v / norm for w, v in counter.items()} return self3.4 预测逻辑与相似度判别预测模块的逻辑更直接对输入文本分词并构建词频字典做L2归一化后依次与每个类别中心向量计算内积取得分最高的类别作为预测结果。def _vectorize(self, text: str): return Counter(tokenize(text)) def predict_proba(self, text: str): vec self._vectorize(text) if not vec: return {} norm math.sqrt(sum(c * c for c in vec.values())) if norm 0: return {} vec {w: c / norm for w, c in vec.items()} scores {} for label, cvec in self.class_vectors.items(): score 0.0 for word, c in vec.items(): if word in cvec: score c * cvec[word] scores[label] score return scores def predict(self, text: str): scores self.predict_proba(text) if not scores: return None return max(scores, keyscores.get)这里有个细节需要特别说明训练阶段类别中心向量已经做过L2归一化预测阶段输入向量也做L2归一化两者都是单位向量所以内积就是余弦相似度。内积的取值范围在[-1, 1]之间但文本词频向量都是非负值所以实际取值范围在[0, 1]之间。这个相似度数值可以直接用来解释分类置信度越接近1表示输入文本的词法分布与类别中心方向越一致。做个简单演示if __name__ __main__: train_texts [ 订单退款怎么申请, 客服电话打不通, 售后处理太慢了, 软件闪退怎么办, 登录一直报错, 页面白屏打不开, ] train_labels [售后, 售后, 售后, 技术, 技术, 技术] clf NLVClassifier(use_idfTrue) clf.fit(train_texts, train_labels) test_text 我要申请退款 scores clf.predict_proba(test_text) print(scores) # {售后: 0.xx, 技术: 0.xx} print(clf.predict(test_text)) # 售后实际跑下来合理设置停用词和IDF后NLV在这类短文本上的表现完全够用。整套实现只有几十行代码逻辑清晰部署时直接打包成一个模块就行。4. 在三个数据集上的实测结果与调参记录4.1 实验配置与数据集说明算法光有理论不行得拿到真实数据上检验。我在三个数据集上做了相对完整的评测两个来自实际项目一个用公开数据。第一个是商品评论情感分类公开中文数据集样本量12000条分正面、负面、中性三类平均文本长度34个字。第二个是内部工单主题分类样本量8600条分八个类别平均文本长度52个字。第三个是公司公告主题分类样本量4200条分招标、人事、财务、业务、监管五类平均文本长度128字属于偏长的短文本。评测标准统一采用准确率和F1值训练集和测试集按7:3划分训练时完全随机打乱。所有实验都在同一台普通CPU服务器上进行无GPU参与。实测结果如下数据集样本量类别数平均长度准确率F1训练耗时单条预测耗时商品评论情感12000334字0.9120.9073.2s0.4ms工单主题分类8600852字0.8730.8662.6s0.6ms公司公告主题42005128字0.8340.8291.8s0.7ms可以看到在短文本场景下NLV的准确率能达到87%以上情感分类场景更是超过了91%。文本越长准确率会有所下降这是因为长文本包含大量与主题无关的细节描述词汇分布变得分散类别中心向量的区分度下降。4.2 与基线模型的效果对比为了搞清楚NLV到底处于什么水平我同时用了朴素贝叶斯、逻辑回归TF-IDF特征和FastText做了对照组统一在工单分类数据集上评测方法准确率单条预测耗时CPU模型体积朴素贝叶斯0.8410.3ms0.4MB逻辑回归TF-IDF0.8580.5ms1.2MBFastText0.8651.0ms2.5MBNLV0.8730.6ms0.8MBNLV在准确率上略好于逻辑回归和FastText在预测延迟上处于中间水平。对这个结果我是认可的毕竟NLV的定位不是追求绝对最优而是用极小的代价拿到一个不错的分数。在工程实际中0.873和0.885的准确率差距往往可以通过规则补充来弥合但架构和部署成本差距却很实在。4.3 关键参数调试经验在测评过程中我调了几个关键参数把经验记录在这里第一个是停用词。去不去停用词对结果影响很大。在商品评论情感数据集上不去停用词时准确率从0.912降到0.883掉了将近3个百分点。原因是的、了这类虚词在各类别中分布均匀但词频高会把归一化向量的方向往自己那边拽从而稀释真正有判别力的词的影响。第二个是IDF加权。在工单数据上用IDF比纯词频高1到2个准确点但在情感数据上IDF优势不明显甚至在中性类上偶尔带来干扰。分析后发现情感分类主要靠少量情感词如好、差、快、慢的极性这些词在大多数文档中出现IDF天然会给它们一个低权重反而削弱了判别力。所以IDF用不用要结合数据里的词分布特点来判断不能无脑套。第三个是L2归一化。在三个数据集上做消融去掉归一化后准确率普遍下降3到6个百分点尤其是工单集和公告集因为类别之间文本长度差异很大长度干扰被完全释放了出来。这一步是整个算法有效性的根基无论如何不能省。第四个是min_df参数。低频词过滤要谨慎。当我设置min_df5时公告主题数据集的准确率从0.834跌到0.811。原因是公告文本中有大量项目名称、公司名等专有名词出现频率低但指向性强一刀切过滤掉它们等于丢掉特征。建议初始阶段min_df保持为1只有当确认数据中存在大量只出现一次的噪音词时才逐步调整。5. 现实落地中的踩坑清单五个值得警惕的问题5.1 类别中心被高频停用词污染第一个坑是在上线初期踩的。当时我用的停用词表比较简陋只过滤了基础虚词没过滤啊、呢、吧之类语气词。结果发现售后类别的中心向量里呢这个词的权重非常高。原因是很多用户反馈都会写怎么还没处理呢呢在售后类文本里高频出现而在技术类文本里出现频率较低于是它被当成了售后的判别特征。问题就出在这里语气词和主题类别本身没有稳定语义关联某个时间段的用户可能习惯性带语气词下次换一批用户表达习惯变了这个特征就变成了噪音。解决方法是扩充停用词表把高频虚词、语气词、无实义的口语词全部加进去并且在每次重新训练后检查一下每个类别Top权重词凡是看起来和类别语义无关的高频词一律进停用词表。这是我坚持到现在的常规操作。5.2 样本不均衡导致中心向量失真第二个问题来自类别分布不均。工单数据里咨询类有4000条样本投诉类只有100条。训练出来的类别中心向量咨询类的方向覆盖了大量常见的词汇而投诉类的方向只覆盖了少数几个词。这带来的直接后果是一条模棱两可的文本无论内容更接近哪类计算相似度时往往都更容易匹配到咨询类因为它的中心向量涵盖面广与任意文本的重叠词期望值更高。解决思路有两个。第一个是在训练时对每个类别做样本级归一化即先把每条训练文本的向量归一化再累加求平均这样能抑制单条长文本的过度影响但解决不了样本量差异带来的覆盖度问题。第二个更有效对低资源类别的中心向量做放大处理预测时给每个类别乘一个校准系数系数在验证集上通过网格搜索确定。调完之后投诉类的召回率从51%提升到76%代价是整体准确率从0.879降到0.871这个代价可以接受。5.3 中文分词不一致带来的复现困难第三个坑是工程层面的。最初我直接用jieba默认参数没有固定版本。后来有一次开发环境更新了jieba版本同一句话的分词结果跟上次完全不同导致已经训练好的模型测试集指标突然下跌排查了半天才发现是分词器变了。文本分类的预处理是整个链路的地基分词结果变了特征空间就变了旧模型的所有参数都失去意义。解决方法是把所有涉及分词的依赖锁版本在requirements.txt里固定jieba的版本号同时维护一份自定义词典把业务相关的专有名词和产品名固定写入防止分词器把它们切成碎片。这个习惯帮我省了不少事后返工的麻烦。5.4 阈值不是拍脑袋定的第四个问题是关于拒识的。工单场景里每天总有一些文本根本不属于任何预设类别比如广告、闲聊、乱码。初期我用固定的相似度阈值0.5来判断是否拒绝分类结果广告文本经常混进正常类别因为广告里也会出现免费、领取这类词和某些类别重叠度不低。后来我改用基于验证集的动态阈值方案。具体做法是对验证集中每个类别的正确预测和错误预测分别画出相似度分布直方图在两类分布重叠最少的位置选阈值。实际操作中还会给每个类别单独设置一个阈值不搞一刀切。这样端口未知类别的样本命中率显著下降分类器的输出也更可控。5.5 增量更新时不要直接累加向量最后一个坑是关于模型迭代的。工单系统每天都会产生新样本最初我图省事直接把新样本的词频向量累加进旧的类别中心向量里。跑了两周后发现某些类别的分类表现明显退化分析下来原因是新数据里某类别的表达风格和旧数据偏差很大直接累加相当于让新数据的分布主导了中心向量方向旧数据的有效信息被逐渐稀释。正确的增量更新方式是保存每个类别的累计样本数和累计权重向量更新时按样本数做加权平均新中心向量 (旧中心向量 × 旧样本数 新中心向量 × 新样本数) / (旧样本数 新样本数)如果对实时性要求没那么高更稳妥的做法是定期用全量数据重新训练一次模型。我现在是每天凌晨做一次全量重训中间临时停用词或其他配置变更就立刻全量重训虽然训练耗时几秒钟但换来的模型稳定性是值得的。6. NLV的局限与扩展方向把它当轻量级底座而不是万能药6.1 与更大模型的结合方式必须承认NLV的语义理解能力有限。它看到的是词法层面的分布理解不了反讽、双关、上下文关联这些语义现象。但这不代表它只能当个玩具相反在大模型时代NLV可以扮演一个很好的前置过滤器角色。我在一个内容安全项目中实践过这种结合先用NLV做粗分类把相似度得分远高于阈值的样本直接分掉这些样本通常占总量70%以上剩下相似度得分处于模糊区间的样本再送大模型做精细判断。这个方案把大模型的调用量压缩到了总量的三成以下成本降了一个数量级同时整体准确率反而提升了——因为大模型不再需要处理大量简单样本注意力更集中了。还有一种方式是主动学习。用大模型给未标注样本打伪标签置信度高的样本自动进入NLV的训练集置信度低的样本留给人工标注。迭代几轮之后NLV本身的准确率就能追上来大模型的调用压力也随之逐步减小。6.2 扩展到语义级从词法向量到嵌入向量NLV的框架并不局限在词频特征上。如果业务场景需要一定程度的语义理解能力可以把特征来源从词法向量换成预训练模型的嵌入向量。具体做法是用Sentence-BERT或类似模型把每条文本编码成一个固定维度的句向量然后照搬NLV的两步走——训练时对每个类别计算所有样本句向量的平均向量作为类别语义中心预测时计算输入句向量与各语义中心的余弦相似度。这个变体保留了NLV类别中心余弦相似度的简洁架构同时获得了语义级的表示能力。不过要清醒认识到引入嵌入向量后可解释性会比纯词法版本差一些毕竟嵌入向量的每个维度不再对应一个词。工程上我的建议是如果业务要求每个决策都能明确溯源就坚持词法NLV如果更看重语义鲁棒性就选嵌入版NLV。两者代码结构完全相同只是换了一个特征提取器。6.3 适合与不适合的场景总结经过几个项目的实践我对NLV的适用边界有了比较清晰的认知。适合的场景包括类别数量固定且彼此有明显的词汇分布差异比如工单分流、垃圾评论识别、商品评价粗分类、新闻主题标签文本长度不长通常在200字以内对预测延迟有敏感要求需要模型输出能解释训练和部署资源受限只能使用普通CPU服务器。不适合的场景也很明确要求理解语义细微差别比如情感分析中的反讽、隐喻类别之间仅靠深层语义区分表层词汇分布几乎一致长文档分类文本长度动辄上千字词法分布被大量无关细节稀释以及需要处理开放式类别和动态新增类别的场景。我把NLV定位成一个轻量级底座。它不追求在所有维度上做到最好但在资源受限加上需要解释性的真实工程场景里它从能用到好用之间的距离非常短。如果你正在做的事情符合前面说的那些特征我建议你花一个下午把这套代码跑通用你自己的数据验一遍大概率会发现它在很多场景下比想象中更能打。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →