排查一个线上问题用户反馈某商品的自动标签系统突然开始乱打标——把“充电宝”分类成“食品”把“羽绒服”标记为“电子产品”。查日志发现新上线的NLP分类模块在处理某些特定商品描述时相似度计算完全失控。根本原因词向量模型加载时用了错误的维度配置导致“充电”和“充电宝”在向量空间里距离比“充电宝”和“移动电源”还远。今天我们就从这个问题切入聊聊NLP里最基础也最易踩坑的两个概念词向量和文本分类。词向量文字的数字替身早年做文本处理最直接的方法就是one-hot编码。每个词对应一个超长向量只有自己位置是1其他全是0。这种方法简单粗暴但问题很明显——维度爆炸且毫无语义信息。“手机”和“电话”的向量距离跟“手机”和“冰箱”一样远这显然不符合我们的认知。后来Word2Vec这类模型出来算是打开了新世界的大门。它的核心思想很直观一个词的语义可以由它周围经常出现的词来定义。看这段训练代码# 旧版one-hot做法别这样写了defone_hot_encode(word,vocab):vector[0]*len(vocab)vector[vocab[word]]1# 除了自己位置是1其他都是0returnvector# 问题50000个词就需要50000维内存吃不消且没语义# 改用词向量fromgensim.modelsimportWord2Vec# 训练自己的小模型sentences[[智能手机,电池,续航],[电池,容量,充电],[充电,器,快充]]# 领域相关语料很重要modelWord2Vec(sentences,vector_size100,window3,min_count1)# vector_size通常设100-300太小信息不够太大容易过拟合# window控制上下文范围一般3-5效果比较好vectormodel.wv[充电]print(f向量维度{vector.shape})# (100,)print(f充电和快充相似度{model.wv.similarity(充电,快充):.3f})这里有个实际调试的坑预训练模型和自定义模型的维度必须对齐。我们线上问题就是加载了300维的预训练模型但代码里写死了100维的查找逻辑导致向量切片错位语义完全乱套。# 错误示例混用不同维度的模型pretrained_modelload_pretrained(tencent_embedding.bin)# 300维custom_layernn.Linear(100,10)# 期待100维输入# 运行到这就崩了维度对不上# 正确做法统一维度或做投影转换ifpretrained_model.vector_size!100:projectionnn.Linear(pretrained_model.vector_size,100)vectorprojection(pretrained_vector)# 300维转100维现在更常用的其实是BERT这类上下文相关的词向量同一个词在不同句子里有不同表示。比如“苹果”在“苹果手机”和“苹果好吃”里向量不同这比Word2Vec的静态向量更聪明。不过对于入门来说先理解静态词向量是关键基础。文本分类从词袋到深度学习有了词向量文本分类就好办了。最早期的词袋模型Bag of Words完全忽略词序只统计词频。虽然效果有限但在某些场景下依然有用fromsklearn.feature_extraction.textimportCountVectorizer corpus[手机电池续航能力强,电池充电速度快,屏幕显示效果出色]vectorizerCountVectorizer()Xvectorizer.fit_transform(corpus)print(vectorizer.get_feature_names_out())# [充电, 出色, 屏幕, 电池, 续航, 能力, 速度, 手机, 显示, 效果]# 注意中文需要先分词这里为演示简化了词袋模型最大的问题是维度高且稀疏。后来TF-IDF做了改进降低常见词的权重但依然没解决语义问题。现在的主流做法是用词向量神经网络。一个经典的TextCNN结构几行PyTorch代码就能实现classTextCNN(nn.Module):def__init__(self,vocab_size,embed_dim,num_classes):super().__init__()self.embeddingnn.Embedding(vocab_size,embed_dim)# 用不同尺寸的卷积核捕捉不同范围的语义self.convsnn.ModuleList([nn.Conv2d(1,100,(k,embed_dim))forkin[2,3,4]])self.fcnn.Linear(300,num_classes)# 100*3300defforward(self,x):xself.embedding(x)# [batch, seq_len, embed_dim]xx.unsqueeze(1)# 加通道维 [batch, 1, seq_len, embed_dim]conv_results[]forconvinself.convs:conv_outtorch.relu(conv(x)).squeeze(3)# 去掉最后一维pool_outtorch.max_pool1d(conv_out,conv_out.size(2)).squeeze(2)conv_results.append(pool_out)xtorch.cat(conv_results,1)# 拼接不同卷积核的结果returnself.fc(x)这个结构巧妙之处在于用多个尺寸的卷积核同时捕捉2-gram、3-gram、4-gram特征。比如“电池续航”这种二元词组能被2-gram卷积核捕获“充电速度快”这种三元组能被3-gram捕获。实际部署时建议先用FastText跑个基线。它的字符级n-gram特性对拼写错误、未登录词特别鲁棒fromfasttextimportFastText modelFastText.train_supervised(inputtrain.txt,epoch25,lr1.0,wordNgrams2,# 用2-gram特征bucket2000000)# 训练快效果不错特别适合应急上线工程实践里的几个坑领域适配问题通用预训练模型在医疗、法律等专业领域效果会打折。我们之前做医疗文本分类用通用BERT准确率只有72%加入10万条医疗文献微调后到了89%。如果资源有限至少用领域语料训练词向量层。类别不平衡处理真实场景中“其他”类可能占90%。除了重采样试试Focal LossclassFocalLoss(nn.Module):def__init__(self,alpha0.25,gamma2):super().__init__()self.alphaalpha self.gammagammadefforward(self,inputs,targets):BCE_lossF.cross_entropy(inputs,targets,reductionnone)pttorch.exp(-BCE_loss)lossself.alpha*(1-pt)**self.gamma*BCE_lossreturnloss.mean()短文本分类商品标题、搜索查询这类短文本词向量效果可能不如传统方法。可以试试SVMTF-IDF组合有时候反而更稳定。在线服务优化BERT虽然效果好但推理速度慢。可以蒸馏成小模型或者用CNN/RNN结构。我们线上服务从BERT-base换成蒸馏后的3层Transformer响应时间从120ms降到28ms精度只掉了1.2个百分点。个人经验建议别一上来就怼BERT。先从FastText或TextCNN跑通流程确保数据管道没问题。很多bug不是模型问题而是数据预处理时编码不一致、分词方案冲突这类低级错误。词向量一定要可视化检查。用TSNE降维后画个散点图看看“手机”是不是靠近“电话”“苹果”是不是同时靠近“水果”和“手机”。肉眼观察比指标更直观能提前发现很多问题。文本分类的评估指标要选对。多分类别只看准确率特别是类别不平衡时。F1-score、混淆矩阵、AUC-ROC都看看。我们那个充电宝分类问题如果早看混淆矩阵就能发现模型把所有带“电”字的都归到了一类。最后模型上线后一定要加降级策略。当分类置信度低于阈值时走规则匹配或人工审核流程。我们吃过亏——模型更新后有个隐层维度没对齐线上全乱套因为没有降级策略直接导致标签系统瘫痪半小时。NLP项目30%时间在模型调优70%时间在数据清洗和错误分析。多花时间看看模型分错的样本比调超参有用得多。