2015年6月

前言

接着上一篇的坑往下填。Word2Vec训练完,"苹果"在库里就只有一副面孔的向量,可是"苹果发布了新手机"和"下午吃了个苹果"里的苹果分明是两个物种。这个一词多义的问题,静态词向量从原理上就无解,于是有了这一篇的上下文词向量。

静态词向量的三宗罪

把上一代方案的局限摆清楚,才知道后面的人都在补什么:

  • 一词多义无解:一个词一个向量,多义词只能妥协出一个不伦不类的平均值,"苹果"既不像水果也不像手机
  • 新词没有向量:训练时没见过的词(人名、新梗、错别字)直接抓瞎,OOV(out of vocabulary)问题
  • 词序信息丢失:把一句话的词向量平均一下当句向量,"狗咬人"和"人咬狗"算出来是一样的

中间有个补丁式的改进是Facebook的fastText,把词再拆成字符级n-gram,"哈哈哈"没见过但"哈哈"见过就能拼出个大概,缓解了新词问题。但一词多义还是治不了,病根在于静态这两个字。

上下文向量:向量跟着句子走

真正的解法思路其实很直接:词的向量不该在训练完就焊死,而应该在读到具体句子时现场计算,上文是什么,向量就是什么。

2018年是个分水岭,前后脚两件大事:

  • ELMo:用双向LSTM读句子,每个词的向量由整句计算得出,"苹果"在手机句里就被手机这个词拉向科技区,在水果句里被拉向食物区
  • BERT:换成Transformer结构,用完形填空式的掩码语言模型在几十亿词的语料上预训练,效果直接屠榜

从此"预训练+微调"成了NLP的标准范式,词向量也升级成了动态的上下文表示。更妙的是这套表示顺手把句子级任务也统一了,取特殊位置(如句首标记)的向量或做池化,就得到整句话的向量。

拿句向量算个相似度

现在这玩法已经封装得非常顺手,相似度就用余弦,$\mathrm{sim}(a, b) = \dfrac{a \cdot b}{\lVert a \rVert \, \lVert b \rVert}$,sentence-transformers几行:

from sentence_transformers import SentenceTransformer
import numpy as np

# 多语言模型,中英都能算
model = SentenceTransformer("paraphrase-multilingual-MiniLM-L12-v2")

sentences = [
    "显卡最近降价了,可以入手",
    "GPU价格回落,正是购买好时机",
    "今天午饭吃了火锅",
]
emb = model.encode(sentences)  # 每句话一个384维向量

def cos(a, b):
    return np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b))

print(cos(emb[0], emb[1]))  # 前两句意思相近,得分明显高
print(cos(emb[0], emb[2]))  # 和火锅句,得分明显低

同义不同词、词序变化都能hold住,这就是上下文表示的功力。而这个小小的余弦相似度,正是如今一大票系统的心脏。

结束语

上面那三行cos代码放大到生产环境,就是现在火热的RAG(检索增强生成):文档切块算向量入库(各种向量数据库,Milvus、Qdrant这些),用户提问也转成向量,捞回最相近的几段塞给大模型当参考资料。大模型再能聊,接不上你的私有数据也白搭,而这座桥的桥墩子,就是十年前那些想让"爬虫"和"蜘蛛"挨得近一点的努力。技术这条线的伏笔有时候埋得很远,回头看挺有意思。

前言

折腾搜索相关的功能,有感记录,想让程序判断"爬虫"和"蜘蛛"意思相近、"爬虫"和"火锅"不相干,这种事对人 trivial 到不值一提,对程序却是要命的原生问题。翻了些资料,发现这个问题的答案就是NLP的第一块基石:词向量。

从一个尴尬说起

计算机表示一个词最原始的办法是one-hot编码:词表里有五万个词,就造一个五万维的向量,这个词所在的位置填1,其他全是0。这样做有两个要命的问题:

  • 维度爆炸,词表越大向量越长,而且绝大多数位置都是浪费的0
  • 所有词两两之间距离都一样,"电脑"和"计算机"的距离,与"电脑"和"螃蟹"的距离分毫不差,任何语义信息都没有

要让程序理解相似性,就得把词放进一个低维的、有意义的数学空间里,让意思相近的词在这个空间里挨着。这样的向量就叫词向量,也叫词的分布式表示(distributed representation)。

凭什么意思相近的词会挨着

理论基础是语言学家Firth那句著名的话:You shall know a word by the company it keeps(观其伴而知其义)。一个词的意思,是由它周围经常出现的词决定的。"爬虫"周围总是出现"网页""抓取""HTTP","蜘蛛"周围也总出现这些,那它俩的向量自然就该靠近。

把这个思想工程化的就是2013年Google Mikolov那篇著名的Word2Vec论文,两种结构:

  • CBOW:拿上下文词去预测中间那个词
  • Skip-gram:反过来,拿中间的词去预测上下文里都出现了谁

训练过程说白了就是拿一个滑动窗口在语料上扫,窗口里词共现就是正样本。但有个计算上的坑:按原始定义,预测词表里五万个词谁该出场,分母要把五万个词都遍历一遍,这个开销扛不住。工程解法叫负采样(negative sampling):正样本之外,随机从词表里抽几个词当反面教材,把五万分类问题变成几个二分类问题,训练开销直接砍到能跑。就这么一个看起来糙的近似,效果却出奇的好。

跑一下感受感受

gensim几行代码就能在自己语料上训练:

from gensim.models import Word2Vec

# 语料:分好词的句子列表,中文需要先分词
corpus = [
    ["网络", "爬虫", "抓取", "网页", "数据"],
    ["蜘蛛", "也是", "爬虫", "的", "一种", "叫法"],
    ["火锅", "配", "毛肚", "最佳"],
    # ......真实场景下这里应该是几万条句子
]

# sg=1用Skip-gram,negative=5负采样,vector_size向量维度
model = Word2Vec(sentences=corpus, vector_size=100, window=5,
                 min_count=1, sg=1, negative=5, workers=4)

print(model.wv.most_similar("爬虫"))  # 语义最近的邻居

词向量最出圈的玩法是算术:

model.wv.most_similar(positive=["国王", "女人"], negative=["男人"])
# 期望出来"女王",国王-男人+女人≈女王

向量空间里居然隐含着性别、时态、首都国家这类关系,2013年这一点震惊了整个圈子里外:

$$ \text{国王} - \text{男人} + \text{女人} \approx \text{女王} $$

语料太小的时候上面这些效果出不来,真实项目里一般要么拿自己领域的大语料训练,要么直接下载别人用海量新闻训练好的向量用。

它都用在哪些地方

  • 搜索召回与关键词扩展:query和文档都变成向量,算相似度
  • 推荐系统的内容理解
  • 文本分类、情感分析的底层特征
  • 机器翻译早期的双语词向量对齐

最后

词向量把NLP从"处理字符串"带到了"计算语义"的时代,是后来一切预训练模型的起点。但它也有个天生缺陷:一个词只有一副面孔,"苹果手机"和"吃苹果"里的苹果在它眼里是同一个向量。这个坑怎么补上的,放下篇说。