NLP介绍与词向量初步 | 自在学
NLP介绍与词向量初步
你给系统一句“这家店上菜很快”,它看到的起点并不是“好评”,而是一串字符。系统要先决定哪些字符属于正文、句子在哪里结束、哪些片段算作词,再把这些符号变成可以计算的数值。最后,它还要在具体任务中判断:“很快”是在称赞效率,还是在抱怨食物凉得快。
这正是自然语言处理要解决的问题:把人使用的语言与计算过程连接起来。语言的形式看得见,意图、指代、常识和语境却经常没有写全。我们不能只背任务名称,也不能把一个向量当成词义本身。本章会沿着一条可复现的路线推进:先看语言处理的层级,再处理原始文本,接着从共现统计走到词向量,最后训练一个简化的 Skip-gram with Negative Sampling,并讨论怎样判断向量是否真的有用。
本章的目标不是训练一个可上线的大模型,而是建立一套检查框架:任何 NLP 结果都要能追溯到输入边界、语料分布、训练目标和评估任务。后面的上下文表示与预训练模型仍会反复用到这四个问题。
自然语言处理究竟在处理什么
从表面文字到任务输出
自然语言处理可以理解为一组把语言输入转成结构、判断或新语言输出的方法。输入可能是文本,也可能是语音识别得到的转写;输出则取决于业务问题:
文本分类给整段文字分配标签,例如投诉类型、情感或主题。
序列标注给每个词元分配标签,例如人名、地点、时间和产品名。
信息抽取把散落在句子里的实体、属性和关系整理成结构化记录。
检索、排序与问答需要在大量候选内容中找到与问题相关的证据。
翻译、摘要和对话生成需要产出新的语言序列。
这些任务共享语言输入,却不共享唯一的“正确预处理”。搜索系统关心查询与文档能否稳定匹配;命名实体识别关心标签能否对齐到原文边界;情感分类可能需要保留叹号、否定词和表情。设计 NLP 系统时,我们先写清楚输入、输出和错误成本,再选择中间表示。
一张便于排错的层级图
分析语言时,常用下面的层级来定位问题:
这张图适合排错,却不意味着工程上必须把每一层写成互不沟通的模块。现代模型经常联合学习切分、句法与语义,生成模型也可能直接从字符或子词预测输出。不过,当结果出错时,我们仍要问:是正文抽取错了、边界错了、词义混了,还是任务目标本身定义得含糊?层级视角让误差有了落点。
自然语言难在“可接受解释不止一个”
自然语言中的歧义至少有几种来源:
词汇歧义 :“苹果发布新品”和“苹果已经熟了”中的“苹果”不是同一用法。
结构歧义 :“我看见了拿望远镜的人”中,望远镜可能属于“我看见”的方式,也可能是那个人携带的物品。
指代歧义 :“小李告诉小周,他通过了面试”里的“他”指谁,需要更多上下文。
辖域歧义 :“所有学生都没有提交”可能是在说无人提交,也可能是某项材料没有被所有人提交。
语用歧义 :“这里有点冷”既能陈述温度,也可能是在委婉地请求关窗。
统计模型能从大量用法中学习规律,但它只能利用输入和训练数据暴露出来的证据。如果一句话本身确实允许多种解释,模型输出一个高置信度标签不等于歧义已经消失。可靠系统需要保留不确定性,或在高风险场景请求补充信息。
1 句子“我看见了拿望远镜的人”最直接暴露了哪类问题?
A 字符编码不一致 B 短语附着造成的结构歧义 C 词表中没有“望远镜” D 分类标签数量太少
2 为一个线上投诉分类系统定义 NLP 任务时,哪些信息应在选模型前明确?
原始文本为什么不能直接交给模型
先把文件变成可信的正文
网页、日志和导出文件通常混着导航、脚本、页眉、重复段落、控制字符与编码差异。把它们直接计数,会让“登录”“下一页”之类的模板词反复进入语料。一个稳妥的输入流程通常包含:
先保存原始样本与来源元数据。原始副本让清洗规则可以回放,也方便判断异常是采集造成的,还是语言本身的变化。
识别字符编码和语言,抽取正文并去除重复模板。正文抽取要做抽样复核,因为过强的规则可能把表格标题、引用或代码片段一起删掉。
选择 Unicode 规范化策略。NFC 常用于合并等价的组合字符;NFKC 还会折叠全角和兼容字符,匹配更稳定,但也可能把任务需要区分的形式合并。
划分句子与词元,同时记录字符偏移。偏移能把模型结果映射回原文,对实体高亮、标注校验和错误分析都很重要。
规范化不是“越多越好”。例如,产品型号、数学符号、大小写和重复标点都可能携带信息。规则应由任务驱动,并在训练、验证、测试和线上预测时保持一致。
词边界不是空格的同义词
英文中的空格也不能解决所有边界问题:缩写中的句点、连字符、撇号、日期、金额和网址都有自己的切分规则。中文更直接地暴露了问题,因为句子通常没有词间空格:
研究生命起源
合理切分是“研究 / 生命 / 起源”;如果词典和算法选择了“研究生 / 命 / 起源”,后面的词频、实体和句法都会改变。反过来,按单字切分并不一定错误。字符模型能避免固定词表的一部分未登录词问题,有些端到端任务甚至会优于预先分词的词模型。关键不在于寻找放之四海皆准的边界,而在于验证某种边界是否帮助当前任务。
词元、词型与词表
词元(token) 是文本里的一次具体出现,词型(type) 是去重后的符号种类。句子“茶配牛奶,咖啡也配牛奶”里,“牛奶”出现两次,算两个词元、一个词型。训练静态词向量时,通常要建立词表并处理三类情况:
低频词:样本太少,向量容易被偶然上下文支配。
未登录词:预测阶段出现、训练词表里没有的词。
词形变化与别名:不同写法是否合并,取决于语言和任务。
可以用最低词频过滤并映射到未知标记,也可以改用字符或子词单元。过滤会缩小计算量,却可能丢掉人名、型号和新词;子词提高覆盖率,但词的表示会变成多个片段的组合。每种方案都在改变统计对象。
下面的实验台会展示同一段文字经过清理、规范化、句界和词界处理后的变化。试着关闭“小词典最长匹配”,再切换目标任务。你会看到,输入处理没有脱离任务独立存在。
3 只要使用 NFKC 规范化,文本匹配一定会改善,而且不会损失任务信息。
从词表编号走向可比较的含义
先分清“词义关系”和“向量相近”
词汇之间的关系并不只有相似:
同义或近义关注能否在某些语境中替换,例如“迅速”和“快速”。
反义关注同一尺度上的对立,例如“高”和“低”。
上下位关系描述类别包含,例如“猫”是“动物”的下位概念。
部分—整体关系描述组成,例如“车轮”和“汽车”。
主题相关描述经常共同出现,例如“医生”和“医院”,它们相关,却不是同义词。
人工词汇网络能明确标注这些关系,适合规则清楚、概念边界稳定的场景。它的不足是维护成本高,覆盖新词和领域用法较慢,关系的强弱也不容易统一量化。语料统计走了另一条路:观察词怎样被使用,再把相似使用模式变成可比较的数字。
One-hot 只保存身份
给定词表
V = { w 1 , w 2 , … , w ∣ V ∣ } V = \{w_1, w_2, \ldots, w_{|V|}\} V = { w 1 , w 2 , … , w ∣ V
one-hot 表示为每个词分配一个 ∣ V ∣ |V| ∣ V ∣ 维向量,只有对应位置为 1,其余为 0。它适合索引,因为每个词都有唯一编号;它不适合表达词与词的关系,因为任意两个不同词的点积都为 0,欧氏距离也完全相同。
我们真正想学习的是一个映射:
f : V → R d f: V \rightarrow \mathbb{R}^{d} f : V → R d
这里 d d d 通常远小于 ∣ V ∣ |V| ∣ V ∣ 。每个维度不必对应一个人能命名的概念,重要的是整体几何结构能支持相似度、分类或其他下游计算。维度是超参数,不存在对所有语料都最好的固定数值。
分布思想:从使用环境推测词的功能
如果“咖啡”和“茶”经常出现在“搭配牛奶”“适合早餐”“口感浓郁/清爽”这样的相近环境中,我们可以推测它们在语言使用上有相似功能。这个思路把不可直接测量的含义,转成可统计的上下文分布。
这里有三个限定:
相近表示的是分布相近,不保证是同义。 “咖啡”和“杯子”可能因为经常共现而靠近。
上下文的定义会改变相似性的类型。 很窄的窗口常强调局部搭配和句法角色;较宽的窗口更容易得到主题相关性。
语料决定模型能看见什么。 医疗语料中的“阳性”与日常评价中的“积极”分布不同;旧语料也无法可靠覆盖后来出现的新义。
静态词向量通常给一个词型一个主要向量。因此,“银行提高利率”和“河岸被水冲刷”中的“银行/岸”若共用同一词形,多个用法会被混在一个坐标里。向量可以显示它处在几个语义邻域之间,却不会自动为每次出现选择独立词义。上下文表示正是为解决这类限制而发展起来的。
5 在同一个词表里,两个不同词的 one-hot 向量余弦相似度通常是多少?
6 两个词的分布向量很接近,就能断定它们在所有语境中可以互换。
用共现向量亲手算一次相似度
从窗口生成词—上下文矩阵
先把语料切成词元。对每个中心词,在它左右 m m m 个位置内收集上下文,就能得到词—上下文计数矩阵 X X X 。X w c X_{wc} X w c 表示词 w w w 与上下文 c c c 在设定窗口内共同出现的次数。
例如,语料中有:
咖啡 / 搭配 / 牛奶 / 适合 / 早餐
茶 / 搭配 / 牛奶 / 适合 / 早餐
咖啡 / 带来 / 香气 / 适合 / 清晨
茶 / 带来 / 清香 / 适合 / 清晨
那么“咖啡”和“茶”在“搭配、牛奶、适合、早餐、清晨”等维度上会有相似计数。每一行就是一个高维稀疏向量。
原始计数容易被常见上下文支配。一个常见修正是点互信息:
PMI ( w , c ) = log P ( w , c ) P ( w ) P ( c ) \operatorname{PMI}(w,c)
=
\log
\frac{P(w,c)}{P(w)P(c)} PMI ( w , c ) = log P ( w ) P ( c ) P ( w , c )
分母表示假设 w w w 与 c c c 独立时的预期共现。实际共现超过预期,PMI 为正;低于预期,PMI 为负。正点互信息把负值截断:
PPMI ( w , c ) = max ( PMI ( w , c ) , 0 ) \operatorname{PPMI}(w,c)
=
\max\bigl(\operatorname{PMI}(w,c), 0\bigr) PPMI ( w , c ) = max ( PMI ( w , c ) , 0 )
PPMI 能压低“的、是、需要”这类广泛出现的背景,却会放大小语料里的偶然稀有共现。实际使用时还要配合最低词频、上下文平滑和独立验证。
余弦比较方向,不比较长度
两个非零向量 a \mathbf{a} a 与 b \mathbf{b} b 的余弦相似度是:
cos ( a , b ) = a T b ∥ a ∥ 2 ∥ b ∥ 2 \cos(\mathbf{a},\mathbf{b})
=
\frac{\mathbf{a}^{\mathsf{T}}\mathbf{b}}
{\|\mathbf{a}\|_2\|\mathbf{b}\|_2} cos ( a , b ) = ∥ a ∥ 2 ∥ b ∥ 2
余弦接近 1,说明方向相近,也就是各上下文维度的相对构成相似;接近 0,说明方向大致无关;接近 -1,说明方向相反。原始计数和 PPMI 都是非负向量时,余弦通常落在 0 到 1 之间。若某个词没有有效上下文,向量范数为 0,余弦没有定义,代码应显式处理,而不是悄悄除以 0。
下面的实验台允许切换语料、窗口和 PPMI。先观察“咖啡”的近邻,再把窗口从 1 增到 4。为什么结果会变?因为你改变了“上下文”的操作性定义。
7 两个非零向量方向完全相同、长度不同,它们的余弦相似度为 ____。
Word2Vec把统计问题改写成预测问题
两种训练任务
Word2Vec 不是某一个固定网络,而是一组从局部上下文学习静态词向量的训练方法。它最常见的两种架构是:
CBOW :汇总窗口内的上下文词,预测中间的中心词。
Skip-gram :输入中心词,分别预测窗口内的上下文词。
假设句子是“我 / 喜欢 / 学习 / 自然语言处理”,窗口半径为 2,中心位置是“学习”。CBOW 的输入是“我、喜欢、自然语言处理”,目标是“学习”;Skip-gram 则产生多个正词对,例如(学习,喜欢)和(学习,自然语言处理)。
CBOW 每个窗口形成较少的预测事件,常有较高吞吐量;Skip-gram 会把一个中心词展开成多个词对,能给每个上下文关系更直接的更新。哪一种更好不能脱离语料规模、词频、训练预算和下游任务下结论。
Skip-gram 的完整 softmax 目标
给定长度为 T T T 的词元序列 w 1 , … , w T w_1,\ldots,w_T w 1 , … , w T 和窗口半径 m m m ,Skip-gram 希望最大化:
L = ∑ t = 1 T ∑ − m ≤ j ≤ m j ≠ 0 log P ( w t + j ∣ w t ) \mathcal{L}
=
\sum_{t=1}^{T}
\sum_{\substack{-m \le j \le m \\ j \ne 0}}
\log P(w_{t+j}\mid w_t) L = t = 1 ∑ T
每个词有两套参数:作为中心词时的输入向量 v w \mathbf{v}_w v w ,以及作为上下文候选时的输出向量 u w \mathbf{u}_w u w 。完整 softmax 写成:
P ( o ∣ c ) = exp ( u o T v c ) ∑ w ∈ V exp ( u w T v c ) P(o\mid c)
=
\frac{\exp(\mathbf{u}_o^{\mathsf{T}}\mathbf{v}_c)}
{\sum_{w\in V}\exp(\mathbf{u}_w^{\mathsf{T}}\mathbf{v}_c)} P ( o ∣ c ) = ∑ w ∈ V exp (
点积越大,候选上下文 o o o 在中心词 c c c 周围的得分越高;分母对整个词表归一化。训练通过梯度更新两套矩阵,让真实词对的相对概率上升。
训练结束后,常见做法是取输入矩阵作为词向量,也有人使用输入与输出向量的和。这个选择会影响近邻和评估结果,应该写进实验配置。它也解释了一个初学者常见疑问:同一个词为什么在实现中会出现两个向量?因为它在预测任务里扮演了输入和候选输出两种角色。
窗口与采样其实在定义“语义”
Word2Vec 的表示不是只由模型名字决定。下面这些选择都会改变训练词对:
窗口半径及是否使用随机动态窗口;
高频词是否下采样;
低频词是否移出词表;
中文采用词、字还是子词;
句子是否跨段拼接;
语料的领域、时期、体裁与重复比例。
因此,比较两组词向量时必须尽量控制语料与训练预算。否则,所谓“算法差异”可能只是预处理或超参数差异。
9 窗口“我 / 喜欢 / 学习 / 语言”,中心词是“学习”时,哪项属于 Skip-gram 的一个正样本?
A 输入“喜欢”,预测“我” B 输入“学习”,预测“喜欢” C 输入整句话,预测情感标签 D 输入“学习”,预测随机噪声词
10 训练 Word2Vec 时,哪些选择会改变学到的邻域结构?
负采样为何能把训练做快
瓶颈在全词表归一化
完整 softmax 每处理一个正词对,都要计算词表里所有候选词的得分。词表有十万项时,一次更新就涉及十万次左右的点积与指数运算。负采样把问题改成:真实词对应得到高分,随机抽到的噪声词对应得到低分。
对正词对 ( c , o ) (c,o) ( c , o ) 和 k k k 个噪声词 n 1 , … , n k n_1,\ldots,n_k n 1 , … , n k ,单个训练事件最大化:
log σ ( u o T v c ) + ∑ i = 1 k log σ ( − u n i T v c ) \log \sigma(\mathbf{u}_o^{\mathsf{T}}\mathbf{v}_c)
+
\sum_{i=1}^{k}
\log \sigma(-\mathbf{u}_{n_i}^{\mathsf{T}}\mathbf{v}_c) log σ ( u o T v c ) +
其中:
σ ( x ) = 1 1 + exp ( − x ) \sigma(x)
=
\frac{1}{1+\exp(-x)} σ ( x ) = 1 + exp ( − x ) 1
第一项把真实上下文的 sigmoid 得分推向 1,第二项把噪声词的得分推向 0。每次只更新 1 个正样本和 k k k 个负样本,计算量由词表规模降到与 k + 1 k+1 k + 1 相关。
负采样训练出的 sigmoid 得分不是对整个词表归一化的 P ( o ∣ c ) P(o\mid c) P ( o ∣ c ) 。它是“这个词对来自语料还是噪声”的判别得分。如果应用需要严格的全词表概率,不能直接拿 SGNS 分数冒充。
噪声分布为什么用词频的四分之三次方
常用噪声分布是:
P n ( w ) = f ( w ) 3 / 4 ∑ w ′ ∈ V f ( w ′ ) 3 / 4 P_n(w)
=
\frac{f(w)^{3/4}}
{\sum_{w'\in V}f(w')^{3/4}} P n ( w ) = ∑ w
f ( w ) f(w) f ( w ) 是词频。与直接按词频抽样相比,指数 3 / 4 3/4 3/4 压平了分布,让中低频词获得更高的相对 抽样机会;与均匀抽样相比,它仍然更常抽到高频词。这个经验选择不能保证每个语料都最优,负样本数与噪声分布都要通过下游结果验证。
还要区分两个容易混在一起的操作:
负采样 决定每个正词对拿哪些噪声词做对照,目的是降低一次目标计算的成本。
高频词下采样 减少“的、是、了”等常见词进入训练的次数,目的是减少重复事件并改变训练分布。
“拉近、推远”只是二维直觉
梯度更新会提高正词对点积、降低负词对点积。把它画成二维图时,可以说“拉近正样本、推远负样本”;真实训练发生在高维空间,而且同时有很多词对争夺位置。一个词被抽成负样本,不代表它在语义上永远不相关,只表示这一次没有被观察为当前中心词的上下文。小语料中还可能抽到潜在的假负样本。
研究表明,Skip-gram 负采样目标与对移位 PMI 词—上下文矩阵的隐式分解有紧密关系。这说明计数法和预测法并非两个毫不相干的世界:它们都在利用共现,只是优化方式、权重和参数化不同。
在实验台中切换 CBOW 与 Skip-gram,再把负样本数从 1 增到 8。比较次数会线性增加;更新方向更充分,但每个事件也更贵。
11 SGNS 使用负采样后,每个 sigmoid 得分最合适的解释是什么?
A 对整个词表归一化的上下文概率 B 当前词对相对噪声的判别得分 C 词在语料中的原始频率 D 句子的语法正确率
12 把词频取四分之三次方后,所有词成为等概率负样本。
实操:从零训练一个迷你词向量
下面的程序只使用 Python 标准库。它手动构造 Skip-gram 正词对,按词频的 3 / 4 3/4 3/4 次方抽负样本,并对输入/输出向量做梯度更新。示例语料刻意重复了“猫—狗”“苹果—香蕉”“北京—上海”三组相似使用模式,便于在几秒内观察结果。
把代码保存为 mini_sgns.py,执行 python3 mini_sgns.py:
import math
import random
from collections import Counter
SEED = 7
random.seed( SEED )
base_sentences = [
[ "猫" , "喜欢" , "吃" , "鱼" ],
[ "狗" , "喜欢" , "吃" , "肉" ],
[ "猫" , "属于" , "宠物" ],
固定随机种子后,一次完整运行得到:
轮次 1: 平均损失 0.4592
轮次 30: 平均损失 0.2321
轮次 60: 平均损失 0.2251
轮次 90: 平均损失 0.2258
猫 的近邻: 狗(0.998),肉(0.788),鱼(0.783)
苹果 的近邻: 香蕉(0.999),口感(0.500),甜(0.484)
北京 的近邻: 上海(0.998),位于(0.498),中国(0.491)
为什么这样设计
初始化时,输入向量是很小的随机数,输出向量为零。随机数打破词之间的对称;输出矩阵从零开始,使第一次正负预测都接近 0.5,便于观察梯度。sigmoid 分成正数和负数两条计算路径,是为了避免指数溢出。
训练时先复制 center_before,再更新输出向量。这样,同一训练事件中的正样本和负样本都依据同一份中心词状态计算梯度。assert 检查末轮损失低于首轮,它不是质量证明,却能发现“符号写反、参数没更新”这类基本错误。
近邻结果与人造语料一致,是因为“猫/狗”“苹果/香蕉”“北京/上海”被安排在几乎相同的句式里。这个结果不能外推到真实语言:语料很小、句子被重复、没有高频词下采样,也没有批处理和并行优化。把 base_sentences 换成领域语料时,应重新检查分词、最低词频、窗口、训练/验证切分和邻近词。
13 示例中“猫”和“狗”成为近邻,最直接的原因有哪些?
14 程序中的 assert 直接检查末轮平均损失是否比首轮更 ____。
从实验结果走向项目选择
内在评估只能回答局部问题
训练结束后,可以先做四类快速检查:
查看目标词的近邻,回到原句核对它们为何共现。
用人工标注的词对计算相似度排序相关性。
测试类比关系,例如城市—国家或词形变化。
检查不同随机种子、窗口和语料切片下的稳定性。
经典类比会计算:
v b − v a + v c \mathbf{v}_b-\mathbf{v}_a+\mathbf{v}_c v b − v a + v c
再找与结果余弦最高的候选词。它能探测某些几何规律,但对词表覆盖、候选过滤、频率和评分公式很敏感。一个漂亮的“国王—男人+女人≈女王”不能证明模型理解了性别或王权,也不能代表分类、检索和实体识别都会改善。
外在评估才连接到真实目标
外在评估把向量放进最终任务,例如用作文本分类特征、实体识别输入或检索表示,然后与明确基线比较。评估至少要分三层:
整体指标 :准确率、F1、召回率或排序指标,必须与业务错误成本一致。
切片指标 :低频词、未登录词、不同领域、不同文体与关键群体分别计算。
稳定性 :更换随机种子、时间段或数据来源后,结论是否仍成立。
语料偏差也会进入向量空间。如果职业词与某些性别词在历史文本中长期共同出现,模型会忠实编码这种关联。删除一个方向或调整邻域可以缓解某些测量到的偏差,却不能替代数据治理、任务审查和上线监控。
静态词向量的失败边界
Word2Vec 轻量、训练快、便于理解,适合教学、资源受限环境和一些稳定领域的特征初始化。它有清楚的边界:
一个词型通常只有一个主向量,多义词的不同用法会混合。
词序主要通过局部窗口间接进入,不能完整表示句子结构。
词表外的新词没有独立向量。
语料换领域、换时间,邻域可能明显漂移。
高余弦可能表示相关、对立或句法功能相似,不等同于可替换。
选择表示方法时,可以用下面的判断表:
本章先把“词”压缩成一个静态点。下一篇会继续比较静态词向量的训练目标、近似方法与评估方式,弄清负采样、分层 Softmax、GloVe 等路线分别在优化什么。再往后,我们才会追问:同一个词出现在不同句子里,怎样根据上下文得到不同向量?这时才真正进入上下文相关表示。
16 系统必须区分“银行提高利率”和“坐在河岸旁”中的不同词义时,最直接的改进方向是什么?
A 让两个用法永远共享同一个静态向量 B 使用能根据句子生成表示的上下文模型 C 把向量维度固定为 300 就够了 D 删除所有上下文词
∣
}
a T b
− m ≤ j ≤ m j = 0
∑
log
P
(
w t + j
∣
w t )
u
w T
v c
)
exp ( u o T v c )
i = 1 ∑ k
log
σ
(
−
u n i T
v c
)
′
∈
V
f
(
w ′
) 3/4
f ( w ) 3/4
[ "狗" , "属于" , "宠物" ],
[ "苹果" , "属于" , "水果" ],
[ "香蕉" , "属于" , "水果" ],
[ "苹果" , "口感" , "甜" ],
[ "香蕉" , "口感" , "甜" ],
[ "北京" , "是" , "城市" ],
[ "上海" , "是" , "城市" ],
[ "北京" , "位于" , "中国" ],
[ "上海" , "位于" , "中国" ],
]
sentences = base_sentences * 60
counts = Counter(word for sentence in sentences for word in sentence)
vocab = sorted (counts)
word_to_id = {word: index for index, word in enumerate (vocab)}
id_to_word = {index: word for word, index in word_to_id.items()}
window = 2
pairs = []
for sentence in sentences:
ids = [word_to_id[word] for word in sentence]
for i, center in enumerate (ids):
left = max ( 0 , i - window)
right = min ( len (ids), i + window + 1 )
for j in range (left, right):
if i != j:
pairs.append((center, ids[j]))
dimension = 12
input_vectors = [
[(random.random() - 0.5 ) / dimension for _ in range (dimension)]
for _ in vocab
]
output_vectors = [[ 0.0 ] * dimension for _ in vocab]
weights = [counts[id_to_word[i]] ** 0.75 for i in range ( len (vocab))]
weight_sum = sum (weights)
cumulative = []
running = 0.0
for weight in weights:
running += weight / weight_sum
cumulative.append(running)
def sample_negative (excluded):
while True :
value = random.random()
for index, boundary in enumerate (cumulative):
if value <= boundary:
if index not in excluded:
return index
break
def sigmoid (value):
if value >= 0 :
return 1.0 / ( 1.0 + math.exp( - value))
exp_value = math.exp(value)
return exp_value / ( 1.0 + exp_value)
def dot (left, right):
return sum (a * b for a, b in zip (left, right))
def train_pair (center, context, learning_rate, negative_count = 4 ):
center_vector = input_vectors[center]
center_before = center_vector[:]
center_gradient = [ 0.0 ] * dimension
loss = 0.0
context_vector = output_vectors[context]
positive_score = dot(center_before, context_vector)
positive_probability = sigmoid(positive_score)
positive_gradient = positive_probability - 1.0
loss -= math.log( max (positive_probability, 1e-12 ))
for d in range (dimension):
center_gradient[d] += positive_gradient * context_vector[d]
context_vector[d] -= (
learning_rate * positive_gradient * center_before[d]
)
negatives = [
sample_negative({center, context})
for _ in range (negative_count)
]
for negative in negatives:
negative_vector = output_vectors[negative]
negative_score = dot(center_before, negative_vector)
negative_probability = sigmoid(negative_score)
negative_gradient = negative_probability
loss -= math.log( max ( 1.0 - negative_probability, 1e-12 ))
for d in range (dimension):
center_gradient[d] += (
negative_gradient * negative_vector[d]
)
negative_vector[d] -= (
learning_rate * negative_gradient * center_before[d]
)
for d in range (dimension):
center_vector[d] -= learning_rate * center_gradient[d]
return loss / (negative_count + 1 )
loss_history = []
epochs = 90
for epoch in range (epochs):
random.shuffle(pairs)
learning_rate = 0.04 * ( 1.0 - 0.8 * epoch / epochs)
epoch_loss = sum (
train_pair(center, context, learning_rate)
for center, context in pairs
) / len (pairs)
loss_history.append(epoch_loss)
if epoch in { 0 , 29 , 59 , 89 }:
print ( f "轮次 { epoch + 1 :>2 } : 平均损失 { epoch_loss :.4f } " )
def cosine (left, right):
numerator = dot(left, right)
left_norm = math.sqrt(dot(left, left))
right_norm = math.sqrt(dot(right, right))
if left_norm == 0.0 or right_norm == 0.0 :
return 0.0
return numerator / (left_norm * right_norm)
def nearest (word, topn = 3 ):
word_id = word_to_id[word]
scores = []
for other_id, other_word in id_to_word.items():
if other_id != word_id:
score = cosine(
input_vectors[word_id],
input_vectors[other_id],
)
scores.append((score, other_word))
return sorted (scores, reverse = True )[:topn]
assert loss_history[ - 1 ] < loss_history[ 0 ]
for word in [ "猫" , "苹果" , "北京" ]:
formatted = "," .join(
f " { other } ( { score :.3f } )"
for score, other in nearest(word)
)
print ( f " { word } 的近邻: { formatted } " )