做过文本类项目的人大概率都有过这样一个困惑为什么BERT都出来这么久了学校里还在讲TF-IDF、词袋模型这些“老古董”也有不少朋友从各种教程里学会了调用预训练模型但真到做业务的时候又不知道手里那几百个项目的向量到底该怎么用。这篇内容想聊聊我这些年做NLP项目时对特征提取和无监督学习的理解。从最传统的词袋模型到Word2Vec再到BERT的动态表征以及围绕这些特征展开的聚类、主题建模、降维可视化。标题虽然写着“第6-7节”但本质上这就是一条从“手写特征”走向“预训练特征”再从“特征”走向“业务洞察”的完整链路。内容不深但足够实在适合刚入门自然语言处理、或者已经在用预训练模型但想搞清楚“为什么这么用”的朋友。先说几个经常被问到的核心问题词袋模型和TF-IDF特征在BERT时代到底还有没有用Word2Vec和BERT生成的向量本质区别在哪拿到几千上万条文本怎么用无监督的办法快速看一遍数据分布BERT向量落到具体业务里常用的有哪几条路我尽量用大白话把这些讲清楚顺带给出可直接跑的代码和参数选择的思路。1. 特征提取这条线为什么从词袋一直走到BERT1.1 词袋与TF-IDF能用来做什么又卡在哪最早期的文本特征提取本质上就是“数词”。一段话进来了先分词再统计每个词出现的次数于是每个句子就变成了一个维度等于词典大小的稀疏向量。这个办法叫词袋模型Bag of Words。词袋模型的问题很明显它只看词频不看词序。“我打你”和“你打我”在词袋眼里是同一个向量。这不算致命因为它起码把文本变成了机器能算的数字。更麻烦的是高频停用词“的”“了”“在”会占据很高的权重真正有区分度的词反而被淹没。于是就有了TF-IDF。它的核心就一句话如果一个词在当前文本里出现得多但在整个语料里出现得少那它就很重要。这相当于给每个词打了两个分一个是词频分一个是“稀有度”分。稀有度怎么算呢就是总文档数除以包含该词的文档数再取对数。这个设计逻辑非常朴素但直到今天它仍然是文本匹配、关键词抽取任务里的强大基线。我自己现在还用TF-IDF的场景是给数据做冷启动标签。比如刚拿到一批“某电商平台用户评价”又没标注又没词表我就先把TF-IDF跑一遍抽出每个类簇里的高权重词快速判断数据主题。这事BERT也能干但在十几万条零标注文本上TF-IDF几秒钟就出来了BERT光过一遍得跑几分钟而且未必比TF-IDF的抽取结果更适合让人“肉眼读”。1.2 稠密词向量把“意思”塞进几百个数里词袋和TF-IDF生成的向量维度动辄上万而且还很稀疏大多数位置是零。这种向量的问题不仅在于计算慢更在于它表达不了语义关系。“苹果”和“香蕉”明明是两类水果但在TF-IDF向量空间里它们可能相隔十万八千里因为它们的共现词几乎不重叠。Word2Vec还包括GloVe、FastText解决的正是这个问题。它用了一个非常朴素的思想一个词的意思由它上下文的词决定。训练的时候模型让出现在相似语境里的词在向量空间里彼此靠近。于是几百维的稠密向量就把“语义距离”压缩进了几何距离。生活化理解Word2Vec像是给每个词做了一张“身份证照片”把它的长相固化下来。优点是快、轻、离线可用、适合中小规模数据缺点是无论“苹果”出现在“吃苹果”还是“苹果公司发布新机”它的向量都是一样的。这种“一词一义”的静态设定在遇到多义词时就会吃大亏。1.3 BERT嵌入语境感知的“动态词典”BERT在特征提取上的革命性在于它给每个词生成的向量不是固定的而是随着所在句子变化的。同一句子里“苹果”前面的词是“切开”向量主干指向水果义如果上下文是“发布会”向量主干就偏向公司义。这个能力不是魔法而是预训练阶段用海量语料和Transformer的双向注意力机制学出来的。用句人话概括词袋模型只看“有没有这个词”TF-IDF看“这个词多有代表性”Word2Vec看“这个词大概属于哪一类意思”BERT看“这个词在当前这句话里到底是什么意思”。因此BERT能做到很多传统方法做不到的事比如同义词替换检测、语义相似度匹配、跨语种对齐以及长文本的语义编码。不过也要说句公道话。BERT向量虽然语义信息丰富但它不是万能的。它的嵌入向量在不同层级上“编码”的信息是不一样的底层更偏向词法和句法特征中层偏语义高层偏上下文和任务相关特征。这就引出实操里一个很重要的问题到底把哪一层的输出当特征用这个问题后面专门讲。2. 无监督学习在文本里的三个主要打法2.1 文本聚类K-Means和它的脆弱之处拿到一堆文本最想做的第一件事就是看看这堆东西能不能分成几类。无监督聚类恰好承担这个角色。最常用的算法还是K-Means虽然它简单古老但速度极快、结果好解释。K-Means做的事情非常简单先随机指定K个中心点然后把每一条文本分给离它最近的中心再根据每个簇内文本的平均位置更新中心不断重复直到中心点不再变化。这个过程本质上是“让簇内尽可能紧凑、簇间尽可能分离”。但如果直接把TF-IDF的高维稀疏向量喂给K-Means效果往往很差。为什么因为在高维空间里向量之间的余弦距离趋同欧氏距离被大的模长主导稀疏零点也干扰簇心更新。所以实际操作中一般会先用SVD或者PCA降到低维再用K-Means。这就引出一个经验法则高维稀疏特征聚类先降维再聚类低维稠密特征如BERT向量直接聚类问题不大但建议先标准化。另一个常见的坑是K值怎么选。K-Means要求提前指定簇数但你拿到新语料往往是不知道有几类的。所以一般会用“肘部法则”画一条偏差平方和SSE随K变化的曲线找一个“拐点”。不过说实话这个拐点在真实数据上经常不明显。我的做法是用轮廓系数跑K从2到20找出轮廓系数最大的K再结合业务可解释性做人工判断。两个指标结合着看比只看一张图靠谱得多。2.2 LDA主题模型把文档集合“摊开”看聚类是把“文本”分到不同桶里主题模型则是在问这批文档里反复出现的“话题”是哪几个。LDA隐含狄利克雷分配是这里最经典的方法。理解LDA最生活化的方式是把每篇文档想象成一个“混合饮料”主题是里面的“基料”。同样的文档集合里可能有20%的内容在谈性能测试30%在谈界面交互50%在谈部署运维。LDA做的事情就是根据词共现的规律把这些潜在主题反推出来并且给出每个主题下的关键词分布。LDA有个特别好的用处当你不知道数据里到底有哪些内容区块时它能在不做任何标注的情况下给你一张“内容地图”。比如我处理过一批某社区论坛的帖子用LDA跑出10个主题很快发现其中几个主题对应的是广告帖于是直接在预处理阶段当噪声过滤掉。这种用途聚类也能做但LDA给出的“关键词描述”更直观和业务同事沟通时尤其好用。LDA的两个关键参数是主题数K和超参数α/β。K的选择跟聚类里的K一样让人头大常用办法是用困惑度perplexity或者主题一致性coherence score来调。经验上主题一致性比困惑度更贴近人的判断。α建议设小一点比如0.1让每篇文档尽量聚焦在少数几个主题上β同理设小一点让每个主题尽量集中到少数词上。这个从贝叶斯先验角度也好理解小α意味着“文档主题分布稀疏”小β意味着“主题词分布稀疏”。2.3 降维可视化t-SNE让高维向量“显形”聚类和主题模型都在做“数字层面的分组”但人类看不了几百维的向量。于是降维可视化就成了无监督学习里非常实用的附赠武器。PCA是线性降维速度最快但它只能捕捉全局的线性结构。BERT向量里丰富的非线性语义结构PCA往往压制到二维时都糊成一团。这时t-SNE效果要好得多它的设计目标是让高维空间中距离近的点在二维平面上仍然靠近距离远的点尽量分开。代价是计算量很大几万条样本还算能忍几十万条就非常吃内存了。实操中我通常在聚完类之后再做一次t-SNE把BERT句子向量降到二维用簇标签给点上色一眼就能看出聚类结果到底是“边界清晰”还是“混沌一片”。这个图用来给非技术的业务方看比单纯讲轮廓系数直观得多。需要注意一个细节t-SNE的超参数perplexity非常敏感。它大致表示“每个点考虑多少个邻居”默认30但如果样本量小建议把perplexity降到5到15之间否则图上的点会互相挤成一团。多跑几个perplexity值挑一个视觉上分离度最合适的。3. BERT特征提取实操把预训练模型变成向量生成器3.1 环境准备与模型选择做BERT特征提取我的主力工具是transformers库加PyTorch。如果只跑推理不做训练用CPU其实也勉强能行但批处理大量文本还是建议上GPU哪怕是一块入门级显卡也行。模型选哪个通用领域中文任务推荐用中文预训练模型。英文同理选对应语言的模型。任务偏短文本相似度可以把输入截断到128以内任务偏长文本考虑序列长度512或者分段提取再做聚合。这一节代码以经典中文模型为例。3.2 三种提取方式CLS、mean pooling、按token取层拿到BERT的输出最常见的特征提取方式有三种这里逐一说明差异第一种是取[CLS]位置的向量。预训练模型训练的时候[CLS]位置的输出被设计成“整句表征”。但实际使用中它的效果不一定最好因为[CLS]在被训练时更多是为“句子对关系判断”服务的而不是为通用特征服务的。所以直接拿CLS当句向量简单但可能损失细节。第二种是mean pooling也就是把所有token的输出向量按位取平均。这个做法忽略不同token的权重差异但由于它整合了全句所有词的信息在句子相似度任务上往往比CLS更稳。多数项目我默认先用mean pooling。第三种是按需取层。BERT有12层base版每层输出都可以当特征。底层偏词法中层偏语义顶层偏任务。经验上倒数第二层在通用语义任务上往往表现最好。如果你想跟倾向“更泛化”的特征可以试试把最后四层拼接或加权求和。这个方法计算量大一些但效果上限更高。我的默认配方句向量取倒数第二层的mean pooling维度768然后直接进入聚类或检索流程。如果要复用多次可以先离线把所有文本的向量存成npy文件后面随时加载不重复算。3.3 参数与细节max length、batch size、layer选择参数选择里最有讲究的反而不是模型结构而是几个工程细节。第一个是truncation策略。输入序列一旦超过max length直接截断会丢掉尾部信息。若是长文本分类我一般先做滑窗切段每个片段单独编码再取平均或最大池化当全文向量。第二个是batch size。虽然推理比训练省显存但batch size太大会直接OOM。经验值是12G显存跑base模型建议batch size在16到32之间。遇到显存不够不是因为模型太大而是你没开torch.no_grad()和model.eval()导致梯度计算占了一大半显存。推理阶段务必把这两步落实显存占用能降一半还多。第三个是向量是否标准化。BERT向量各维度方差不同直接做欧氏距离计算时某些维度会主导结果。我通常在提取之后做L2归一化这样后续无论算余弦相似度还是跑K-Means数值都更稳定。实践中这一步对聚类效果的提升非常明显。4. 把无监督学习和BERT结合起来一个可复现的聚类流程4.1 整体流程设计特征工程和无监督学习单独拆开看都简单但一个能落地的完整流程最好按下面这个顺序来先清洗文本再做文本编码得到BERT向量然后降维用PCA降到约100维接着跑K-Means聚类最后用t-SNE可视化并回看每个簇的高频词。为什么降维后再聚类这一步不要省因为BERT原始向量是768维在这么高的维度下跑K-Means距离值会被拉平簇心更新也变得不稳定。降到100维左右既保留了绝大多数语义信息又让聚类算法更稳当。这一步属于典型的技术细节但省了它后面就是玄学调参。4.2 完整代码示例下面给一套可直接跑的流程代码用英文短文本举例中文只需换成中文模型和分词方式即可。import torch import numpy as np from transformers import AutoTokenizer, AutoModel from sklearn.cluster import KMeans from sklearn.decomposition import PCA from sklearn.preprocessing import StandardScaler model_name bert-base-uncased tokenizer AutoTokenizer.from_pretrained(model_name) model AutoModel.from_pretrained(model_name) model.eval() texts [ the battery life is amazing, great camera quality for the price, the app keeps crashing on startup, interface is clean and easy to navigate, delivery was faster than expected, customer support never replied, ] def encode_texts(text_list, max_len64): encodings tokenizer( text_list, max_lengthmax_len, paddingTrue, truncationTrue, return_tensorspt, ) with torch.no_grad(): outputs model(**encodings) # 取倒数第二层的输出[batch, seq_len, hidden_size] hidden outputs.hidden_states[-2] # 用 attention mask 做 mean pooling忽略 padding 位置 mask encodings[attention_mask].unsqueeze(-1).float() masked hidden * mask pooled masked.sum(dim1) / (mask.sum(dim1) 1e-8) return pooled.numpy() # 需要设置 output_hidden_statesTrue # 因此加载模型时改为 model AutoModel.from_pretrained(model_name, output_hidden_statesTrue)上面这25行代码囊括了“加载模型、编码、取倒数第二层、masked mean pooling”这四个关键动作。其中masked mean pooling是整段代码最容易写错的地方。很多初学者直接对hidden_states取mean没考虑padding位置结果就是每条样本的“有效长度”不同短文本被大量pad位“稀释”向量整体向零向量方向偏移。加了attention_mask加权之后这个问题就消失了。这个细节我在不同项目里至少帮别人排查过三次。拿到向量后下一步是降维和聚类vec encode_texts(texts) vec StandardScaler().fit_transform(vec) pca PCA(n_components4) vec_pca pca.fit_transform(vec) kmeans KMeans(n_clusters2, random_state42, n_init10) labels kmeans.fit_predict(vec_pca) print(labels)关于StandardScaler标准化单独说一句。BERT向量的维度分布不是标准的某些维度方差大某些方差小如果不标准化聚类会被少数高方差维度主导。加了标准化这一步后续特征都保持在类似尺度上聚类结果更稳定。n_init设成10是让K-Means多试几组初始中心取最优结果避免局部最优。4.3 结果评价与调参聚类做完结果怎么评价无监督没有标准答案只能从多个侧面交叉验证。我常用三个视角第一是人工抽样看簇内文本。每个簇抽出三到五条文本看它们是否在讲同一件事。这是最朴素也最可靠的方法。第二是看轮廓系数。取值范围从-1到1越接近1表示簇内紧密、簇间分离。但要注意真实长尾数据里轮廓系数0.3都算不错别拿教科书里的0.7来对标。第三是算簇内高频词。把每个簇的文本拼起来跑TF-IDF取Top词看这些词是否符合语义直觉。这一点很好用尤其当聚类结果不好时高频词列表会显示出簇内混入了明显无关的主题。调参路径上我会依次调K值聚类、perplexity可视化、池化方式CLS还是mean pooling、层数选择倒数第一还是第二层。每次只动一个变量记录指标变化别同时改两个以上否则你根本不知道是哪个改动起了作用。5. 常见问题与排查技巧实录5.1 出问题最多的地方往往不在模型先放一张问题速查表都是实际项目里高频踩过的坑现象可能原因解决方案向量全是极其接近的值忘了关梯度推理时加torch.no_grad()和model.eval()短文本和长文本聚类混在一起masked mean pooling写错用attention_mask加权取平均聚类结果每个簇都差不多没标准化或没降维先StandardScaler再PCA降维t-SNE图糊成一团perplexity和迭代次数不合适perplexity设15~40多跑几次看直接调用CLS向量效果不稳定CLS并非通用句向量改用倒数第二层mean pooling几万条文本编码太慢没做批处理或没上GPU用batch推理至少换一块入门卡这里面最容易被忽略的还是推理模式没关。很多人以为BERT编码文本是“纯前向计算”没注意默认的PyTorch模型其实是train模式里面的Dropout还在随机遮蔽节点。这样出来的向量每次都在变同一句话编码两次结果差不少。做特征提取时先调model.eval()是比选模型更优先的事。另一个高频问题是“输出维度和预期不符”。有人拿长文本编码后发现序列长度被截断向量信息严重丢失。解决办法是滑窗切段但如果只是做聚类一个更省事的方案是先概括——模型编码摘要后的短文本几十字足够保留主干语义。不过这个方法对正式项目不推荐只适合快速看数据分布时凑合。5.2 避坑心得真正想清楚“特征给谁用”最后说点原则性的事情。特征提取不是越“先进”越好而是要看下游任务是什么。如果下游任务是精确的语义匹配、问答检索那么BERT向量完胜。如果下游任务只是给一万条无标注文本快速分类打标传统TF-IDF加K-Means可能两分钟搞定而BERT向量加聚类可能要半小时结果未必更准。如果任务涉及小样本或者类别极不均衡那先向量化再做聚类找困难样本再用这些样本做主动学习反而是一条非常高效的闭环。我做过一个典型的案例某新业务要建立一套客服对话的分类体系可用标注为零。先用BERT向量加聚类分出12个大类专家在每类里标注了50条再训练一个轻量文本分类器准确率达到可接受水平。整个过程下来真正花时间的设计不在模型而在“特征怎么提、簇怎么定义、人工怎么介入”。所以总结起来就一句话BERT给了我们一个很强的语义特征底座但无监督学习这套“先看数据、再定方案”的方法论才是让底座真正发挥价值的黏合剂。两者是配合关系不是替代关系。后面如果再往下走无非是两条路一条是把特征接到检索、匹配、RAG这类业务里另一条是做微调让特征更贴近具体任务。不管哪条路前面这“第6-7节”的地基打得牢不牢往往决定了后面能走多远。踩过几次坑之后我现在拿到任何一批新文本项目反而越来越愿意先老老实实跑一遍特征提取和无监督聚类因为这可能是整个项目里性价比最高的一步。