嵌入 — 把语义变成坐标的最简单办法
一句话总结
嵌入就是把文本变成向量,使其可以用距离来比较。即使不用神经网络,仅靠 TF-IDF 也能完整观察它的骨架——向量化 → 归一化 → 内积——以及这个骨架崩塌的方式。
为什么需要它
假设客服搜索框收到两条反馈。一条是用“付款好慢”搜索时找不到“付款延迟处理”文档;另一条是用错误码 E4012 搜索时,排在前面的是不相干的文档。前者是表面文字不同导致的失败,后者是本应要求文字完全一致、却取回了只是意思相近的内容导致的失败。要区分并修复这两种失败,首先得知道“相似”是如何被量化定义的。
把文本变成向量后,就可以用内积或夹角计算相似度。最简单的向量就是列出各个词的出现次数。问题在于常见词占据主导。出现在所有文档中的词对区分文档毫无帮助,数值却最大。
TF-IDF 正面处理了这个问题。它把词在文档内的频率(TF)乘以文档频率的倒数(IDF)。在许多文档中都均匀出现的词 IDF 较低而被压低,只出现在特定文档中的词 IDF 较高而被突出。
它如何工作
加入平滑的 IDF 通常这样定义。它与 scikit-learn 的 TfidfTransformer 默认使用的公式相同。
idf(t) = ln((1 + N) / (1 + df(t))) + 1
分子分母各加 1,相当于假设还有一篇把每个词各包含一次的文档,这样即使 df 为 0 的词也不会让除法出错。最后再加 1,是为了防止出现在所有文档中的词权重变成 0 而完全消失。用数字看更直观。文档有 30 篇时,只出现在一篇文档中的词约为 3.741,出现在三篇中的约为 3.048,出现在十篇中的约为 2.036,出现在所有文档中的正好是 1。罕见词的权重是常见词的三四倍。
构造向量之后要做 L2 归一化。把每一行除以它自身的长度、使长度为 1,两个向量的内积就等于余弦相似度,文档长度的影响也随之消失。如果不归一化,长文档仅仅因为词多,与几乎所有查询的内积都会变大。
哈希技巧不维护词汇表,而是用哈希函数把词分配到固定数量的桶里。无论词汇如何增长,维度都保持固定,也不必随身携带词典。代价有两个:不同的词落进同一个桶就无法区分;哈希是单向的,无法从桶编号找回原来的词。scikit-learn 的 HashingVectorizer 默认使用 2 的 20 次方个桶,并根据哈希值交替加上正负号,使冲突不会单向累积,而是相互抵消。如果把桶数设得很小,比如 1024 个,冲突就会多到肉眼可见,代价表现为最近邻发生变化。
神经网络嵌入的骨架也一样,区别只在于构造向量的方法。不再是词的次数,而是训练好的模型把句子变成几百维的稠密向量,因此“慢”和“延迟大”会彼此接近。归一化后用内积比较,这其余的步骤完全相同。
实际项目中会出什么问题
漏掉归一化。 症状是少数几篇文档出现在大多数互不相关的查询结果前列,通常是最长的那些文档。如果在结果列表中反复看到同一个文档 id,先检查行长度。
查询变成零向量。 如果查询中的词全都不在索引词汇表里,查询向量的所有值都是 0。此时再除以长度,就是 0 除以 0,值全部变成 nan,而 numpy 只留下一行警告就继续计算。用混有 nan 的分数排序,顺序就失去了意义,却不会报错。长度为 0 的查询要在归一化之前拦下,直接返回“无结果”。
索引和查询的规则不同。 例如建索引时转成了小写而查询时没有转,或者每来一个查询都重新计算 IDF,两个向量就处在不同的空间里。由于仍然会算出分数,很难察觉。在神经网络嵌入中这个问题更严重。模型一旦更换,向量就无法比较,必须全部重新建索引;有些模型在训练时就要求给查询和文档加上不同的前缀(例如 E5 系列的 query: 和 passage:),漏掉它们,质量会悄无声息地下降。
哈希值每次运行都会变。 Python 内置的 hash() 对字符串会在每个进程中混入不同的随机值。用它来决定桶,昨天建的索引和今天进来的查询就会使用不同的桶。必须使用可复现的哈希(hashlib.md5 等)。
原样搬用分数阈值。 TF-IDF 的余弦 0.3 与神经网络嵌入的余弦 0.3 含义不同。每个模型的分数分布都不一样,更换模型或加权方式后,阈值也要用评测集重新确定。
如何确认
构造好矩阵后,在相信计算结果之前先检查不变量。
import numpy as np
X = np.load("tfidf.npy")
norms = np.linalg.norm(X, axis=1)
print(np.allclose(norms, 1.0)) # every row has length 1
print(np.isnan(X).any()) # no nan anywhere
S = X @ X.T
print(np.allclose(np.diag(S), 1.0)) # self-similarity is 1
np.fill_diagonal(S, -1)
nn = S.argmax(axis=1)
print(np.bincount(nn).max()) # one document as everyone's neighbour?
最后一行尤其有用。它汇总每篇文档的最近邻,统计某篇文档有多少次成为“某人的最近邻”。如果某篇文档被选中的次数格外多,就说明归一化或加权出了问题。
哈希技巧的损失用两个数字衡量。比较不同词的数量与实际用到的桶的数量,可以看出冲突有多少;统计精确矩阵与哈希矩阵的最近邻有几条一致,可以看出这些冲突在多大程度上改变了检索结果。另外,确认检索结果时不要只看分数,要看为什么是这篇文档。把查询与文档共有的词及其权重打印出来,原因通常一目了然。
下一个实验要做什么
本模块有两个实验。紧接着的实验是从零实现前一篇理论课讲过的 BPE 分词器,之后的实验再亲手计算本课的内容:对 30 篇文档分词,用 numpy 直接构造文档频率、IDF 和 TF-IDF 矩阵,用余弦相似度完成最近邻和查询检索,最后用哈希技巧构造 1024 维矩阵,测量最近邻变化了多少。