共现矩阵原理与实战:从NLP词向量到推荐系统应用
发布时间:2026/9/30 1:39:27来源:尧图网络
1. 共现矩阵到底是个什么东西1.1 先从一句话说起谁能和谁“出现在一起”共现矩阵这个名字乍一听挺唬人拆开看就三个字共、现、矩阵。“共”是共同“现”是出现合起来就是“谁和谁共同出现过”然后把这些共同出现的次数填进一张方方正正的表格里这就是共现矩阵。用大白话打个比方你参加了很多场聚会每一场聚会里都有几个人。你拿个小本子记下“张三和李四在同一场聚会出现过几次”“张三和王五在同一场聚会出现过几次”。记多了以后你就能发现张三到底跟谁关系近、跟谁压根玩不到一块。共现矩阵干的事本质上就是这个只不过把“聚会”换成了“句子”或者“窗口”把“人”换成了“词”或者把“人”换成“商品”“用户”这类你想分析的东西。在自然语言处理、推荐系统、关键词挖掘、文本相似度计算这些场景里共现矩阵是很多机器学习模型最底层的那块砖。很多今天看起来很高级的算法比如GloVe词向量、某些点击率预估模型的特征工程、甚至知识图谱里的实体关系抽取起步阶段往往都要先算一张共现矩阵出来。这篇我想把这东西彻底讲明白不绕弯子从构建原理讲到代码实现再讲到实际应用和避坑技巧。无论你是刚接触NLP的新手还是已经在做推荐系统、做搜索优化的从业者看完多少都能用得上。1.2 一个能看懂的手工构建例子先别急着上代码咱们拿手搓一个共现矩阵把概念彻底搞清楚。假设有两句话“我喜欢吃苹果”“他喜欢吃香蕉”我们关心的词有我、喜欢、吃、苹果、他、香蕉。现在定义“共同出现”的标准如果一个词和另一个词在同一个句子里出现就算共现一次。那么统计出来“喜欢”出现了2次它跟“我”“吃”“苹果”在同一句跟“他”“香蕉”也在同一句所以“喜欢”跟其他每个词都共现过一次。“我”只在第一句出现所以“我”跟“喜欢”“吃”“苹果”共现跟“他”“香蕉”不共现。把词表顺序定为“我、喜欢、吃、苹果、他、香蕉”做一个6乘6的矩阵行列都是这些词第i行第j列就填“第i个词和第j个词共同出现的次数”。对角线就是词自身跟自身的共现次数有的做法填词频有的做法不太关心对角线看你后续拿矩阵做什么用。这个例子粒度很粗用的是“整句作为窗口”所以矩阵长得很稀疏。真实场景里不会用整句做窗口而是定义一个滑动窗口比如窗口大小设为5意思就是“当前词左右各5个词以内出现的词就算跟我共现”。窗口大小的选择非常讲究后面专门细说。这里顺带说一下共现矩阵并不只有“词-词”这一种形态。用户-物品共现矩阵也很常见比如用户A买过商品X那A和X就算共现矩阵填1在协同过滤推荐算法里这种矩阵再乘以评分就成了最经典的“用户-物品评分矩阵”。原理一脉相承只是角色换了一下。2. 构建共现矩阵的关键参数与选型2.1 窗口大小兄弟情义几米内算数刚才说了如果拿整句话当窗口窗口太大共现关系就会变得特别“泛”几乎每个词都跟其他词共现矩阵里非零值变多数据倒是密了但词与词之间的关联反而不那么精确了。反过来如果窗口太小比如窗口设为1那就只有紧挨着的两个词才算共现关系又太局促远一点的语义关联全都抓不到。这里有一个很直观的经验在大多数中文和英文的NLP任务里对称窗口大小取2到10之间比较常见。经典论文里GloVe默认用的是10Word2Vec的CBOW和Skip-gram很多实现里窗口取5。我做中文文本处理的时候一般先取5试跑一轮看一下下游任务效果再微调。窗口还有对称和非对称之分。对称窗口就是只看左右各N个词不管方向非对称窗口会区分“左边第2个词”和“右边第2个词”把方向信息也当作特征。像GloVe的原始实现统计的是“词j出现在词i的上下文窗口中的次数”窗口本身是左右对称的但最后拟合出的向量其实隐式带了方向语义。对于大多数工程场景对称窗口就够用了非对称窗口会翻倍增加特征维度收益大多数时候不匹配成本。2.2 权重设计共现不是“0或1”那么简单另一个容易被忽略的是共现次数的加权方式。最简单的做法是窗口内出现了就算1次累计次数。但实际做项目时你会发现紧挨着出现的两个词比如“北京”“大学”和隔着老远出现的两个词比如“北京”和“樱花”出现在同一个长句里语义关联强度显然是不一样的。处理方式之一是距离衰减。常见做法是给共现次数除以两个词在窗口内的距离或者用1/d作为权重d是两个词的位置间隔。这样一来离得近的词贡献更大离得远的贡献小一些矩阵的数值分布也更能反映真实的语义亲疏关系。还有一种做法是加“窗口滑动次数”的归一化。比如有的语料里某些高频词“的”“了”“是”几乎跟所有词都共现它们的次数天然就大如果不做归一化后续算相似度时这些功能词会霸榜把真正的关键词挤下去。常见对策是把矩阵的每一行除以该行的总频次转成条件概率的形式再做后续计算。2.3 要不要去掉停用词做中文共现矩阵时有个绕不开的预处理问题要不要先做分词去不去停用词。分词是必须的中文词和词之间没有空格不切分就没法讨论“词”的共现关系。分完词以后建议对停用词做一定处理。所谓停用词就是“的、了、在、是、我”这类几乎不携带语义信息的词。这些词在语料里出现频率极高会跟所有词都共现拉高矩阵密度却对语义区分毫无帮助。行业里的常见做法是构建一个停用词表在构建矩阵时直接跳过这些词不让它们进入词表。词表也不用做得特别大几百个常用停用词就够把数量级压下来矩阵稀疏度也能明显改善。不过这里有个反直觉的地方有些任务里“否定词”特别重要比如“不好”“不错”“不感兴趣”这里的“不”如果被当停用词一律滤掉语义信息就丢了。所以停用词表不能闭眼抄网上的现成表格还是要看一眼自己语料里的高频词人工判断哪些是真停用词哪些其实是业务关键词。3. 共现矩阵的实际应用场景3.1 NLP词向量从统计到语义的桥梁共现矩阵在NLP里最经典的应用就是生成词向量。早期有一个方法叫“共现矩阵SVD降维”现在看起来有点老派但思路非常清晰先把超大语料所有词的共现矩阵算出来矩阵的每一行就是这个词的“邻居画像”再用SVD把它从几万维降到几十维甚至几百维得到的向量就能用来算词与词之间的余弦相似度“国王-男人女人”这类经典类比任务用这种方法也能做出相当不错的结果。到了GloVe这里思路就更进了一步。GloVe不直接拿共现矩阵当输入而是把共现次数作为回归目标训练两个词向量让它们的点积去拟合共现次数的对数。换句话说共现矩阵从“结果”变成了“训练数据”。这也是为什么很多人说GloVe的底层还是共现统计只是包装成了神经网络的训练目标。如果你只是要在工程里快速得到词向量我建议直接拿训练好的现成词向量用没必要自己从零训。但当你需要针对特定领域比如医疗、法律、工业日志做语义分析通用词向量效果不灵的时候自己构建共现矩阵然后训练一个GloVe模型往往比硬套通用模型靠谱得多。3.2 推荐系统把用户和物品放进同一个矩阵推荐系统里也有一张非常著名的共现矩阵叫作“用户-物品共现矩阵”行是用户列是物品第u行第v列的值表示用户u对物品v有过行为点击、购买、收藏。协同过滤算法里最基础的ItemCF物品协同过滤就是从这个矩阵出发先算出物品与物品之间的共现关系再做推荐。举个例子用户A买了手机、耳机、充电器用户B买了手机、耳机。那“手机”和“耳机”在多个用户的行为里共同出现过这两个物品之间就有一条很强的共现边。当用户C买了手机但没买耳机时系统就会根据“手机-耳机”的共现关系把耳机推荐给C。这里有个细节和NLP里用窗口计算共现不太一样推荐系统里的共现矩阵通常不需要设窗口而是直接按用户划分。同一个用户买过哪几个物品这些物品之间就算共现。不过也要注意活跃用户的影响一个用户狂买几百个商品会让几百个商品之间全部共现一次噪音很大。常规做法是做“惩罚”比如加一个用户活跃度权重让购买多、行为膨胀的用户贡献的价值按比例衰减。3.3 知识图谱与图挖掘共现即是关系还有一个容易忽略的方向是知识图谱和关系抽取。很多实体关系背后的直接证据就是两个实体在同一篇文章里频繁共现。比如“杭州”和“阿里巴巴”经常出现在同一篇新闻里哪怕它们之间没有显式的结构化关系共现次数本身就已经暗示了某种联系。实际做知识图谱构建的时候共现矩阵可以拿来生成候选关系对。我先在语料里抽取所有候选实体统计两两共现次数再设定一个最低频次阈值比如共现次数超过50次才进入候选关系池。之后再用规则或者模型判断具体关系类型。这一步看起来简单但在冷启动阶段比任何复杂模型都管用因为它的计算成本低、可解释性强而且能快速过滤掉大量明显无关的实体对。图挖掘领域更是把共现矩阵当成邻接矩阵直接用。词当节点、共现次数当边权重就可以在图上跑社区发现算法、PageRank、随机游走等等。我做过一个关键词推荐项目就是先算关键词共现矩阵再转成无向图在图上跑Label Propagation算法把孤立的关键词聚类成主题簇最后的推荐效果比纯统计的方法好了不少。4. 完整实操用Python从零构建共现矩阵4.1 准备语料与预处理说了这么多理论下面上一份可以直接复制的实操流程。我拿中文新闻语料做例子你换英文语料或者商品行为日志思路完全一样。先准备语料最简单的形式是一个文本文件每行一篇文档。为了演示我写了一个小语料张江高科技园区发布最新人工智能产业政策人工智能技术正在改变传统制造业的生产方式制造业转型升级需要更多人工智能人才张江园区的科技企业聚焦人工智能芯片研发第一步是分词和过滤。中文分词我习惯用jieba简单且够用。分完词之后把停用词、单字词、纯数字之类的都过滤掉。这一步直接影响后面矩阵的质量多花点时间不亏。import jieba import re stopwords set() # 实际使用时从文件加载停用词表 for word in [的, 了, 正在, 需要, 更多, 发布, 聚焦, 是, 在, 与, 和]: stopwords.add(word) docs [] with open(corpus.txt, r, encodingutf-8) as f: for line in f: text line.strip() if not text: continue words jieba.lcut(text) # 过滤停用词和纯数字/单字 filtered [ w for w in words if w not in stopwords and len(w) 1 and not re.fullmatch(r\d, w) ] docs.append(filtered)这一步的细节在于停用词表的选取我上面只是随手写了几条演示生产环境里需要根据语料的词频统计来定制。实际项目里我一般先跑一遍全量分词统计词频把TOP频率的词拉出来人工过一遍。4.2 主流程代码滑动窗口与共现统计接下来是把语料里的每一个窗口切出来累计共现次数。我设定窗口大小为5也就是目标词左右各取5个词。遍历每个词作为中心词统计它跟窗口内其他词的共现关系。import numpy as np from collections import Counter, defaultdict window_size 5 co_count defaultdict(Counter) for words in docs: n len(words) for i, target in enumerate(words): start max(0, i - window_size) end min(n, i window_size 1) for j in range(start, end): if i j: continue context_word words[j] # 距离越近权重越高这里用1/d加权 distance abs(i - j) weight 1.0 / distance co_count[target][context_word] weight这段代码里需要注意的有几点。第一窗口右边界用的是i window_size 1因为Python的切片区间左闭右开如果不加1最后一个词会漏掉。第二我用了defaultdict(Counter)来存共现统计外层key是目标词内层Counter的key是上下文词value是加权后的共现次数。等所有语料跑完再把Counter转成矩阵。第三权重用的是1.0 / distance这跟“距离越近关系越强”的直觉一致。如果你想跟GloVe默认行为保持一致可以把权重全部设为1只看共现次数忽略距离。接下来把词表列出来并构建矩阵vocab sorted(co_count.keys()) vocab_index {word: i for i, word in enumerate(vocab)} size len(vocab) matrix np.zeros((size, size)) for target, ctx_counter in co_count.items(): row vocab_index[target] for ctx_word, cnt in ctx_counter.items(): col vocab_index[ctx_word] matrix[row, col] cnt到这里矩阵就构建完成了。你可以检查一下矩阵的稀疏度sparsity (matrix 0).sum() / (matrix.shape[0] * matrix.shape[1]) print(f矩阵维度: {matrix.shape}, 稀疏度: {sparsity:.2%})如果稀疏度太高比如99%以上不用慌这是常态。真实语料里词表动辄几万而每个词真正共现的上下文词通常只有几百个所以稀疏度必然很夸张。稀疏本身不是问题问题在于后续怎么处理。4.3 把共现矩阵变成可用的“向量”矩阵算出来以后直接拿来做相似度计算还不合适因为原始频次受高频词影响太大。我一般会先做两步PPMI变换再SVD降维得到稠密的词向量。PPMI正逐点互信息公式是PPMI(w, c) max(log(P(w,c) / (P(w) * P(c))), 0)直观理解如果词w和上下文词c一起出现的概率远大于两者独立出现概率的乘积说明这两个词之间有“超预期”的关联PPMI值就大如果它们只是泛泛地碰巧一起出现PPMI会趋近0。取max(0, x)是为了把负值截断掉负PPMI通常被认为是不稳定、不可靠的。实现也不复杂# 转成概率 total matrix.sum() row_sum matrix.sum(axis1, keepdimsTrue) col_sum matrix.sum(axis0, keepdimsTrue) p_wc matrix / total p_w row_sum / total p_c col_sum / total # PMI with np.errstate(divideignore, invalidignore): pmi np.log(p_wc / (p_w * p_c)) # PPMI ppmi np.maximum(pmi, 0) ppmi[np.isinf(ppmi)] 0 np.nan_to_num(ppmi, copyFalse)做完PPMI之后再用SVD降维from sklearn.decomposition import TruncatedSVD svd TruncatedSVD(n_components128, random_state42) word_vectors svd.fit_transform(ppmi)降维之后的向量每一行对应一个词维度128。算两个词的相似度就用余弦相似度from numpy.linalg import norm def cosine_similarity(v1, v2): return np.dot(v1, v2) / (norm(v1) * norm(v2)) # 拿人工智能和制造业试试 score cosine_similarity( word_vectors[vocab_index[人工智能]], word_vectors[vocab_index[制造业]] ) print(相似度:, score)这段流程跑完你就从零实现了一个mini版的“词向量训练”。效果当然比不上大规模预训练模型但胜在可控、可解释特别适合特定领域的小规模文本分析。5. 从共现矩阵到更高级的方法5.1 PMI和PPMI为什么要做这一步刚才代码里用到了PPMI这里再把原理讲透一点。原始共现矩阵最大的问题是“频次不等价于关联强度”。一个词如果本身频率就高比如“中国”这种词在新闻语料里跟谁都共现它的频次自然高但这不代表它跟那些词关系更紧密。PMI做的事情是“扣除随机共现的期望”。公式里P(w) * P(c)表示的是“如果w和c完全独立它们一起出现的概率应该是多大”。如果实际共现概率远大于这个值说明它们之间的关联是显著的非随机事件。为什么要截断成PPMI因为负值的PMI在统计上非常不稳定。语料里两个词只共现了一次计算出的负PMI可能纯粹是抽样噪音。深度学习模型里负样本的信息也不是都有效反而会干扰训练稳定性。所以常规做法是截断为0只保留正的、显著的共现信号。在很多NLP任务里词-词PPMI矩阵本身就可以作为特征矩阵直接喂给分类器效果不输简单的词向量。5.2 SVD降维和GloVe的关系PPMI矩阵的维度是词表大小乘以词表大小这个维度通常是个天文数字直接喂给模型是不现实的。SVD降维把几万维压缩到几百维得到的就是稠密向量。这个流程也叫“基于矩阵分解的词向量”Hellinger-PCA、PPMI-SVD、SGNS这些方法的共同起点都是共现矩阵。GloVe则是另一种思路。它不去做矩阵分解而是把共现矩阵里的每一个非零值当作训练样本用两个向量的点积去拟合共现次数的对数。这样做的优势是能够充分利用共现次数中的全局统计信息而不是像Word2Vec那样只通过局部窗口采样。实际上GloVe的作者在论文里专门比较过“共现矩阵SVD”和GloVe的效果结论是GloVe在很多语义任务上更稳定但GloVe核心的输入仍然是共现矩阵。所以我一直觉得共现矩阵不是一个“过时的技术”而是很多经典模型的根。你现在再去看一些基于BERT的语义相似度任务底层也经常需要先算共现特征来辅助训练。5.3 稀疏矩阵的工程处理实际项目里词表几万个矩阵维度几万乘几万光存储就很占内存。全量用稠密numpy数组可能一次就把内存吃光。生产环境下我一般用scipy.sparse来保存共现矩阵或者直接用gensim的Corpus格式。from scipy.sparse import csr_matrix # 把numpy矩阵转成稀疏格式 sparse_matrix csr_matrix(ppmi)用稀疏矩阵做后续的SVD也方便sklearn的TruncatedSVD天然支持稀疏输入。另外还有一个技巧构建词表的时候过滤掉低频词比如出现次数少于5次的词直接不要。这样词表规模能压掉一大半矩阵复杂度也能从O(n^2)降到可控范围而且低频词本身统计量不足留下也是噪音。6. 常见问题与排查技巧实录6.1 共现矩阵太稀疏怎么办前面说过稀疏是常态但如果稀疏度接近100%矩阵基本就没法用了。常见原因有四个第一语料太小。几千字的语料构建出来的共现矩阵词与词之间缺乏足够的“碰面”机会共现次数普遍为0。这种情况没什么好办法只能扩充语料。如果业务场景本身就是小数据建议干脆不要用共现矩阵直接上预训练词向量。第二窗口太小。窗口为1的时候只有紧挨着的词才算共现很多跨词关系直接丢失。试试把窗口加到5或者10稀疏度会明显下降。第三停用词处理不够狠。极端情况下分完词出来几千个低频词每个词都只有一两次共现矩阵稀到你怀疑人生。我一般会按词频排序只取前5000到20000个词进词表低频词全部丢掉。第四语料里句子太短。像搜索日志这种短文本场景一句平均五六个词窗口再大也大不到哪去共现关系天然有限。这时候可以考虑把同一用户同一时段的搜索词合并成“伪文档”再做共现统计。6.2 窗口大小怎么选才合适关于窗口大小我的经验是可以做一个小实验来定分别用窗口2、5、10、20各训练一版词向量选几个你关心的词对比如“人工智能”和“芯片”“制造业”和“转型”算一下相似度看哪一版的数值更符合业务直觉。窗口小语义偏句法层面的共现比如形容词和名词挨得近窗口大语义偏主题层面的共现同一个话题的词更容易被拉到一起。做关键词挖掘、主题聚类大窗口更合适做词性判断、语法关系小窗口更稳。没有绝对正确的窗口大小只有“是否符合你任务需求”的大小。6.3 共现矩阵方法与协同过滤的关系很多人第一次见“共现矩阵”这个词是在推荐系统的协同过滤教程里。这里澄清一下协同过滤里的用户-物品矩阵、物品-物品矩阵广义上确实是共现矩阵的一种特例但和NLP里的词-词共现矩阵在构建逻辑上有区别。词-词共现矩阵的“共现”发生在上下文窗口内窗口是连续文本的一段切片协同过滤里的“共现”发生在同一用户的行为集合里没有窗口概念。另外协同过滤矩阵里通常还会带上评分值而不仅仅是0/1标记这又比词频多了一层信息。如果你之前只接触过推荐系统里的协同过滤没接触过NLP的共现矩阵那建议先认真看一下前面1.2节的手工例子把“窗口”这个核心概念吃透。反过来如果你是NLP背景想转型做推荐理解共现矩阵以后协同过滤的很多代码也会觉得眼熟。6.4 计算量太大的优化技巧共现矩阵的计算复杂度跟文档长度、词表大小直接相关。几十万篇文档的语料用Python嵌套循环跑大概率跑一晚上都跑不完。我踩过这个坑之后总结了一个优化套路第一用Counter迭代而不是全量矩阵。上面代码的做法是先从Counter统计出共现关系最后再一次性填充矩阵不要在循环里反复创建数组。第二用joblib或者multiprocessing做多进程。把文档分成多份每个进程各自算一个局部共现统计最后合并。局部Counter合并也很简单直接两层循环累加就行。第三反正分子词表。先对整个语料做一次词频统计挑出高频词构建词表只统计词表内的词。这样矩阵维度和计算量双双下降。第四如果矩阵已经构建完但做SVD时内存不够考虑用sklearn的TruncatedSVD配合scipy.sparse它能随机化算法处理超大稀疏矩阵实测10万乘10万的矩阵也能在合理内存下跑完。7. 踩坑记录与快速排查速查表7.1 几个我踩过的明显坑第一坑忘了过滤单字词。中文分词结果里“的、了、是、也”这类功能词如果没过滤干净它们的高频会造成假共现把“苹果”和“的”拉出高相似度严重干扰下游任务。第二坑用全角括号和半角括号不同。做文本预处理时标点符号的格式如果不统一会导致分词结果不一致进而影响共现矩阵的稳定性。真实文本里中英文标点混用非常常见预处理阶段建议把全角转半角或者统一去除。第三坑语料编码问题。读文件时编码不统一会导致乱码乱码文本分出来的词全是噪音共现矩阵基本作废。建议统一转成UTF-8并在读文件时显式指定编码。第四坑窗口边界算错。这个代码看着简单但边界条件特别容易出bug。比如range(start, end)里的end不包含最后一个词窗口右边界必须加1。这个错一旦踩进去相似度计算的结果会很离谱但代码又不报错排查起来挺费劲。7.2 常见问题速查表问题现象可能原因排查方法矩阵稀疏度超过99.9%语料太小、窗口太小、词表太大查看语料文档长度分布、增加窗口大小、过滤低频词相似度结果大面积异常停用词未处理、未做PPMI、分词质量差检查词表中最高频的词是不是功能词打印部分共现对人工检查SVD降维内存爆掉矩阵太大且用稠密格式存储换成scipy.sparse存储过滤词表降低维度共现次数跑了一晚上没出结果双重循环嵌套太深用Counter多进程限制词表规模PPMI矩阵出现大量NaN某词没有出现在共现矩阵中用np.nan_to_num处理检查词表构建逻辑7.3 一个快速验证矩阵质量的小技巧模型好不好先拿小数据验。我每次构建完共现矩阵都会找几组“应当相似”的词对和“应当不相似”的词对做一次冒烟测试。“应当相似”的例子“人工智能”和“机器学习”“手机”和“智能手机”。“应当不相似”的例子“人工智能”和“香蕉”“手机”和“环保”。如果相似度排序明显符合预期再放心往下走。如果冒烟测试都过不了先回头看预处理和窗口参数别急着调后面复杂的模型。这一步花不了几分钟但能帮你省下大半天排查问题的时间。做共现矩阵这事儿真正难的不是代码本身而是对语料、对窗口、对词表的判断。同样的代码换一批数据参数就要跟着变。多试几组参数多看几次中间输出边跑边调慢慢就有感觉了。
网站建设高端定制企业官网