▤书架
▶目录

自然语言处理

对应:培养方案 专业选修课 · 学期 5 · 4 学分 · 64 学时(32 课堂 + 32 实践)
标记 ↗:与信息科学学院人工智能专业同堂共享课,同一间教室、同一份作业、同一套考试。
这是整个专业选修模块里学分最重的一门(是《深度学习原理与实践》的两倍),因为它同时要
讲清"语言"和"计算"两套东西怎么接在一起。

前置:这页假设你已经读过同目录的《深度学习原理与实践》。
神经网络、反向传播、注意力机制、Transformer 的推导那一页都有,这里不重复,
只引用(写成"见《深度学习》§8.2"这种形式)。如果那页没读,先回去读。

这份笔记怎么用:所有代码都用 numpy / 标准库从零手写并实际跑过,
复制到终端就能跑。搜索 >>> 实跑输出 能看到我跑出来的真实结果。
运行环境:~/miniconda3/bin/python。只用到 numpy,其余全是标准库。
下面这几个模型(词向量、n-gram、HMM、转移式解析器)的全部智力内容就在那几十行里。

相关笔记:语言学侧的概念请对照 ../专业课/教材/笔记-语言学概论.html(下称《语言学概论》)
与 ../专业课/教材/笔记-现代汉语.html(下称《现代汉语》);
认知侧的争议请对照 ../专业课/教材/笔记-心理语言学核心.html(下称《心理语言学》)§6。


1 导论:这门课在研究什么(先读这个)

1.1 这门课不是什么

误解一:"NLP 就是语言学加编程。"

这是认知科学专业的学生最容易有的误会,而且它有一半是对的,所以更危险。NLP 确实
借用了大量语言学概念(词、词性、句法树、依存关系),但借的是这些概念的"用途",不是
它们的"内容"。语言学里"什么是词"至今没有公认答案(《现代汉语》§1 就在讨论普通话
里"词"的界定有多麻烦),而 NLP 用一句"按空格切"或者"按 BPE 子词切"就绕过去了。
NLP 的判据永远是"下游任务有没有变好",不是"分析得对不对"。两者的关系是 §1 的主题,
那里会讲透,但先记住这个心态:不要拿语言学的标准去要求 NLP,也不要拿 NLP 的结果去
冒充语言学结论。

误解二:"NLP 是深度学习之后才有的。"

深度学习把 NLP 的效果推高了一大截,但 NLP 本身比计算机还老。1950 年代就有机器翻译的
尝试(冷战的产物);1966 年 ALPAC 报告宣告早期机器翻译失败,NLP 转向规则系统;1990 年代
统计方法(IBM 的翻译模型、n-gram 语言模型)全面接管;2013 年词向量、2018 年 BERT 才带来
今天的局面。为什么这段历史重要:因为每一次转向都留下了遗产。今天讲的 HMM 分词、
n-gram 平滑、BLEU,全是统计时代的产物,而且今天的工业系统里它们还在跑。

误解三:"NLP 的困难在于语言太复杂。"

语言确实复杂,但那不是最难的地方。真正的困难是歧义是语言的常态而不是例外。
请看:

"咬死了猎人的狗"

  读法 A:[ 咬死了 ] [ 猎人的狗 ]     -> 狗被咬死了
  读法 B:[ 咬死了猎人的 ] [ 狗 ]     -> 猎人被咬死了

这两个读法的每一个词、每一个词性都完全一样,只有结构不同。人靠语义和常识秒选,
机器必须在没有任何额外信息的情况下从结构上分辨。而越往下读越糟:

歧义 = 一个形式对应多个意义。语言为了表达效率,故意让形式保持简短(Zipf 1949 的
省力原则),代价就是歧义只能靠上下文补。NLP 的绝大部分技术,本质上都是在回答
"用哪些上下文信息来消解哪个层次的歧义"。这句话是这一整页的线索。

误解四:"中文和英文的 NLP 是同一件事。"

不是。中文有两条额外的硬麻烦:没有空格(分词问题,§4 整节都在讲),
以及词形几乎不变(没有英语那种 -s / -ed / 复数带来的形态信息)。
这两条让中文 NLP 的技术路线和英文长期不同:英文的经典流水线从"词性标注"开始,
中文必须从"分词"开始,而分词本身就是一个歧义消解问题。

1.2 它在专业里的位置

                        认知科学与技术 · 专业选修
                                    │
        ┌───────────────────────────┼───────────────────────────┐
        │                           │                           │
     它依赖谁                    【本课】                     它被谁依赖
   ────────────          自然语言处理 (学期 5)              ──────────────
   · 深度学习原理与实践     4 学分 / 64 学时                · 大语言模型
     (学期 5,同批)    ↗ 与信科院人工智能专业共享           · 智能体原理与设计
   · 人工智能原理        32 课堂 + 32 实践                  · 认知神经科学
     (学期 3)              │                                  (语言脑机制建模)
   · 语言学概论(句法树)     │                              · 心理语言学
   · 现代汉语(词/语素)      │                                  (surprisal 框架)
   · 概率统计(HMM/EM)       │
                              ▼
                    ┌──────────────────┐
                    │ 一条主干 + 三层任务 │
                    └──────────────────┘
                     主干:语言模型(给一句话打分)
                        │
        ┌───────────────┼───────────────┐
        ▼               ▼               ▼
     序列标注         结构分析         生成
   分词/词性/NER    句法/依存       翻译/摘要/对话
   (§4)           (§5)          (§6, §7)

和隔壁几门课的分工:

课程 它负责 和本课的分界
深度学习原理与实践(学期 5) 通用机制:反向传播、注意力、Transformer 那里拆开讲每一个组件;这里直接用,只关心怎么用到语言上
本课(学期 5) 把通用机制用在文本上 讲 NLP 的任务形式、评测、数据表示,不重复推导网络结构
人工智能原理(学期 3) 搜索、逻辑、agent 框架 那门讲"智能体怎么决策",不涉及从数据里学参数
语言学概论 / 现代汉语 语言的结构(音、词、句法树) 那两门描述语言是什么样;本课利用这些结构去做事
心理语言学 人怎么做语言加工 那门研究人;本课研究对象是机器,但两者互相借用(§8)
大语言模型(后续) 把本课推到十亿参数规模 那里关心规模和涌现,这里关心任务与表示

一句话定位:本课是认知科学专业里唯一一门要用语言学知识、写代码、做
实验评测的课。它同时是这门专业和信科院共享的三门课之一。换句话说,这是你用本专业
的语言学背景换技术学分的地方,也是你最容易掉队的地方(因为同堂的AI专业学生已经
写过两年代码)。

1.3 真正要教你的能力

具体、可检验,一共四条:

  1. 能把一个语言现象翻译成计算任务。给你一个现象(比如"的"字结构的歧义),
    你能说出:它对应哪个任务(句法分析?语义角色标注?)、输入输出各是什么、
    用什么指标衡量。检验方式:做 §9 第 8 题。
  2. 能手推词向量的目标函数。 Skip-gram 的对数似然、为什么取 log、负采样把
    一个 $V$ 类分类问题变成 $K+1$ 个二分类、梯度长什么样,不看笔记能写出来。
    检验方式:§2.2 的推导 + §9 第 2 题。
  3. 能手算困惑度和 F1。给一个小语料和一个测试句,你能用纸笔算出它的
    bigram 困惑度和加 1 平滑下的值;给一组标签,你能算出实体级 F1。
    检验方式:§3.3、§7.3 的手算例子 + §9 第 4、7 题。
  4. 能看懂一个评测数字背后的坑。别人报"BLEU 提升 2.3 分",你能说出这个数字
    在什么情况下会骗人(§7.4 列了六条)。这是本课最有用的一条能力,
    因为它是你以后读 AI 论文时天天要用的。

1.4 这份笔记的组织逻辑 / 怎么读

组织的原则是:先解决"词怎么变成数"(表示),再解决"句子怎么打分"(模型),
然后把模型用到三类任务上,最后问它评得准不准、像不像人。

   §1 NLP 和语言学的关系     ▸ 先把坐标系建好,否则后面全是名词堆
        │
        ▼
   §2 语言表示的三代 ★★★     ▸ 离散符号 -> 词向量 -> 上下文化
        │                       全篇最硬的一节,有可跑的词向量训练
        ▼
   §3 语言模型与困惑度 ★★     ▸ n-gram -> 神经语言模型;PPL 手算
        │                       这是后面一切生成任务的底座
        ▼
   §4 序列标注(分词/词性/NER)▸ HMM + Viterbi 手写,与最大匹配对比
        │
        ▼
   §5 句法分析                ▸ 依存 vs 成分;转移式解析器手写
        │                       (和你学过的句法树直接接口)
        ▼
   §6 从 RNN 到 BERT          ▸ 预训练 + 微调范式(细节见《深度学习》§7-8)
        │
        ▼
   §7 评测:BLEU/ROUGE/F1 ★   ▸ 定义 + 已知缺陷(这一节最实用)
        │
        ▼
   §8 和你专业的关系 -> §9 习题 -> §10 出处索引

阅读顺序建议:

一页速查:

符号 读法 含义 典型值
$V$ 词表大小 有多少个不同的词 $3\times10^4$ ~ $10^6$
$d$ 向量维度 每个词的向量多长 100 / 300 / 768
$K$ 负采样数 每个正样本配几个负样本 5 ~ 15
$w_i$ 第 $i$ 个词 序列里的一个 token —
$w_{i-n+1}^{i-1}$ 上下文 前面 $n-1$ 个词 —
$N$ 序列长度 一个句子里有多少 token 20 ~ 512
$\mathrm{PPL}$ 困惑度 语言模型的评价指标 20 ~ 200
$t$ 时间步 / 步数 HMM 或循环网络的下标 —

2 先说清一件事:NLP 和语言学是什么关系

这一节是整页的坐标系。搞不清这一节,后面每个名词都会站错位置。

2.1 共用对象,相反目标

一句话:

语言学研究"语言的结构是什么样";NLP 研究"怎么让机器用语言做成事"。
两者面对的是同一个对象(人类的语言),但判据完全相反。

语言学 NLP
目标 描述并解释语言的结构 让机器完成具体任务
判据 解释力、简洁性、是否符合母语者直觉 下游指标(准确率、F1、BLEU)
数据的作用 语料是证据,用来支持或反驳理论 语料是燃料,越多越好
一个反例意味着什么 理论错了(或需要修补) 只是这一个样本错了(继续训练)
对"例外"的态度 例外必须被解释 例外可以先不管,只要平均分高

这个对照表值得抄下来。它解释了后面很多"看不懂为什么这么做"的地方,比如
为什么 NLP 明明知道"词"没有严格定义,还是照切不误:因为判据不同。

2.2 语言学有五个层次,NLP 有五个对应的任务

语言学把语言分成若干层次(《语言学概论》§2 讲过这个层级),每一个层次都对应一组
NLP 任务。这张表是本课的骨架:

语言学层次 语言学关心什么 对应的 NLP 任务 本页哪里讲
音系 音位、音节、韵律 语音识别(ASR)、语音合成(TTS) 不在本课范围(属语音处理)
形态 语素、构词、屈折 分词、子词切分(BPE) §2.4、§4.1
句法 词怎么组合成短语和句子 句法分析(依存/成分) §5
语义 词义、句义、指代 词义表示、语义角色、指代消解 §2.2、§6
语用 语境中的言外之意 对话系统、语用推理 《大语言模型》那门

注意这张表里,"词性标注"和"NER"没有单独出现在语言学那一列,因为它们不属于语言学
的层次,只能算工程上的中间件。词性标签(名词/动词/…)在语言学里是"词类",但 NLP
用它只是为了给后续任务一个廉价的特征。这个区别在后面的任务设计里会反复出现:语言学的分类是目的,NLP 的
分类是手段。

2.3 三个具体的接口(这是本课和你已学内容的接缝)

接口一:句法树。你在《语言学概论》§5 学过怎么画句法树("我看见那个人"那个例子)。
NLP 里这叫成分句法分析(constituency parsing),输出就是同一棵树。
但 NLP 更常用的是依存句法(dependency parsing):不划分短语,直接标注"哪个词修饰
哪个词、是什么关系"。两者对比如下(用同一句话):

成分句法(你在《语言学概论》§5 画的那种树)

              S
        ┌─────┼─────┐
       NP     VP    │
       │    ┌──┴──┐ │
       我   V     NP
            │    ┌─┴──┐
           看见  Det  N
                 │    │
                那个  人


依存句法(同一句话,NLP 更常用)

    看见 root ─────────────┐  (中心词是"看见")
      │ nsubj              │ obj
     我                   人
                          │ det
                         那个

   写成三元组(NLP 的实际存储形式):
     我   --nsubj--> 看见
     人   --obj-->   看见
     那个 --det-->   人
     看见 --root-->  ROOT

两者的关键差别:成分树告诉你"哪些词先抱成团"(短语结构),依存树告诉你
"哪个词是哪个词的老板"(词间关系)。前者更适合做递归的结构预测,
后者更适合跨语言、跨语序的表示,所以《Universal Dependencies》这样的
跨国标注规范选了依存(Nivre et al. 2016)。§5 会把依存解析器写出来。

接口二:语素与词。《现代汉语》讨论过"什么是词"的困难("白菜"是词还是短语?
"洗澡"能不能拆开?)。NLP 的处置方式是两套并行的:

子词切分其实比语言学更接近语素的概念,但它不是从语言学来的。它是为了解决
"词表太大"和"没见过的词怎么办"这两个工程问题被发明出来的,结果顺手摸到了语素。
这是 NLP 里反复出现的模式。

接口三:分布假设。这是 §2 的全部基础,先预告一句:

一个词的意义,由它经常一起出现的词决定。

这句话在语言学里是 Firth (1957) 说的("You shall know a word by the company it
keeps"),在心理学里有更早的版本(Harris 1954 的分布假设),而它恰好是可以直接
计算的。词向量就是把这句话变成算法。

2.4 一段必须知道的历史:NLP 曾经"不要"语言学,后来又"要"了

所以语言学到哪去了? 答案很有意思:它没有被扔掉,而是被"重新发现"了。
模型自己学会了句法,说明句法结构确实是预测下一个词的有用中间变量,这是
对语言学的一个计算层面的支持。但它同时也是一种挑战:模型不需要知道"什么是
中心语",也能把中心语找出来。这正好是 §8 要讨论的问题。

NLP 和语言学共用对象、相反目标;语言学概念在 NLP 里的
地位是"有用的先验",不是"必须遵守的规范"。这个心态会让你在读后面的内容时
少困惑一半。


3 语言表示的三代(全篇最硬的一节)

这一节回答一个最底层的问题:词怎么变成数字?

顺序是三代表示,每一代都在修补上一代的漏洞:

   第一代:离散符号        第二代:静态词向量        第三代:上下文化表示
   ──────────────         ────────────────         ──────────────────
   one-hot / 词表          Word2Vec / GloVe          ELMo / BERT
        │                       │                         │
        │ 问题:                 │ 问题:                   │ 问题:
        │ ・维度爆炸             │ ・一个词只有一个向量       │ ・算得贵(一页的
        │ ・任意两词正交         │   "多义"没地方放          │  算力换来的)
        │ ・没有相似度           │ ・无上下文                │ ・可解释性更差
        ▼                       ▼                         ▼
   词就是一个 ID           词是空间里的一个点        词是一串随上下文变的向量

3.1 第一代:离散符号,以及它为什么不够

最朴素的做法:给词表里每个词一个编号,然后用 one-hot 向量表示它。

词表 $V$ 有 $V$ 个词,词 $w$ 的 one-hot 向量 $\mathbf{e}_w \in \{0,1\}^V$,
只有第 $w$ 个位置是 1,其余是 0。例如 $V=4$ 时:$\mathbf{e}_{\text{猫}}=(1,0,0,0)$。

import numpy as np

vocab = ["猫", "狗", "车", "跑"]
V = len(vocab)
def one_hot(w):
    v = np.zeros(V); v[vocab.index(w)] = 1.0
    return v

print("one-hot 向量:")
for w in vocab:
    print("   %s -> %s" % (w, one_hot(w)))

print("\n任意两个不同词的余弦相似度:")
import itertools
for a, b in itertools.combinations(vocab, 2):
    x, y = one_hot(a), one_hot(b)
    print("   cos(%s, %s) = %.1f" % (a, b, float(x @ y / (np.linalg.norm(x) * np.linalg.norm(y)))))

print("\n真实词表下的维度:")
for n in (10_000, 100_000, 1_000_000):
    print("   词表 %9d 时,每个词的 one-hot 要 %9d 个 float = %6.1f MB"
          % (n, n, n * 4 / 1024 / 1024))

实跑输出

one-hot 向量:
   猫 -> [1. 0. 0. 0.]
   狗 -> [0. 1. 0. 0.]
   车 -> [0. 0. 1. 0.]
   跑 -> [0. 0. 0. 1.]

任意两个不同词的余弦相似度:
   cos(猫, 狗) = 0.0
   cos(猫, 车) = 0.0
   cos(猫, 跑) = 0.0
   cos(狗, 车) = 0.0
   cos(狗, 跑) = 0.0
   cos(车, 跑) = 0.0

真实词表下的维度:
   词表     10000 时,每个词的 one-hot 要     10000 个 float =    0.0 MB
   词表    100000 时,每个词的 one-hot 要    100000 个 float =    0.4 MB
   词表   1000000 时,每个词的 one-hot 要   1000000 个 float =    3.8 MB

两条致命伤,全在这张输出里:

  1. 所有词的相似度都是 0。"猫"和"狗"的相似度跟"猫"和"车"一模一样,
    因为 one-hot 把每个词放在互相垂直的坐标轴上。而语言最重要的事实之一就是
    词之间有远近关系。one-hot 把这个信息全丢了。
  2. 维度随词表线性增长。一个词 3.8 MB(稀疏存储能缓解,但语义信息还是零)。
    更糟的是,维度高不等于信息多,这些维度之间没有任何结构。

问题的本质不在"one-hot 不高效",而在它假设词与词之间毫无关系。
而语言恰恰相反:词与词的关系就是它的意义。第一代表示失败在假设上,不是失败在
实现上。

3.2 第二代:词向量(Word2Vec)

3.2.1 思路:把"上下文相似"变成"向量相近"

出发点是 §1.3 的分布假设:"一个词的意义由它的上下文决定"。把它工程化:

如果两个词能替换而不改变句子的合理性,它们的意义就相近,向量就该相近。

注意这个判据是可以自动执行的:我不需要知道"猫"和"狗"是什么意思,我只需要
统计它们在语料里周围的词是不是相似。这就是 Word2Vec(Mikolov et al., 2013a,
arXiv:1301.3781;Mikolov et al., 2013b, NIPS, arXiv:1310.4546)。

Word2Vec 有两个版本,先讲清它们的区别(这是常考点):

  CBOW(Continuous Bag of Words)      Skip-gram
  ─────────────────────────────       ─────────────
  用上下文预测中心词                    用中心词预测上下文

       w(t-2)                               w(t-2)
       w(t-1)  ──▶  w(t)          w(t) ──▶  w(t-1)
       w(t+1)                               w(t+1)
       w(t+2)                               w(t+2)

  平均上下文向量 -> 预测 1 个词          用 1 个词 -> 预测很多个词
  快,但平滑掉细节                      慢,但对低频词更好

本页实现 Skip-gram + 负采样,因为它是 2013b 那篇的标配组合,也是推导最干净的。

3.2.2 Skip-gram 的目标函数:逐项推导

设语料是一个词序列 $w_1, w_2, \dots, w_T$,窗口大小为 $m$(只看左右各 $m$ 个词)。
每个词 $w$ 有两个向量:

为什么要有两套向量? 因为你问"$w$ 和 $c$ 像不像"和"$w$ 能否预测 $c$"是两个不同
问题。$P(c \mid w) \ne P(w \mid c)$,用一组向量会给不出这种不对称。两套向量训完
通常取 $\mathbf{v}$(Mikolov 原文的建议;也可以相加或拼接,性能差别很小)。

第一步:写出似然。对每个位置 $t$,我们要最大化"给定中心词 $w_t$,
它周围 $2m$ 个位置都出现真实词"的概率:

$$L(\theta) = \prod_{t=1}^{T} \prod_{\substack{-m \le j \le m \\ j \ne 0}} P(w_{t+j} \mid w_t)$$

逐项读:$\prod_t$ 遍历语料里每个位置;$\prod_j$ 遍历该位置的每个窗口邻居;
$P(w_{t+j} \mid w_t)$ 是"中心词是 $w_t$ 时,邻居是 $w_{t+j}$ 的概率"。
这里有一个重要简化假设:所有邻居条件独立。这显然是假的(邻居之间有关联),
但和朴素贝叶斯一样,这个错假设换来的是可计算性,而且效果够用。

第二步:取对数。乘积下溢,且求导麻烦,取 $\log$(单调,不改变最优解):

$$\ell(\theta) = \sum_{t=1}^{T} \sum_{-m \le j \le m,\, j\ne0} \log P(w_{t+j} \mid w_t)$$

第三步:定义 $P$。用什么把向量变成概率?用 softmax:

$$P(o \mid c) = \frac{\exp(\mathbf{u}_o^\top \mathbf{v}_c)}{\sum_{w \in V} \exp(\mathbf{u}_w^\top \mathbf{v}_c)}$$

逐项读:
- 分子 $\exp(\mathbf{u}_o^\top \mathbf{v}_c)$:中心词 $c$ 和上下文词 $o$ 的内积。
内积越大 = 两个向量越对齐 = 模型认为它们越可能共现。取 $\exp$ 是为了保证正数。
- 分母 $\sum_{w \in V}$:对所有 $V$ 个词求和,作用是把分子归一化成概率。
这一步就是困难所在。

第四步:求梯度。令 $s(o,c) = \mathbf{u}_o^\top \mathbf{v}_c$,对 $\log P(o \mid c)$
关于 $\mathbf{v}_c$ 求导:

$$\frac{\partial \log P(o \mid c)}{\partial \mathbf{v}_c} = \mathbf{u}_o - \sum_{w \in V} P(w \mid c)\, \mathbf{u}_w$$

逐项读这个式子,它是整个词向量训练的引擎:

第五步:问题来了,分母要遍历整个词表。

计算一次 $\log P$,要算 $V$ 次内积。而梯度里那个 $\sum_{w\in V}$ 又要遍历 $V$ 个词。
每见一个词对就要 $O(Vd)$ 的计算量。真实词表 $V \sim 10^6$、$d=300$,
一次梯度就是 $3\times10^8$ 次浮点运算,而语料有几十亿个词对。不可行。

3.2.3 负采样:把 $V$ 分类问题变成 $K+1$ 个二分类

负采样(Negative Sampling,NEG)的思路是:我们其实不需要完整的概率分布,
只需要能区分"真词对"和"假词对"。

把任务改写成二分类:

目标变成(对每个正样本配 $K$ 个负样本):

$$\ell = \log\sigma(\mathbf{u}_o^\top \mathbf{v}_c) + \sum_{k=1}^{K} \mathbb{E}_{w_k \sim P_n(w)}\left[\log\sigma(-\mathbf{u}_{w_k}^\top \mathbf{v}_c)\right]$$

逐项读:
- $\sigma(x) = 1/(1+e^{-x})$ 是 sigmoid,把内积压到 $(0,1)$ 当概率用;
- 第一项:正样本的得分应该高($\sigma$ 接近 1,$\log$ 接近 0);
- 第二项:负样本的得分应该低,所以用 $-\mathbf{u}^\top\mathbf{v}$(得分越低,
$\sigma(-\cdot)$ 越接近 1);
- $\mathbb{E}_{w_k \sim P_n(w)}$:负样本按分布 $P_n$ 抽。原文用的是
$P_n(w) \propto \text{count}(w)^{3/4}$,3/4 次方是一个经验性的"削峰"操作:
纯按词频抽的话,"的"这种词会被抽成负样本几百次,模型光在学"不是的";
取 $0.75$ 次方把高频词的权重压下来、低频词提上去。本页代码就用了这个。

梯度呢? 对单个样本 $x$($x=1$ 是正样本,$0$ 是负样本),损失
$\ell_x = -\log\sigma\big((2x-1)\,\mathbf{u}_x^\top\mathbf{v}_c\big)$。对 $\mathbf{v}_c$ 求导:

$$\frac{\partial \ell_x}{\partial \mathbf{v}_c} = \big(\sigma(\mathbf{u}_x^\top\mathbf{v}_c) - x\big)\,\mathbf{u}_x$$

这个式子极简,而且是本页代码里唯一的一行梯度。直觉:
- 如果 $x=1$ 而模型给出 $\sigma(\cdot)=0.3$(太低了),梯度是 $0.3-1=-0.7$,
方向是 $-\mathbf{u}_x$,减掉它,即沿 $-\mathbf{u}_x$ 更新 $\mathbf{v}_c$,
等价于把 $\mathbf{v}_c$ 推向 $+\mathbf{u}_x$。✓
- 如果 $x=0$ 而模型给出 $0.7$(太高了),梯度是 $0.7-0=+0.7$,沿 $+\mathbf{u}_x$
更新,即把 $\mathbf{v}_c$ 推离 $\mathbf{u}_x$。✓
- 如果模型已经判对($x=1,\sigma=0.99$),梯度 $0.99-1=-0.01$,几乎不动。✓

复杂度对比:从 $O(Vd)$ 降到 $O(Kd)$,$K$ 通常取 5–15。降了三到五个数量级。
下面的代码最后会把这笔账实际算出来。

为什么这样做在数学上还站得住?
严格地说,负采样不是在优化原来的 softmax 目标,它换了目标。但 Levy & Goldberg
(2014, NIPS) 证明了:在把 $\mathbf{u}^\top\mathbf{v}$ 理解为点互信息(PMI)的
偏移量时,负采样的最优解满足
$\mathbf{u}_w^\top\mathbf{v}_c \propto \mathrm{PMI}(w,c) - \log K$。
也就是说,它在做一个隐式的矩阵分解,分解的对象是"词-上下文共现的 PMI 矩阵"。
这解释了为什么词向量的线性结构这么好,PMI 矩阵本身就有低秩的加性结构。

3.2.4 实跑:用 numpy 训出词向量,看 "king - man + woman"

现在把上面的推导变成代码。语料是我构造的(下面会说明为什么),
重点看训练完之后的性质,不要纠结语料内容。

关于语料的诚实说明:真实词向量的类比性质需要上亿词的语料才能稳定出现。
为了让这个性质在十几秒内跑出来,我构造了一个结构清晰的小语料:每对词
(king/queen、actor/actress、…)共享一组专属场景词,男女性别由代词 he/she 标记。
这样语料里天然存在"性别"和"身份"两个正交维度。这是在演示模型能否恢复
被埋进语料的结构,不是在证明真实语料上必然如此。真实语料上的数字我会在
§2.2.5 里如实说明。

import numpy as np

# ============ 1. 语料:12 对词,每对共享一组专属场景词,性别由 he/she 标记 ============
PAIRS = [
    ("king", "queen"), ("prince", "princess"), ("man", "woman"), ("boy", "girl"),
    ("uncle", "aunt"), ("nephew", "niece"), ("father", "mother"), ("son", "daughter"),
    ("actor", "actress"), ("waiter", "waitress"), ("emperor", "empress"), ("monk", "nun"),
]
CTX = {
    ("king", "queen"):      ["rules", "kingdom", "palace", "crown"],
    ("prince", "princess"): ["inherits", "castle", "ballroom", "royal"],
    ("man", "woman"):       ["works", "city", "office", "salary"],
    ("boy", "girl"):        ["plays", "games", "toys", "young"],
    ("uncle", "aunt"):      ["visits", "relatives", "holiday", "kind"],
    ("nephew", "niece"):    ["grows", "children", "school", "family"],
    ("father", "mother"):   ["cares", "home", "dinner", "warm"],
    ("son", "daughter"):    ["studies", "books", "room", "quiet"],
    ("actor", "actress"):   ["performs", "stage", "film", "audience"],
    ("waiter", "waitress"): ["serves", "restaurant", "guests", "menu"],
    ("emperor", "empress"): ["empire", "army", "conquers", "palace2"],
    ("monk", "nun"):        ["prays", "temple", "silent", "robes"],
}
sentences = []
for m, f in PAIRS:
    for c in CTX[(m, f)]:
        sentences += [["the", m, c], ["the", f, c], [m, "and", c], [f, "and", c]]
    sentences += [["he", "sees", "the", m], ["she", "sees", "the", f],
                  [m, "and", "he"], [f, "and", "she"]]
for s in ["the world is big", "people live in the world", "the city is big",
          "people live in the city", "the home is warm", "the school is big"]:
    sentences.append(s.split())
corpus = sentences * 3

vocab = sorted({w for s in corpus for w in s})
w2i = {w: i for i, w in enumerate(vocab)}
V = len(vocab)
data = [[w2i[w] for w in s] for s in corpus]
cnt = np.zeros(V)
for s in data:
    for w in s:
        cnt[w] += 1
print("语料:%d 句 / 词表 %d 词 / 共 %d 词次" % (len(corpus), V, int(cnt.sum())))

# ============ 2. 负采样分布 P(w) ∝ count(w)^0.75(原文的 3/4 次方) ============
unigram = cnt ** 0.75
unigram = unigram / unigram.sum()

# ============ 3. 训练 ============
T_SUB = 1e-3                       # 高频词下采样阈值(Mikolov et al. 2013b)
freq = cnt / cnt.sum()
keep_p = np.minimum(1.0, (np.sqrt(freq / T_SUB) + 1) * T_SUB / freq)

rng = np.random.default_rng(0)
DIM, WINDOW, K_NEG, LR, EPOCHS = 32, 2, 5, 0.05, 200
v = (rng.random((V, DIM)) - 0.5) / DIM     # 中心词向量(要的就是它)
u = np.zeros((V, DIM))                     # 上下文词向量(训完丢弃)

def sigmoid(x):
    return 1.0 / (1.0 + np.exp(-x))

for ep in range(EPOCHS):
    lr = LR * (1.0 - ep / EPOCHS)          # 学习率线性衰减,防止后期发散
    L = 0.0; n = 0
    for sent in data:
        sent = [w for w in sent if rng.random() < keep_p[w]]     # 高频词下采样
        for i, c in enumerate(sent):
            for j in range(max(0, i - WINDOW), min(len(sent), i + WINDOW + 1)):
                if j == i:
                    continue
                # 1 个正样本 + K 个负样本,负样本按 unigram 分布抽
                tgt = np.concatenate(([sent[j]], rng.choice(V, size=K_NEG, p=unigram)))
                lab = np.concatenate(([1.0], np.zeros(K_NEG)))
                vc = v[c].copy()                              # 缓存更新前的中心向量
                p = sigmoid(np.clip(u[tgt] @ vc, -30, 30))     # (K+1,)
                g = (lab - p) * lr                             # 梯度 = (标签 - 预测) * lr
                u[tgt] += g[:, None] * vc                      # 更新上下文向量
                v[c] += (g[:, None] * u[tgt]).sum(axis=0)      # 更新中心向量
                L += -(np.log(p[0] + 1e-9) + np.log(1 - p[1:] + 1e-9).sum()); n += 1
    if ep % 25 == 0 or ep == EPOCHS - 1:
        print("epoch %3d  lr %.4f  平均损失/pair %.4f" % (ep, lr, L / n))

W = v / np.linalg.norm(v, axis=1, keepdims=True)   # 单位化后用余弦比较
id_ = lambda w: w2i[w]
def near(vec, exc=(), topk=5):
    s = W @ vec
    for e in exc:
        s[e] = -np.inf
    return [(vocab[i], round(float(s[i]), 3)) for i in np.argsort(-s)[:topk]]

print("\n【一】余弦相似度")
for a, b in [("king", "queen"), ("king", "man"), ("man", "woman"),
             ("king", "actor"), ("king", "emperor"), ("king", "prays")]:
    print("   cos(%-8s,%-8s) = %+.3f" % (a, b, float(W[id_(a)] @ W[id_(b)])))

print("\n【二】最近邻(看它学到了什么)")
for w in ["king", "woman", "actor"]:
    print("   %-8s -> %s" % (w, near(W[id_(w)], exc=(id_(w),), topk=5)))

print("\n【三】词类比:a - b + c 的最近邻")
for a, b, c in [("king", "man", "woman"), ("queen", "woman", "man"),
                ("actor", "man", "woman"), ("prince", "boy", "girl"),
                ("emperor", "king", "queen"), ("nephew", "uncle", "aunt")]:
    vec = W[id_(a)] - W[id_(b)] + W[id_(c)]
    print("   %-8s - %-8s + %-8s -> %s" % (a, b, c, near(vec, (id_(a), id_(b), id_(c)), 3)))

print("\n【四】对照:没训练过的随机向量(同样做 king-man+woman)")
rng2 = np.random.default_rng(7)
Wrand = rng2.normal(size=(V, DIM))
Wrand /= np.linalg.norm(Wrand, axis=1, keepdims=True)
vec = Wrand[id_("king")] - Wrand[id_("man")] + Wrand[id_("woman")]
s = Wrand @ vec
for e in (id_("king"), id_("man"), id_("woman")):
    s[e] = -np.inf
print("   最近邻 top-3 ->", [(vocab[i], round(float(s[i]), 3)) for i in np.argsort(-s)[:3]])

print("\n【五】全词表 softmax vs 负采样,算一笔账")
for Vv in (10_000, 100_000, 3_000_000):
    full = Vv * DIM * 2                      # 前向 + 反向
    neg = (K_NEG + 1) * DIM * 2
    print("   词表 %9d:全 softmax 每步约 %10.3e 次浮点运算;负采样小 %d 倍,每步 %d 次"
          % (Vv, float(full), full // neg, neg))

print("\n【六】数值核对梯度公式 d(loss)/d(v_c) = (sigmoid(u·v_c) - 1) * u  [正样本]")
rng3 = np.random.default_rng(3)
vc = rng3.normal(size=DIM) * 0.5
uo = rng3.normal(size=DIM) * 0.5
loss = lambda x: -np.log(sigmoid(uo @ x) + 1e-12)
g_analytic = (sigmoid(uo @ vc) - 1.0) * uo
eps = 1e-6
g_numeric = np.array([(loss(vc + eps * e) - loss(vc - eps * e)) / (2 * eps)
                      for e in np.eye(DIM)])
print("   解析梯度与数值梯度的最大偏差 = %.3e" % np.abs(g_analytic - g_numeric).max())

实跑输出

语料:738 句 / 词表 83 词 / 共 2310 词次
epoch   0  lr 0.0500  平均损失/pair 4.1587
epoch  25  lr 0.0438  平均损失/pair 2.3579
epoch  50  lr 0.0375  平均损失/pair 1.3825
epoch  75  lr 0.0312  平均损失/pair 1.2950
epoch 100  lr 0.0250  平均损失/pair 1.1996
epoch 125  lr 0.0188  平均损失/pair 1.1793
epoch 150  lr 0.0125  平均损失/pair 1.2040
epoch 175  lr 0.0063  平均损失/pair 1.1036
epoch 199  lr 0.0003  平均损失/pair 1.1981

【一】余弦相似度
   cos(king    ,queen   ) = +0.937
   cos(king    ,man     ) = +0.292
   cos(man     ,woman   ) = +0.929
   cos(king    ,actor   ) = +0.444
   cos(king    ,emperor ) = +0.247
   cos(king    ,prays   ) = +0.280

【二】最近邻(看它学到了什么)
   king     -> [('queen', 0.937), ('actor', 0.444), ('uncle', 0.414), ('son', 0.402), ('actress', 0.395)]
   woman    -> [('man', 0.929), ('actress', 0.474), ('live', 0.462), ('in', 0.442), ('mother', 0.394)]
   actor    -> [('actress', 0.94), ('man', 0.449), ('king', 0.444), ('emperor', 0.443), ('father', 0.427)]

【三】词类比:a - b + c 的最近邻
   king     - man      + woman    -> [('queen', 1.01), ('actress', 0.448), ('palace', 0.428)]
   queen    - woman    + man      -> [('king', 0.99), ('actor', 0.455), ('uncle', 0.446)]
   actor    - man      + woman    -> [('actress', 0.992), ('queen', 0.459), ('empress', 0.429)]
   prince   - boy      + girl     -> [('princess', 1.028), ('waitress', 0.484), ('quiet', 0.417)]
   emperor  - king     + queen    -> [('empress', 0.991), ('niece', 0.488), ('nun', 0.469)]
   nephew   - uncle    + aunt     -> [('niece', 0.981), ('big', 0.518), ('empress', 0.46)]

【四】对照:没训练过的随机向量(同样做 king-man+woman)
   最近邻 top-3 -> [('palace2', 0.768), ('city', 0.722), ('rules', 0.649)]

【五】全词表 softmax vs 负采样,算一笔账
   词表     10000:全 softmax 每步约  6.400e+05 次浮点运算;负采样小 1666 倍,每步 384 次
   词表    100000:全 softmax 每步约  6.400e+06 次浮点运算;负采样小 16666 倍,每步 384 次
   词表   3000000:全 softmax 每步约  1.920e+08 次浮点运算;负采样小 500000 倍,每步 384 次

【六】数值核对梯度公式 d(loss)/d(v_c) = (sigmoid(u·v_c) - 1) * u  [正样本]
   解析梯度与数值梯度的最大偏差 = 7.851e-11

读输出(这一段是本页最重要的"读结果"训练):

  1. 【一】验证了"相似词相似"。 $\cos(\text{king},\text{queen})=+0.937$,
    而 $\cos(\text{king},\text{prays})=+0.280$。注意 king 和 man 只有 $+0.292$,
    模型正确地认为"国王"和"男人"不是一类东西(它们是同一性别,但不同身份)。
    这正是我们想要的:性别维度被从身份维度里分出来了。
  2. 【二】最近邻是语义的。 king 的第一名是 queen,第二名之后全是其他成对词。
    有意思的是 woman 的邻居里有 live、in 这种虚词,因为语料太小,虚词没被
    充分下采样掉。这是小语料的典型症状,真实语料上不会这样。
  3. 【三】六组类比全部命中。而且能看到除了第一名之外,第二名之后的分数掉得很快
    ($1.01 \to 0.448$),说明这个方向确实是尖锐的,不是碰巧。
  4. 【四】是对照组,必须看。同样的算式,用没训练过的随机向量跑,得到的是
    palace2 / city / rules,完全是噪声。这一行排除了"这个结果是不是随便就能
    得到"的疑问:训练是必要的,方向是学出来的。
  5. 【五】把"负采样为什么重要"变成了数字。真实词表 $3\times10^6$ 时,
    全 softmax 每一步要 $1.92\times10^8$ 次浮点运算,负采样只要 384 次,
    差 50 万倍。这就是 §2.2.3 那个推导的实际价值。
  6. 【六】验证了推导。解析梯度 $\big(\sigma(\mathbf{u}^\top\mathbf{v}_c)-1\big)\mathbf{u}$
    和数值梯度(中心差分)的偏差是 $7.9\times10^{-11}$,也就是双精度浮点的舍入误差。
    §2.2.3 的梯度公式是对的,可以放心用。

3.2.5 king - man + woman ≈ queen 到底是怎么来的

这是最常被神秘化的一个现象,其实机制很朴素。关键是把向量空间看成一组"方向"。

在我们构造的语料里,有两条几乎正交的信息:身份(王室 / 普通人 / 亲属 / 职业)
和性别。训练结束后,每个词的向量近似是这两条的叠加:

      v(king)   ≈   身份(王室)  +  性别(男性)     + 噪声
      v(man)    ≈   身份(普通)  +  性别(男性)     + 噪声
      v(woman)  ≈   身份(普通)  +  性别(女性)     + 噪声

  于是:

      v(king) - v(man) + v(woman)

        = [身份(王室) + 性别(男性)]
        - [身份(普通) + 性别(男性)]        <- 性别(男性) 抵消
        + [身份(普通) + 性别(女性)]

        = 身份(王室) + 性别(女性)          <- 正是 v(queen) 的内容

所以这个"算术"并非巧合,它来自"向量可以线性分解成若干语义方向"这个假设。
它成立的条件有三条,缺一不可:

  1. 语义确实是可加的(至少近似):词义 = 若干正交属性的和;
  2. 每一个属性方向在语料里有一致的证据:性别方向靠 he/she 的共现模式,
    身份方向靠各自的场景词;
  3. 语料足够大,噪声被平均掉。

在真实语料上这个性质有多强? 老实说:比教科书说的弱得多。
- Mikolov et al. (2013b) 报告的类比准确率是约 70%(在 Google 的
$10^{11}$ 词级语料上,语义类比子集);
- 后续工作(如 Linzen 2016 "Issues in evaluating semantic spaces using word
analogies",出处待查确切的会议信息)指出:这个数字对"排除法"极敏感,
如果允许答案就是输入的三个词之一,准确率会虚高;正确地排除输入词后,数字会掉。
本页代码里 near(vec, exc=(...)) 那个参数就是在做这个排除;
- 更要命的是:如果随机初始化不训练,直接用随机向量的算术性质也能在排除法下
拿到不低的分数(这是"类比任务本身有偏"的证据)。本页【四】的对照就是在防这一点。

结论:king - man + woman 演示的是一个真实存在但被夸大的性质。
记住它的三个成立条件,尤其是第 1 条,它是假设,不是定理。
§2.2.6 会讲它在什么情况下彻底失效。

3.2.6 边界与批评

(a) 多义性无处安放。一个词只有一个向量。"苹果"(水果 / 公司)、
"bank"(河岸 / 银行)在词向量里被平均成一个点,落在两个义项中间,
两个都不像。这是静态词向量的理论天花板,也是第三代表示(§2.3)出场的唯一理由。

(b) 偏见会被原样学进去,还会被算术放大。 Bolukbasi et al. (2016, NeurIPS,
"Man is to Computer Programmer as Woman is to Homemaker?") 系统地展示了:
在 Google News 词向量上,
$$\text{doctor} - \text{man} + \text{woman} \approx \text{nurse}$$
也就是说性别方向在职业词上是一个真实存在的线性方向,因为语料里就是这么写的
("男医生"报道得多,"女护士"报道得多)。而且这个方向可以被算术操作。
这有应用价值,也有风险。这不是模型的 bug,是语料的统计事实被忠实记录了。
(顺带一提:那篇论文的标题本身就是一个"该不该说出口"的争议,值得一读。)

(c) 词表外的词没辙。 OOV(out-of-vocabulary)词要么映射到 <unk>,要么
靠字符级特征硬凑。BERT 之后的模型用子词(§2.4)缓解了这个问题。

(d) 它到底学到了什么? 有争议。一种观点是"它学到了意义";另一种是
"它只学到了共现的几何结构,而共现 $\ne$ 意义"(Bender & Koller 2020,
ACL, "Climbing towards NLU", arXiv:2005.04118)。这个争论在 §8.3 会展开。

(e) 上游任务的一个隐患:词向量会被下游模型当作"事实"来用。
如果你用职业报道有偏的语料训练词向量,再拿去做简历筛选,模型会复现并放大歧视。
而且它看起来"客观"(只是数学),这是最危险的地方。

3.3 第三代:上下文化表示(ELMo → BERT)

要解决的问题只有一个:让同一个词在不同上下文里得到不同的向量。

先用一个手算级的例子看清楚"静态"到底缺了什么:

import numpy as np

# 手工设定的 4 维"静态词向量"(为了让算术过程看得见,故意设得很小)
V = {
    "river": np.array([1.0, 0.0, 0.2, 0.0]),
    "water": np.array([0.8, 0.1, 0.3, 0.0]),
    "money": np.array([0.0, 1.0, 0.0, 0.3]),
    "loan":  np.array([0.1, 0.9, 0.0, 0.2]),
    "bank":  np.array([0.5, 0.5, 0.1, 0.15]),   # 两个义项各占一半,被"平均"掉了
}
norm = lambda x: x / np.linalg.norm(x)
cos = lambda a, b: float(norm(a) @ norm(b))

print("静态向量(一个词只有一个向量,无论出现在什么句子里):")
for w in ("bank", "river", "money"):
    print("   %-6s = %s" % (w, V[w]))

print("\n「bank」与两个义项的静态相似度(完全相同,因为静态词向量看不到上下文):")
print("   cos(bank, river) = %+.3f" % cos(V["bank"], V["river"]))
print("   cos(bank, money) = %+.3f" % cos(V["bank"], V["money"]))

print("\n现在做一个最朴素的「上下文化」:新向量 = 0.5 * 静态向量 + 0.5 * 上下文平均")
SENTS = [("she goes to the river bank", ["river", "water"]),
         ("she goes to the bank for money", ["money", "loan"])]
for sent, ctx in SENTS:
    c = np.mean([V[w] for w in ctx], axis=0)
    new = 0.5 * V["bank"] + 0.5 * c
    print("\n   句子:%s" % sent)
    print("   上下文向量 = mean(%s) = %s" % ("+".join(ctx), np.round(c, 3)))
    print("   新的 bank  = %s" % np.round(new, 3))
    print("   cos(新bank, river) = %+.3f   cos(新bank, money) = %+.3f"
          % (cos(new, V["river"]), cos(new, V["money"])))
print("\n结论:同一个词在两个句子里得到了不同的向量,而且各自靠近自己那个义项。")

实跑输出

静态向量(一个词只有一个向量,无论出现在什么句子里):
   bank   = [0.5  0.5  0.1  0.15]
   river  = [1.  0.  0.2 0. ]
   money  = [0.  1.  0.  0.3]

「bank」与两个义项的静态相似度(完全相同,因为静态词向量看不到上下文):
   cos(bank, river) = +0.699
   cos(bank, money) = +0.715

现在做一个最朴素的「上下文化」:新向量 = 0.5 * 静态向量 + 0.5 * 上下文平均

   句子:she goes to the river bank
   上下文向量 = mean(river+water) = [0.9  0.05 0.25 0.  ]
   新的 bank  = [0.7   0.275 0.175 0.075]
   cos(新bank, river) = +0.929   cos(新bank, money) = +0.367

   句子:she goes to the bank for money
   上下文向量 = mean(money+loan) = [0.05 0.95 0.   0.25]
   新的 bank  = [0.275 0.725 0.05  0.2  ]
   cos(新bank, river) = +0.348   cos(新bank, money) = +0.937

结论:同一个词在两个句子里得到了不同的向量,而且各自靠近自己那个义项。

读输出:静态条件下,$\cos(\text{bank},\text{river})=0.699$ 与
$\cos(\text{bank},\text{money})=0.715$,几乎一样,也就是说静态向量
根本无法区分这两个义项。加了上下文之后,同一组数字变成 $+0.929$ vs $+0.367$
和 $+0.348$ vs $+0.937$,区别立刻出来了。

这个实验是手工设定的,只演示机制。真实的第三代表示做的是同一件事,但:

上下文向量怎么来 权重怎么定 代表
上面这个玩具 周围词向量的算术平均 我定的 0.5 —
ELMo 双向 LSTM 的每一层输出 学出来的(加权求和) Peters et al. 2018
BERT Transformer 每层的上下文表示 层间通过预训练自动分工 Devlin et al. 2019

ELMo(Peters et al., 2018, NAACL, arXiv:1802.05365)的核心洞察有两条:
1. 用双向,一个词的意义同时取决于左边和右边("咬死了猎人的狗"那个例子就是);
2. 不同层学的信息不一样,底层偏词形/句法,高层偏语义。所以 ELMo 不只用最后一层,
而是把所有层的输出加权求和,权重在下游任务上学。这个"用第几层"的问题
后来变成了 NLP 的常规操作。

BERT(Devlin et al., 2019, NAACL, arXiv:1810.04805)换掉了 LSTM,用
Transformer 编码器(结构和推导见《深度学习》§8),并设计了两个预训练任务:
- MLM(掩码语言模型):随机遮住 15% 的 token,让模型猜。这解决了双向模型的
"作弊"问题,单向语言模型里每个词只能看左边,双向如果直接预测当前词就等于抄答案。
- NSP(下一句预测):判断两句话是不是连续的。后来的工作(RoBERTa, Liu et al.
2019, arXiv:1907.11692)发现这个任务没什么用,去掉反而更好。

ELMo 和 BERT 的关键差别:ELMo 是"特征提取器",预训练完冻结住,把它当
特征喂给下游模型;BERT 是"预训练 + 微调",整个模型在下游任务上继续训练。
后者成了主流,§6 会专门讲这个范式为什么改变了 NLP。

3.4 补一块砖:子词切分(为什么 BERT 里没有"词")

到 BERT 这一代,"词"这个概念在工程上已经被拆掉了。原因有两个:
1. 词表爆炸:自然语言的词形组合近乎无限(黏着语、派生、专名、拼写错误);
2. OOV 无解:没见过的新词怎么办?

字节对编码(BPE,Sennrich et al., 2016, ACL, arXiv:1508.07909)的做法:

初始:所有词拆成单个字符(或字节)

   "low"  ->  l o w
   "lower"->  l o w e r
   "newest"-> n e w e s t

第一步:统计所有相邻字符对,把出现最多的那对合并成一个新单元
        假设 "e"+"s" 出现最多 -> 得到新单元 "es"

重复:  每轮合并一个最高频的相邻对,直到词表达到预设大小(如 30000)

结果:
   "low"   ->  low
   "lower" ->  low + er            (er 是高频后缀)
   "newest"->  new + est

关键在于:高频词保留为整词,低频词被拆成常见的片段。这样:
- 词表固定(比如 30000),不会爆炸;
- 任何没见过的字符串都能表示(最差情况拆成单字符);
- 顺带恢复了一部分形态学信息,"er"、"est"、"ing" 会作为独立单元存在,
和语言学里的语素(《语言学概论》§2 的层级)不谋而合。

WordPiece(BERT 用的,Schuster & Nakamura 2012)和 BPE 略有不同(它按
似然而不是频率来选合并对),但思路一样。

一个值得记住的观察:BPE 是从工程需求出发被发明出来的(压缩词表 + 处理 OOV),
结果自动摸到了语素的边界。这是本课反复出现的一个模式:
工程约束会逼着你重新发现语言学结构,但发现的方式和语言学家完全不同。


4 语言模型:从 n-gram 到困惑度

4.1 语言模型要解决什么问题

语言模型(LM)只做一件事:给一个词序列打一个概率分。

$$P(w_1, w_2, \dots, w_N)$$

听起来很简单,但它是整门课的主干:机器翻译要挑"译文概率最高的那个",
语音识别要用它消歧("识别" vs "十别",哪个更像人话),
拼写纠错要用它("teh" 在 "the" 的上下文里概率高),
今天的大语言模型本身就是一个语言模型(只是大到能直接当任务求解器用)。

怎么算这个联合概率? 用链式法则,没有任何近似:

$$P(w_1,\dots,w_N) = \prod_{i=1}^{N} P(w_i \mid w_1, \dots, w_{i-1})$$

逐项读:$P(w_i \mid w_1,\dots,w_{i-1})$ 是"看到前面所有词,下一个词是 $w_i$ 的概率"。
这个式子数学上完全正确,但工程上不可用,因为条件部分太长了,
每个不同的历史都是一种情况,语料里不可能每种都见过。所以必须近似。

4.2 n-gram:最粗暴也最有效的近似

马尔可夫假设:第 $i$ 个词只依赖前面 $n-1$ 个词,再早的忘掉。

$$P(w_i \mid w_1,\dots,w_{i-1}) \approx P(w_i \mid w_{i-n+1},\dots,w_{i-1})$$

参数怎么估? 最大似然估计(MLE)就是直接数数:

$$P(w_i \mid w_{i-1}) = \frac{\mathrm{count}(w_{i-1}, w_i)}{\mathrm{count}(w_{i-1})}$$

为什么要加平滑? 因为 MLE 会给没见过的 n-gram 概率 0。而
- 概率 0 意味着整个句子的概率是 0(乘法里有一个 0 全毁);
- 困惑度(下面会讲)会变成无穷大;
- 更讽刺的是:$n$ 越大,模型在训练集上越准,在测试集上越多零,
因为 $n$-gram 的组合数是指数增长的,语料再大也只覆盖极小一部分。
这是"过拟合"在语言模型上最直白的样子。

平滑的做法:从高频事件那里"匀"一点概率给没见过的。最简单的加 $k$ 平滑
($k=1$ 时叫 Laplace 平滑):

$$P(w_i \mid w_{i-1}) = \frac{\mathrm{count}(w_{i-1},w_i) + k}{\mathrm{count}(w_{i-1}) + k \cdot V}$$

逐项读:分子加 $k$,保证任何 n-gram 的计数至少是 $k$,概率不为 0;
分母加 $k\cdot V$,$V$ 是词表大小,因为分子上每个可能的 $w_i$(共 $V$ 个)
都被加了 $k$,总共多出来 $kV$,分母必须补上这个量,否则所有概率加起来不是 1。
这个 $kV$ 是最容易写错的地方。

更好的平滑(真实系统用这些):Good-Turing(Good 1953)、Katz 回退
(Katz 1987)、Kneser-Ney(Kneser & Ney 1995;Chen & Goodman 1999 的
系统比较)。Kneser-Ney 的核心思想很漂亮:一个 n-gram 好不好,不看它出现多少次,
而看它能接在多少个不同的上下文后面。比如 "Francisco" 出现很多次,
但永远只在 "San" 后面,它的延续计数(continuation count)很低,
所以它在"预测下一个词"这件事上其实没什么用。本页不实现它,
但你要知道现代 n-gram 系统的标准配置是 modified Kneser-Ney。

4.3 困惑度:语言模型的唯一核心指标

定义:给测试集 $W = w_1 w_2 \dots w_N$,模型的困惑度是

$$\mathrm{PPL}(W) = \exp\left(-\frac{1}{N}\sum_{i=1}^{N} \log P(w_i \mid w_1,\dots,w_{i-1})\right) = P(W)^{-1/N}$$

逐项读这个式子,它是全页的核心公式:

困惑度的直观含义(这一句最重要):

困惑度 = 模型在每一步平均"在多少个词之间犹豫"。

这个解释和第一定义为什么等价? 因为 $\exp(\cdot)$ 和 $\log$ 是逆运算:
若模型每一步都在 $k$ 个等可能的词里均匀猜,则每步概率 $1/k$,
$-\log(1/k) = \log k$,平均之后 $\exp(\log k) = k$。$\mathrm{PPL}$ 就是把这个
"等价的 $k$" 从一般情形里反解出来。

另一个等价说法:$\log_2 \mathrm{PPL}$ 就是每个词平均多少 bit的信息量。
$\mathrm{PPL}=64$ 意味着每词 6 bit。

4.3.1 手算一个困惑度(拿纸笔跟着做)

语料(训练集,6 句):

the cat sat on the mat
the dog sat on the log
the cat ate the fish
the dog ate the bone
a cat sat on a mat
a dog sat on a log

第一步:数 unigram。所有词次共 40。计数:the 8 次,cat 3 次,sat 4 次,
on 4 次,ate 2 次,bone 1 次,其余略。词表大小 $V = 11$(a ate bone cat dog fish log mat on sat the)。

第二步:加 1 平滑算每个词的概率。公式
$P(w) = \dfrac{\mathrm{count}(w)+1}{\text{总词次} + V} = \dfrac{\mathrm{count}(w)+1}{40+11}$:

词 count 分子 概率
the 8 9 $9/51 = 0.176471$
cat 3 4 $4/51 = 0.078431$
sat 4 5 $5/51 = 0.098039$
on 4 5 $5/51 = 0.098039$
bone 1 2 $2/51 = 0.039216$

第三步:算测试句 "the cat sat on the bone" 的困惑度。

$$\mathrm{PPL} = \exp\left(-\frac{\log 0.176471 + \log 0.078431 + \log 0.098039 + \log 0.098039 + \log 0.176471 + \log 0.039216}{6}\right)$$

分子(用自然对数):$-1.7346 + (-2.5455) + (-2.3224) + (-2.3224) + (-1.7346) + (-3.2387) = -13.8982$

$$\mathrm{PPL} = \exp\left(\frac{13.8982}{6}\right) = \exp(2.3164) = 10.139$$

读这个数字:$10.139$ 意味着"模型平均在 10 个词之间犹豫",而词表是 11 个词。
也就是说:这个 unigram 模型几乎没学到任何东西(它只是在猜词的频率)。
对照均匀分布的 $\mathrm{PPL} = V = 11$,只好了那么一点点。

为什么会这样? 因为 unigram 完全不用上下文。bone 这个词不管前面是什么,
概率都一样。而加 1 平滑在这么小的词表上撒了太多概率出去($11/51 \approx 21.6\%$
的概率质量被均分给了没见过的组合),进一步拉高了 PPL。
这正是一个"平滑太强"的教学案例:$k$ 不是越大越好。

下面用代码把上面每一步都验算一遍,并且看 $n$ 和平滑系数 $k$ 怎么影响 PPL。

4.4 实跑:n-gram、平滑与困惑度

import math
from collections import Counter

TRAIN = [
    "the cat sat on the mat", "the dog sat on the log",
    "the cat ate the fish",   "the dog ate the bone",
    "a cat sat on a mat",     "a dog sat on a log",
]
TEST = [
    "the cat sat on the log",     # 每个 n-gram 都在训练集里出现过
    "the cat sat on the bone",    # bigram 都见过,但 trigram「on the bone」没见过
]
tok = lambda s: s.split()
train_tokens = [tok(s) for s in TRAIN]
vocab = sorted({w for s in train_tokens for w in s})
V = len(vocab)
print("训练语料 %d 句,词表 %d 词:%s" % (len(TRAIN), V, " ".join(vocab)))

def counts(n):
    c, ctx = Counter(), Counter()
    for s in train_tokens:
        s = ["<s>"] * (n - 1) + s + ["</s>"]        # 句首句尾补符号
        for i in range(len(s) - n + 1):
            g = tuple(s[i:i + n]); c[g] += 1; ctx[g[:-1]] += 1
    return c, ctx

def perplexity(sent, n, k):
    c, ctx = counts(n)
    s = ["<s>"] * (n - 1) + tok(sent) + ["</s>"]
    tot, N = 0.0, 0
    for i in range(n - 1, len(s)):
        hist = tuple(s[i - (n - 1):i]) if n > 1 else ()
        num = c.get(hist + (s[i],), 0) + k            # 分子:加 k
        den = ctx.get(hist, 0) + k * V                # 分母:加 k*V
        tot += math.log(num / den); N += 1
    return math.exp(-tot / N)

print("\n【一】平滑系数 k 对困惑度的影响(测试句 1:所有 n-gram 都见过)")
print("   n \\ k       0.0001       0.01        0.1        1.0")
for n in (1, 2, 3):
    print("   %d   " % n + "".join("  %10.3f" % perplexity(TEST[0], n, k)
                                   for k in (1e-4, 0.01, 0.1, 1.0)))

print("\n【二】训练句 vs 测试句(k=0.01):看「见过」和「没见过」的差别")
print("   n   训练句(见过)  测试句1(见过)  测试句2(trigram没见过)")
for n in (1, 2, 3):
    print("   %d   %11.3f  %12.3f  %12.3f"
          % (n, perplexity(TRAIN[3], n, 0.01), perplexity(TEST[0], n, 0.01),
             perplexity(TEST[1], n, 0.01)))

print("\n【三】不加平滑(k=0)会怎样")
for n in (2, 3):
    c, _ = counts(n)
    for sent in TEST:
        s = ["<s>"] * (n - 1) + tok(sent) + ["</s>"]
        miss = [tuple(s[i:i + n]) for i in range(len(s) - n + 1)
                if c.get(tuple(s[i:i + n]), 0) == 0]
        if miss:
            print("   n=%d  「%s」" % (n, sent))
            print("        没见过的 %d-gram:%s -> 该步概率为 0" % (n, miss))
            print("        未平滑时 PPL = inf(log 0);加 k 平滑后 PPL = %.3f"
                  % perplexity(sent, n, 0.01))
        else:
            print("   n=%d  「%s」 PPL(k=0) = %.3f" % (n, sent, perplexity(sent, n, 0.0)))

print("\n【四】手算核对:unigram + 加 1 平滑,测试句 the cat sat on the bone")
c, _ = counts(1)
total_tokens = sum(c.values())
seq = ["the", "cat", "sat", "on", "the", "bone"]
tot = 0.0
for w in seq:
    p = (c.get((w,), 0) + 1) / (total_tokens + V)
    tot += math.log(p)
    print("      P(%-5s) = (%d + 1) / (%d + %d) = %.6f" % (w, c.get((w,), 0), total_tokens, V, p))
print("   PPL = exp(-(%.4f) / %d) = exp(%.4f) = %.4f"
      % (tot, len(seq), -tot / len(seq), math.exp(-tot / len(seq))))
print("   对照:均匀分布在 %d 个词上的模型,PPL = %d" % (V, V))

print("\n【五】困惑度的「有效词表」解释")
for ppl in (2.034, 3.173, 8.908, 11.216, 64.0):
    print("   PPL = %6.3f  ->  平均每个词 %.3f bit(log2 PPL)" % (ppl, math.log2(ppl)))

实跑输出

训练语料 6 句,词表 11 词:a ate bone cat dog fish log mat on sat the

【一】平滑系数 k 对困惑度的影响(测试句 1:所有 n-gram 都见过)
   n \ k       0.0001       0.01        0.1        1.0
   1          8.906       8.908       8.928       9.127
   2          2.034       2.079       2.457       4.838
   3          1.575       1.641       2.173       5.129

【二】训练句 vs 测试句(k=0.01):看「见过」和「没见过」的差别
   n   训练句(见过)  测试句1(见过)  测试句2(trigram没见过)
   1        10.996         8.908         9.829
   2         2.366         2.079         2.093
   3         1.594         1.641         3.173

【三】不加平滑(k=0)会怎样
   n=2  「the cat sat on the log」 PPL(k=0) = 2.034
   n=2  「the cat sat on the bone」 PPL(k=0) = 2.034
   n=3  「the cat sat on the log」 PPL(k=0) = 1.575
   n=3  「the cat sat on the bone」
        没见过的 3-gram:[('on', 'the', 'bone')] -> 该步概率为 0
        未平滑时 PPL = inf(log 0);加 k 平滑后 PPL = 3.173

【四】手算核对:unigram + 加 1 平滑,测试句 the cat sat on the bone
      P(the  ) = (8 + 1) / (40 + 11) = 0.176471
      P(cat  ) = (3 + 1) / (40 + 11) = 0.078431
      P(sat  ) = (4 + 1) / (40 + 11) = 0.098039
      P(on   ) = (4 + 1) / (40 + 11) = 0.098039
      P(the  ) = (8 + 1) / (40 + 11) = 0.176471
      P(bone ) = (1 + 1) / (40 + 11) = 0.039216
   PPL = exp(-(-13.8982) / 6) = exp(2.3164) = 10.1387
   对照:均匀分布在 11 个词上的模型,PPL = 11

【五】困惑度的「有效词表」解释
   PPL =  2.034  ->  平均每个词 1.024 bit(log2 PPL)
   PPL =  3.173  ->  平均每个词 1.666 bit(log2 PPL)
   PPL =  8.908  ->  平均每个词 3.155 bit(log2 PPL)
   PPL = 11.216  ->  平均每个词 3.487 bit(log2 PPL)
   PPL = 64.000  ->  平均每个词 6.000 bit(log2 PPL)

读输出(五个发现,每个都是考点):

  1. 【一】$n$ 越大,PPL 越低。同一个测试句:unigram 8.91 → bigram 2.08 →
    trigram 1.64。原因:bigram 知道了 "the 后面多半跟名词",
    trigram 还知道 "on the 后面接的是 mat/log"。"用更多上下文"确实有用,
    这就是后来所有神经语言模型的出发点。
  2. 【一】$k$ 不是越大越好,而且 $n$ 越大越怕大 $k$。 $k=1$ 时 trigram 的
    PPL 从 1.641 涨到 5.129,平滑把好不容易学到的东西又抹平了。
    原因:$k$ 越大,越接近均匀分布。平滑是在"防零"和"保信息"之间做权衡。
  3. 【二】第 3 列揭示了 n-gram 的死穴。测试句 2 只有 trigram 没见过,
    结果 ${n=2}$ 的 PPL 是 2.093,${n=3}$ 反而涨到 3.173。因为 $n$ 越大,
    遇到没见过的 n-gram 的概率越高,此时只能靠平滑给的"垃圾概率"。
    这就是 §3.2 说的指数增长问题。
  4. 【三】这是"为什么要平滑"的实锤。未平滑的 trigram 遇到没见过的
    (on, the, bone),概率是 0,整句 PPL 直接变成 $\infty$。
    注意:一个 3-gram 的缺失就能毁掉整句话。这就是乘积形式的脆弱性。
  5. 【四】手算与代码完全一致(10.1387),而且数字本身说明了问题:
    $10.14$ 对 $V=11$ 的均匀基线只好了 8%。可见 unigram 有多弱,
    加 1 平滑在小词表上有多浪费。

一个必须建立的直觉:PPL 只在"模型、词表、测试集"三者都相同时才能比较。
换了分词方式(比如从词切改成 BPE 子词),词表变了,PPL 数字之间没有任何可比性,
因为"每一步犹豫多少"里的"一步"都不一样长。这是一个极常见的外行错误。
论文里报 PPL 时必须同时报分词方案和词表大小。

4.5 神经语言模型:从 n-gram 到 RNN 到 Transformer

n-gram 的三大问题:(a)上下文窗口固定且极短(trigram 只看 2 个词);
(b)参数随 $n$ 指数爆炸;(c)完全不知道"相似词":
"猫在垫子上"和"猫咪在垫子上"在 n-gram 眼里毫无关系。

神经语言模型(Bengio et al. 2003, JMLR, "A Neural Probabilistic Language
Model")一次性解决这三个问题:
1. 用词向量代替 one-hot(解决 c,顺带是 §2.2 词向量的思想源头之一);
2. 用连续空间里的函数代替计数表(解决 b);
3. 用 RNN / Transformer 把上下文窗口变成"变长的"(解决 a)。

代价是:$P(w_i \mid \cdot)$ 的计算变成了 $O(Vd)$(要过 softmax),
又回到了 §2.2.3 的老问题。解决办法还是负采样/层次 softmax,
或者干脆不做归一化(后来的 GPT 系列直接用完整 softmax,因为算力够了)。

RNN 语言模型的推导见《深度学习》§7;Transformer 语言模型见 §8。
这里只给结论:今天的语言模型(GPT 系列)就是一个超大的 Transformer
解码器,训练目标就是 §3.3 那个 $\log P$ 的相反数,交叉熵。
换句话说,你这一节手算的困惑度,正是训练最大的那几个模型时用的损失函数。

4.6 边界与批评

(a) PPL 不衡量实用性。两个模型 PPL 差 0.5,可能对下游任务毫无影响;
反过来 PPL 相近的模型在某些具体现象上差别巨大。PPL 是必要的,但不是充分的。

(b) PPL 会奖励"学舌"。模型如果见过测试集(数据泄漏),PPL 会非常漂亮。
所以 PPL 必须在真正没见过的文本上算。

(c) 词表不匹配就没法比。上面那条,不再重复,但它是最高频的错误。

(d) 神经语言模型把"困惑度"和"理解"的关系搞得更模糊了。 GPT-2/GPT-3 的
PPL 远低于任何 n-gram 模型,但它们在常识推理上的错误同样惊人。
低困惑度 $\ne$ 理解(Bender & Koller 2020)。这个问题在 §8.3 会正面处理。


5 序列标注:分词、词性标注、NER

5.1 三个任务,一个形式

序列标注是 NLP 里最普遍的任务形式:输入一个词/字序列,输出等长的标签序列。

任务 输入 输出标签 难点
中文分词 字序列 B/M/E/S(词首/词中/词尾/单字) 歧义切分、OOV 词
词性标注 词序列 名词/动词/形容词/… 兼类词("研究"名动两用)
命名实体识别(NER) 词序列 B-PER/I-PER/O/B-ORG/… 实体边界、嵌套实体、新实体

中文分词用 B/M/E/S 标记体系的原理:不直接输出"切在哪里",而是给每个字
一个位置标签,然后按 B…E 拼成词。为什么这样做?因为"每个字的标签"可以用
序列模型来做,而"切分位置"是一个结构化的输出,不好直接建模。
这是 NLP 里一个反复出现的技巧:把结构化输出问题转化成逐位置的分类问题。

   我   爱   北   京   大   学
   S    S    B    E    B    E
   └─词─┘ └─词─┘ └───词───┘
   我     爱     北京    大学

NER 用同一套:

   张   三   在   北京   大学   工作
   B-PER I-PER O  B-LOC  I-LOC  O     <- 注意「北京」是 B-LOC,「大学」不是实体

5.2 HMM:序列标注的第一个概率模型

隐马尔可夫模型(HMM) 的设定:有一个看不见的标签序列
$t_1, t_2, \dots, t_n$(比如 B/M/E/S),我们看到的只有字序列
$c_1, c_2, \dots, c_n$。

HMM 用两个假设把它变成可算的:

假设一(马尔可夫性):标签只依赖前一个标签:
$$P(t_i \mid t_1,\dots,t_{i-1}) = P(t_i \mid t_{i-1})$$

假设二(输出独立性):第 $i$ 个字只依赖第 $i$ 个标签,和其他字无关:
$$P(c_i \mid t_1,\dots,t_n, c_1,\dots,c_n) = P(c_i \mid t_i)$$

这两条假设都是假的。汉语里"北京"的"京"当然和"北"有关(这就是为什么
模型能学到构词),但 HMM 假装它们独立,靠标签之间的转移间接建模。
这是本页第二个"错假设换取可计算性"的例子(第一个是 skip-gram 的邻居独立)。

于是整个句子的联合概率是:

$$P(c_{1:n}, t_{1:n}) = \underbrace{P(t_1)}_{\pi}\prod_{i=2}^{n}\underbrace{P(t_i \mid t_{i-1})}_{A}\prod_{i=1}^{n}\underbrace{P(c_i \mid t_i)}_{B}$$

三个参数各有名字:$\pi$ 是初始状态分布,$A$ 是状态转移矩阵,
$B$ 是发射矩阵。它们全部可以用数数 + 平滑估计出来(和 n-gram 完全一样):

$$\hat{A}_{t\to t'} = \frac{\mathrm{count}(t \to t') + k}{\mathrm{count}(t) + k|S|}, \qquad \hat{B}_{t\to c} = \frac{\mathrm{count}(t, c) + k}{\mathrm{count}(t) + k|C|}$$

($|S|$ 是标签数 4,$|C|$ 是不同字的个数。分母那个 $k|S|$、$k|C|$ 的理由
和 §3.2 完全相同。)

解码要的是:给定字序列,找概率最大的标签序列
$$\hat{t}_{1:n} = \arg\max_{t_{1:n}} P(c_{1:n}, t_{1:n})$$

穷举是 $4^n$($n=20$ 时是 $10^{12}$,不可行)。Viterbi 算法
(Viterbi 1967)用动态规划做到 $O(n|S|^2)$。

5.3 Viterbi:动态规划的核心两行

定义 $\delta_i(t)$ = "以标签 $t$ 结尾的前 $i$ 个字,最大的路径概率"。

初始化:$\delta_1(t) = \pi(t) \cdot B(t, c_1)$

递推(这是整个算法的心脏):

$$\delta_i(t) = \max_{t' \in S}\Big[\delta_{i-1}(t') \cdot A(t' \to t)\Big] \cdot B(t, c_i)$$

逐项读:
- $\delta_{i-1}(t')$:到第 $i-1$ 个字、以 $t'$ 结尾的最佳路径(已经算好了);
- $\cdot A(t'\to t)$:从 $t'$ 转到 $t$ 的概率;
- $\max_{t'}$:只保留最好的那个前驱,这是动态规划的关键,因为
如果一条路到第 $i$ 步时已经被另一条压过,那它后面无论怎么走都不可能翻盘
(所有路径乘的是同样的后续因子)。这个性质叫最优子结构。
- $\cdot B(t, c_i)$:第 $i$ 个字在标签 $t$ 下发射出 $c_i$ 的概率。

同时记下 $\psi_i(t) = \arg\max_{t'}$(回溯指针),最后从 $\delta_n$ 的最大值
往回走,就得到最优标签序列。

在代码里全部用 log 概率,把乘法变成加法(防止下溢),$\max$ 不变。

5.4 实跑:HMM 分词 vs 正向最大匹配

对照组是"正向最大匹配"(FMM),一个 1980 年代就广泛使用的中文分词方法
(早期中文期刊文献,具体出处待查):从左到右,每次取词表里最长的匹配。
它零训练、极快,是很好的基线。

import math
from collections import Counter, defaultdict

# ---------- 1. 已分词语料(手工,极小) ----------
TRAIN = [
    "我 爱 北京 天安门", "他 爱 北京 大学", "我 在 北京 大学 学习",
    "他 在 图书馆 看书", "北京 是 中国 的 首都", "中国 的 首都 是 北京",
    "我 喜欢 学习 汉语", "她 喜欢 看 书", "我们 是 大学生",
    "图书馆 里 有 很多 书", "他 在 大学 学习 汉语", "我 是 大学生 我 爱 学习",
]
TEST = [("我爱北京大学", "我 爱 北京 大学"),
        ("他在图书馆学习汉语", "他 在 图书馆 学习 汉语"),
        ("我们喜欢看书", "我们 喜欢 看 书"),
        ("我是大学生", "我 是 大学生"),
        ("我爱上海", "我 爱 上海")]          # 「上海」训练集里从未出现过
STATES = ["B", "M", "E", "S"]

def to_bmes(wordlist):
    tags = []
    for w in wordlist:
        if len(w) == 1:
            tags.append("S")
        else:
            tags.append("B"); tags.extend(["M"] * (len(w) - 2)); tags.append("E")
    return list("".join(wordlist)), tags

sent_data = [to_bmes(s.split()) for s in TRAIN]
print("训练语料:%d 句,%d 词" % (len(TRAIN), sum(len(s.split()) for s in TRAIN)))
print("样例:", sent_data[0])

# ---------- 2. 统计 HMM 参数(加 k 平滑) ----------
K = 0.1
chars = sorted({c for cs, _ in sent_data for c in cs}); CH = len(chars)
pi, A, B = Counter(), defaultdict(Counter), defaultdict(Counter)
for cs, ts in sent_data:
    pi[ts[0]] += 1
    for c, t in zip(cs, ts):
        B[t][c] += 1
    for t1, t2 in zip(ts, ts[1:]):
        A[t1][t2] += 1

logA = lambda t1, t2: math.log((A[t1].get(t2, 0) + K) / (sum(A[t1].values()) + K * len(STATES)))
logB = lambda t, c: math.log((B[t].get(c, 0) + K) / (sum(B[t].values()) + K * CH))
logpi = lambda t: math.log((pi.get(t, 0) + K) / (sum(pi.values()) + K * len(STATES)))

print("\n【转移概率 log P(t_next | t_now)】(加 0.1 平滑后)")
print("        " + "".join("%10s" % t for t in STATES))
for t1 in STATES:
    print("   %s  " % t1 + "".join("%10.4f" % logA(t1, t2) for t2 in STATES))

# ---------- 3. Viterbi 解码 ----------
def viterbi(sentence):
    n = len(sentence)
    delta = [{} for _ in range(n)]; psi = [{} for _ in range(n)]
    for t in STATES:
        delta[0][t] = logpi(t) + logB(t, sentence[0]); psi[0][t] = None
    for i in range(1, n):
        for t in STATES:
            best, arg = -1e18, None
            for t_prev in STATES:                       # 只看前一个标签:O(n|S|^2)
                sc = delta[i - 1][t_prev] + logA(t_prev, t)
                if sc > best:
                    best, arg = sc, t_prev
            delta[i][t] = best + logB(t, sentence[i]); psi[i][t] = arg
    best, arg = -1e18, None
    for t in STATES:
        if delta[n - 1][t] > best:
            best, arg = delta[n - 1][t], t
    tags = [arg]
    for i in range(n - 1, 0, -1):
        tags.append(psi[i][tags[-1]])
    tags.reverse()
    words, cur = [], ""
    for c, t in zip(sentence, tags):                    # 按 BMES 还原成词
        cur += c
        if t in ("E", "S"):
            words.append(cur); cur = ""
    if cur:
        words.append(cur)
    return words, "".join(tags)

# ---------- 4. 正向最大匹配 ----------
DICT = {w for s in TRAIN for w in s.split()}
MAXLEN = max(len(w) for w in DICT)
def fmm(sentence):
    out, i = [], 0
    while i < len(sentence):
        for L in range(min(MAXLEN, len(sentence) - i), 0, -1):
            if sentence[i:i + L] in DICT:
                out.append(sentence[i:i + L]); i += L; break
        else:
            out.append(sentence[i]); i += 1
    return out

# ---------- 5. 评测:按"词的边界"算 P/R/F1 ----------
def boundaries(words):
    out, i = set(), 0
    for w in words:
        out.add((i, i + len(w))); i += len(w)
    return out

print("\n【分词结果对照】")
TP = {"HMM": 0, "FMM": 0}; NP = {"HMM": 0, "FMM": 0}; NG = 0
for raw, gold_str in TEST:
    gold = gold_str.split()
    p_hmm, tags = viterbi(raw); p_fmm = fmm(raw)
    bg = boundaries(gold); bh = boundaries(p_hmm); bf = boundaries(p_fmm)
    TP["HMM"] += len(bh & bg); NP["HMM"] += len(bh)
    TP["FMM"] += len(bf & bg); NP["FMM"] += len(bf); NG += len(bg)
    print("   原句    : %s" % raw)
    print("   标准    : %s" % " ".join(gold))
    print("   HMM     : %s    (BMES 标签 %s)" % (" ".join(p_hmm), tags))
    print("   最大匹配 : %s" % " ".join(p_fmm))
    print("   本例命中 : HMM %d/%d  最大匹配 %d/%d\n"
          % (len(bh & bg), len(bg), len(bf & bg), len(bg)))

print("【汇总(%d 句测试,共 %d 个标准词)】" % (len(TEST), NG))
print("   方法          P       R      F1")
for name in ("HMM", "FMM"):
    P = TP[name] / NP[name]; R = TP[name] / NG; F = 2 * P * R / (P + R)
    print("   %-10s  %.3f   %.3f   %.3f"
          % ("HMM" if name == "HMM" else "正向最大匹配", P, R, F))

print("\n【OOV 会怎样:测试句里有训练集从未出现的字/词】")
for raw in ["他在大学读语言学", "图书馆里有很多书"]:
    print("   「%s」" % raw)
    print("      HMM      : %s" % " ".join(viterbi(raw)[0]))
    print("      最大匹配  : %s" % " ".join(fmm(raw)))

实跑输出

训练语料:12 句,54 词
样例: (['我', '爱', '北', '京', '天', '安', '门'], ['S', 'S', 'B', 'E', 'B', 'M', 'E'])

【转移概率 log P(t_next | t_now)】(加 0.1 平滑后)
                 B         M         E         S
   B     -5.6490   -1.7171   -0.2066   -5.6490
   M     -3.9890   -3.9890   -0.0572   -3.9890
   E     -0.7041   -5.2149   -5.2149   -0.7041
   S     -0.4799   -5.4972   -5.4972   -0.9863

【分词结果对照】
   原句    : 我爱北京大学
   标准    : 我 爱 北京 大学
   HMM     : 我 爱 北京 大学    (BMES 标签 SSBEBE)
   最大匹配 : 我 爱 北京 大学
   本例命中 : HMM 4/4  最大匹配 4/4

   原句    : 他在图书馆学习汉语
   标准    : 他 在 图书馆 学习 汉语
   HMM     : 他 在 图书馆 学习 汉语    (BMES 标签 SSBMEBEBE)
   最大匹配 : 他 在 图书馆 学习 汉语
   本例命中 : HMM 5/5  最大匹配 5/5

   原句    : 我们喜欢看书
   标准    : 我们 喜欢 看 书
   HMM     : 我们 喜欢 看书    (BMES 标签 BEBEBM)
   最大匹配 : 我们 喜欢 看书
   本例命中 : HMM 2/4  最大匹配 2/4

   原句    : 我是大学生
   标准    : 我 是 大学生
   HMM     : 我 是 大学生    (BMES 标签 SSBME)
   最大匹配 : 我 是 大学生
   本例命中 : HMM 3/3  最大匹配 3/3

   原句    : 我爱上海
   标准    : 我 爱 上海
   HMM     : 我 爱 上海    (BMES 标签 SSBE)
   最大匹配 : 我 爱 上 海
   本例命中 : HMM 3/3  最大匹配 2/3

【汇总(5 句测试,共 19 个标准词)】
   方法          P       R      F1
   HMM         0.944   0.895   0.919
   正向最大匹配      0.842   0.842   0.842

【OOV 会怎样:测试句里有训练集从未出现的字/词】
   「他在大学读语言学」
      HMM      : 他 在 大学 读语 言 学
      最大匹配  : 他 在 大学 读 语 言 学
   「图书馆里有很多书」
      HMM      : 图书馆 里 有 很多 书
      最大匹配  : 图书馆 里 有 很多 书

读输出(四个发现):

  1. 转移概率矩阵能直接读出语言学规律。看 logA 那张表:
    - B -> E 是 $-0.2066$(最高),B -> M 是 $-1.7171$。因为汉语双字词最多
    ("北京""大学"),三字词次之("图书馆"),所以 B 后面接 E 远比接 M 常见。
    - E -> B 和 E -> S 都是 $-0.7041$,并列最高:一个词结束后,下一个字
    要么开新词(B),要么是单字词(S),绝不可能是 M 或 E($-5.2149$)。
    - M -> E 是 $-0.0572$:一旦进入词中,几乎必然要结束。这是 BMES 体系的
    结构性约束,模型从数据里自动学到了。
    这说明 HMM 的 $A$ 矩阵不是黑箱,它能读。
  2. HMM 赢在 OOV。"我爱上海"这条:上海 训练集里从未出现,
    最大匹配只能切成"上/海"两个字(因为"上"和"海"都在词表里但"上海"不在,
    而且 FMM 贪心地取最长的匹配,结果就是两个单字)。
    HMM 靠转移概率 $P(B\to E)$ 很高,把"上"和"海"拼成了"上海"。
    它不知道"上海"是什么,但它知道"BB"不常见、"BE"很常见。
    这就是统计模型的价值:OOV 也能合理地猜。最终 F1:HMM 0.919 vs FMM 0.842。
  3. 两者都错在同一个地方:"看书"被并成了一个词。这不是 bug,是真正的歧义。
    "看书"在真实语料里可以是词,也可以是短语,我的训练集里"看 书"是分开的,
    但 HMM 从"喜欢"后面接双字词的统计倾向(以及 B->E 的高概率)把它合并了。
    这个例子说明:分词的"标准答案"本身就有主观性。
    换一个标注规范,答案就变了。这也是为什么不同分词器的 F1 不能直接比较。
  4. HMM 也错得很难看的地方:"读语言学"被切成 读语 / 言 / 学。
    因为 读语 从没出现过,模型完全靠转移概率硬凑,凑出了一个不存在的词。
    HMM 的致命弱点在这里暴露:它只看标签的转移,没有"这个词合不合理"的全局判断
    (每个字的发射概率是独立的,见 §4.2 假设二)。CRF 就是来修这个的。

5.5 更好的一代:CRF 与神经网络方法

条件随机场(CRF,Lafferty, McCallum & Pereira, 2001, ICML)换了一个建模方向:

CRF 的定义是

$$P(t_{1:n} \mid c_{1:n}) = \frac{1}{Z(c)}\exp\left(\sum_{i=1}^{n}\Big[\underbrace{s(t_i, c, i)}_{\text{状态特征}} + \underbrace{f(t_{i-1}, t_i, c, i)}_{\text{转移特征}}\Big]\right)$$

逐项读:
- $s(t_i, c, i)$:状态特征,可以依赖整个输入序列 $c$ 和位置 $i$,
不像 HMM 的发射概率只看 $c_i$ 一个位置。这就是 CRF 比 HMM 强的核心。
- $f(t_{i-1}, t_i, c, i)$:转移特征,也可以看全局。
- $Z(c) = \sum_{t'} \exp(\cdot)$:归一化常数。它要对所有 $4^n$ 个标签序列求和,
但可以用前向后向算法(和 Viterbi 同源的动态规划)在 $O(n|S|^2)$ 内算出来。
这是 CRF 能用的关键。

特征函数的权重怎么定? HMM 靠数数,CRF 靠梯度下降(最大化对数似然)。
所以 CRF 需要训练,也不再有"转移概率"那种可读的表。

为什么后来被神经网络取代? 因为 CRF 的特征要人手工设计
("当前字是不是数字"、"前一个字是不是动词"…),而 BiLSTM-CRF
(Huang, Xu & Yu 2015, arXiv:1508.01991)用 LSTM 学特征、CRF 层做结构约束,
自动化掉了这步。BERT 之后(Devlin et al. 2019 §5.3),直接一个线性层接在 BERT 上
就能超过所有 CRF 系统,因为 BERT 的表示里已经包含了足够的信息。

5.6 边界与批评

(a) 评测指标本身有偏。分词 F1 按"词的边界"算,长词和短词权重一样。
但语料里短词(虚词)占大多数,所以 F1 高的模型可能在大词/专业术语上惨不忍睹。

(b) 标注规范不统一,跨系统分数不可比。上面"看书"那个例子就是证据。
著名的"标准不一"问题还包括:人名"欧阳修"要切成"欧阳/修"还是"欧阳修";
机构名"北京大学"要切成一个词还是"北京/大学"。SIGHAN 评测(Bakeoff)正是因为
这个原因要把不同标注规范的数据分开报分。

(c) NER 的"实体"定义是任务相关甚至领域相关的。医学文本里的"实体"
边界和新闻文本完全不同。所以 NER 模型换领域通常要重新标数据。

(d) 嵌套实体。"北京大学"是一个 ORG,其中"北京"是一个 LOC。标准的
B/I/O 标注无法表示嵌套(一个位置只能有一个标签)。解决办法是改成
span-based 或者多层标注(如 ACE 语料的做法)。

(e) 一个诚实的提醒:本节的 HMM 在 5 句测试集上 F1 = 0.919,
这个数字没有任何意义,测试集和训练集出自同一个 12 句语料,
而且测试句几乎全是训练句的子串。真实的中文分词评测用 SIGHAN bakeoff
的公开数据(数十万词),最优系统 F1 在 0.95–0.98。
这一节的代码是教学演示,不是效果报告。


6 句法分析

6.1 依存 vs 成分:两种句法观

§1.3 已经给了同一句话的两种树。这里把差别说透:

成分句法(constituency) 依存句法(dependency)
基本单位 短语(NP、VP、S…) 词与词的关系(弧)
树的样子 层次嵌套的括号结构 每个词一个"中心词"
一组词 先抱成团,再抱更大的团 一个词直接修饰另一个词
优点 表达力强,能表示不连续成分 跨语言通用,中心词跨语言更稳定
缺点 标注成本高,跨语言难统一 表达力弱(表示不了短语级结构)
代表语料 Penn Treebank Universal Dependencies
理论源头 Chomsky (1957) Tesnière (1959)《结构句法基础》

两者可以互相转换(有标准算法),但信息不等价,转换会丢掉一些东西。

为什么 NLP 现在更偏爱依存? 三条实际理由:
1. 中心词跨语言稳定:不管 SOV 还是 SVO 语序,"动词是句子中心"这一点不变,
所以依存树比成分树更适合跨语言迁移;
2. 标注便宜:标注员直接标"这个词修饰哪个词、什么关系",比画嵌套括号容易;
3. 结构简单:每个词恰好一个中心词,可表示成一个数组,
下游任务(如关系抽取)用起来方便。

成分句法仍然有一席之地:需要严格短语结构的任务(如某些语言学分析、
语义组合)还是得用它。

6.2 转移式解析(transition-based parsing)

问题:给定一个句子,怎么高效地输出依存树?
穷举所有可能的树是指数级的,所以和 §4 一样,要把它变成序列决策。

核心思想(Nivre 2003, 2004):把解析过程看成一台状态机,
用一个栈和一个缓冲区(还没处理的词),每次做一个动作。

arc-standard 系统的三个动作(本页实现的是这个):

  配置 = (栈 stack, 缓冲 buffer, 已确定的弧 arcs)

  SHIFT        把 buffer 的第一个词压入栈
  LEFT-ARC(l)  栈顶 s1 依存于第二个 s2:记一条弧 s1 --l--> s2,弹出 s1
  RIGHT-ARC(l) 第二个 s2 依存于栈顶 s1:记一条弧 s2 --l--> s1,弹出 s2

用一个例子走一遍(就是下面代码里第一句):

  句子:我 看见 那个 人

  步 0  栈=[ROOT]          缓冲=[我,看见,那个,人]   动作:SHIFT
  步 1  栈=[ROOT,我]       缓冲=[看见,那个,人]      动作:SHIFT
  步 2  栈=[ROOT,我,看见]  缓冲=[那个,人]           动作:RIGHT-ARC(nsubj)
                                                     └ 我 --nsubj--> 看见,弹出"我"
  步 3  栈=[ROOT,看见]     缓冲=[那个,人]           动作:SHIFT
  步 4  栈=[ROOT,看见,那个] 缓冲=[人]               动作:SHIFT
  步 5  栈=[ROOT,看见,那个,人] 缓冲=[]              动作:RIGHT-ARC(det)
                                                     └ 那个 --det--> 人,弹出"那个"
  步 6  栈=[ROOT,看见,人]   缓冲=[]                 动作:LEFT-ARC(obj)
                                                     └ 人 --obj--> 看见,弹出"人"
  步 7  栈=[ROOT,看见]      缓冲=[]                 动作:LEFT-ARC(root)
                                                     └ 看见 --root--> ROOT

  代价:n 个词约 2n 个动作(每个词进栈一次、出栈一次)。

这一步是关键:把"找一棵树"(指数级)变成"做一个长度为 $2n$ 的动作序列"。
但动作序列怎么选? 真实系统训练一个分类器,
每一步根据当前配置(栈顶几个词、缓冲头几个词、它们的词性/词向量)预测下一个动作。
这个分类器的训练数据来自 treebank:给定标准树,有唯一正确的动作序列,
这个"标准答案"叫做 oracle。

为什么现代解析器用神经网络做这个分类器? Chen & Manning (2014, EMNLP,
"A Fast and Accurate Dependency Parser using Neural Networks") 把手工特征
(词形、词性、距离等)换成词向量 + 一个 MLP,准确率直接跳了一个台阶。
这是 NLP 从特征工程转向表示学习的标志性工作之一。

6.3 实跑:用 oracle 驱动 arc-standard 解析器

import math
ROOT = 0          # 用 0 表示 ROOT,词的下标是 1..n

def show(tree):
    """把依存树画成 (依存词 --标签--> 中心词) 的三元组,用于打印。"""
    return sorted("%s --%s--> %s" % (w, lab, "<ROOT>" if h == 0 else tree[h - 1][0])
                  for i, (w, pos, lab, h) in enumerate(tree, 1))

def oracle_step(stack, buffer, arcs, tree):
    """Nivre (2004) 静态 oracle:给定配置和标准树,返回下一个动作。"""
    n = len(tree)
    head = {i: tree[i - 1][3] for i in range(1, n + 1)}
    label = {i: tree[i - 1][2] for i in range(1, n + 1)}
    children = {0: set()}
    for i in range(1, n + 1):
        children.setdefault(head[i], set()).add(i)
    attached = {d for d, h, l in arcs}            # 已经挂好的依存词
    ready = lambda x: children.get(x, set()) <= attached   # x 的依存词都挂好了吗

    if len(stack) >= 2:
        s1, s2 = stack[-1], stack[-2]
        if s1 != ROOT and head.get(s1) == s2 and ready(s1):
            return ("LEFT-ARC", label[s1])        # s1 --l--> s2,弹出 s1
        if s2 != ROOT and head.get(s2) == s1 and ready(s2):
            return ("RIGHT-ARC", label[s2])       # s2 --l--> s1,弹出 s2
    if buffer:
        return ("SHIFT", None)
    return ("DONE", None)

def run(tree, forced=None):
    """forced=(step, (action, label)) 可以在某一步强行执行一个错误动作。"""
    n = len(tree)
    stack, buffer, arcs, acts = [ROOT], list(range(1, n + 1)), [], []
    for step in range(3 * n + 5):
        if forced and step == forced[0] and len(stack) >= 2 and buffer:
            act, lab = forced[1]
        else:
            act, lab = oracle_step(stack, buffer, arcs, tree)
        if act == "DONE":
            break
        if act == "SHIFT":
            if not buffer: break
            stack.append(buffer.pop(0))
        elif act == "LEFT-ARC" and len(stack) >= 2:
            s1 = stack.pop(); arcs.append((s1, stack[-1], lab))
        elif act == "RIGHT-ARC" and len(stack) >= 2:
            s1 = stack[-1]                 # 栈顶:中心词
            s2 = stack.pop(-2)             # 第二个:依存词,被"归约"掉
            arcs.append((s2, s1, lab))
        else:
            break
        acts.append(act if lab is None else "%s(%s)" % (act, lab))
    return acts, arcs

TREES = [
    [("我","PRON","nsubj",2), ("看见","VERB","root",0), ("那个","DET","det",4), ("人","NOUN","obj",2)],
    [("小猫","NOUN","nsubj",2), ("吃","VERB","root",0), ("鱼","NOUN","obj",2)],
    [("这","DET","det",2), ("书","NOUN","nsubj",3), ("很","ADV","advmod",4), ("好","ADJ","root",0)],
]

print("arc-standard 转移系统:栈 + 缓冲,每步一个动作,n 个词约 2n 步完成。\n")
for tree in TREES:
    words = [t[0] for t in tree]
    acts, arcs = run(tree)
    pred = sorted("%s --%s--> %s" % (words[d-1], l, "<ROOT>" if h == 0 else words[h-1])
                  for d, h, l in arcs)
    print("句子:%s" % " ".join(words))
    print("  动作序列(%d 步):%s" % (len(acts), " ".join(acts)))
    print("  解析结果:%s" % " | ".join(pred))
    print("  标准答案:%s" % " | ".join(show(tree)))
    print("  完全一致? %s\n" % ("是" if pred == show(tree) else "否"))

print("【动作空间】若用 20 个依存标签:20(左弧) + 20(右弧) + 1(移进) = 41 种动作")
for n in (5, 10, 20):
    print("   %2d 词句需约 %2d 步,序列总数上界 41^%d ≈ 10^%.0f"
          % (n, 2*n, 2*n, 2*n*math.log10(41)))
print("   -> 不可能穷举,只能训练一个分类器逐步预测下一步动作(贪心解码)。\n")

print("【只错一步会怎样】同一个转移系统,两种错误,后果完全不同\n")
for tree in TREES[:2]:
    words = [t[0] for t in tree]
    g_acts, g_arcs = run(tree)
    fmt = lambda A: sorted("%s --%s--> %s" % (words[d-1], l, "<ROOT>" if h == 0 else words[h-1])
                           for d, h, l in A)
    def wellformed(A):
        """合法依存树:每个词恰好当一次依存词,且恰好一个词的 governor 是 ROOT。"""
        deps = [d for d, h, l in A]
        return (len(A) == len(words) and len(set(deps)) == len(deps)
                and len([1 for d, h, l in A if h == 0]) == 1)
    for tag, forced in [("错误 A:第 2 步多走了一次 SHIFT(该挂弧时不挂)", (2, ("SHIFT", None))),
                        ("错误 B:第 2 步提前挂弧 RIGHT-ARC(dep)(该移进时乱挂)",
                         (1, ("RIGHT-ARC", "dep")))]:
        b_acts, b_arcs = run(tree, forced=forced)
        print("   「%s」 %s" % (" ".join(words), tag))
        print("      正确动作:%s" % " ".join(g_acts))
        print("      出错动作:%s" % " ".join(b_acts))
        print("      正确树  :%s" % " | ".join(fmt(g_arcs)))
        print("      出错树  :%s" % " | ".join(fmt(b_arcs)))
        print("      与标准树一致? %s ;是一棵合法的依存树吗? %s\n"
              % ("是" if set(fmt(b_arcs)) == set(fmt(g_arcs)) else "否",
                 "是" if wellformed(b_arcs) else "否"))

实跑输出

arc-standard 转移系统:栈 + 缓冲,每步一个动作,n 个词约 2n 步完成。

句子:我 看见 那个 人
  动作序列(8 步):SHIFT SHIFT RIGHT-ARC(nsubj) SHIFT SHIFT RIGHT-ARC(det) LEFT-ARC(obj) LEFT-ARC(root)
  解析结果:人 --obj--> 看见 | 我 --nsubj--> 看见 | 看见 --root--> <ROOT> | 那个 --det--> 人
  标准答案:人 --obj--> 看见 | 我 --nsubj--> 看见 | 看见 --root--> <ROOT> | 那个 --det--> 人
  完全一致? 是

句子:小猫 吃 鱼
  动作序列(6 步):SHIFT SHIFT RIGHT-ARC(nsubj) SHIFT LEFT-ARC(obj) LEFT-ARC(root)
  解析结果:吃 --root--> <ROOT> | 小猫 --nsubj--> 吃 | 鱼 --obj--> 吃
  标准答案:吃 --root--> <ROOT> | 小猫 --nsubj--> 吃 | 鱼 --obj--> 吃
  完全一致? 是

句子:这 书 很 好
  动作序列(8 步):SHIFT SHIFT RIGHT-ARC(det) SHIFT RIGHT-ARC(nsubj) SHIFT RIGHT-ARC(advmod) LEFT-ARC(root)
  解析结果:书 --nsubj--> 很 | 好 --root--> <ROOT> | 很 --advmod--> 好 | 这 --det--> 书
  标准答案:书 --nsubj--> 很 | 好 --root--> <ROOT> | 很 --advmod--> 好 | 这 --det--> 书
  完全一致? 是

【动作空间】若用 20 个依存标签:20(左弧) + 20(右弧) + 1(移进) = 41 种动作
    5 词句需约 10 步,序列总数上界 41^10 ≈ 10^16
   10 词句需约 20 步,序列总数上界 41^20 ≈ 10^32
   20 词句需约 40 步,序列总数上界 41^40 ≈ 10^65
   -> 不可能穷举,只能训练一个分类器逐步预测下一步动作(贪心解码)。

【只错一步会怎样】同一个转移系统,两种错误,后果完全不同

   「我 看见 那个 人」 错误 A:第 2 步多走了一次 SHIFT(该挂弧时不挂)
      正确动作:SHIFT SHIFT RIGHT-ARC(nsubj) SHIFT SHIFT RIGHT-ARC(det) LEFT-ARC(obj) LEFT-ARC(root)
      出错动作:SHIFT SHIFT SHIFT SHIFT RIGHT-ARC(det) LEFT-ARC(obj) RIGHT-ARC(nsubj) LEFT-ARC(root)
      正确树  :人 --obj--> 看见 | 我 --nsubj--> 看见 | 看见 --root--> <ROOT> | 那个 --det--> 人
      出错树  :人 --obj--> 看见 | 我 --nsubj--> 看见 | 看见 --root--> <ROOT> | 那个 --det--> 人
      与标准树一致? 是 ;是一棵合法的依存树吗? 是

   「我 看见 那个 人」 错误 B:第 2 步提前挂弧 RIGHT-ARC(dep)(该移进时乱挂)
      正确动作:SHIFT SHIFT RIGHT-ARC(nsubj) SHIFT SHIFT RIGHT-ARC(det) LEFT-ARC(obj) LEFT-ARC(root)
      出错动作:SHIFT RIGHT-ARC(dep) SHIFT RIGHT-ARC(nsubj) SHIFT SHIFT RIGHT-ARC(det) LEFT-ARC(obj)
      正确树  :人 --obj--> 看见 | 我 --nsubj--> 看见 | 看见 --root--> <ROOT> | 那个 --det--> 人
      出错树  :人 --dep--> 我 | 人 --obj--> 看见 | 我 --nsubj--> 看见 | 那个 --det--> 人
      与标准树一致? 否 ;是一棵合法的依存树吗? 否

   「小猫 吃 鱼」 错误 A:第 2 步多走了一次 SHIFT(该挂弧时不挂)
      正确动作:SHIFT SHIFT RIGHT-ARC(nsubj) SHIFT LEFT-ARC(obj) LEFT-ARC(root)
      出错动作:SHIFT SHIFT SHIFT LEFT-ARC(obj) RIGHT-ARC(nsubj) LEFT-ARC(root)
      正确树  :吃 --root--> <ROOT> | 小猫 --nsubj--> 吃 | 鱼 --obj--> 吃
      出错树  :吃 --root--> <ROOT> | 小猫 --nsubj--> 吃 | 鱼 --obj--> 吃
      与标准树一致? 是 ;是一棵合法的依存树吗? 是

   「小猫 吃 鱼」 错误 B:第 2 步提前挂弧 RIGHT-ARC(dep)(该移进时乱挂)
      正确动作:SHIFT SHIFT RIGHT-ARC(nsubj) SHIFT LEFT-ARC(obj) LEFT-ARC(root)
      出错动作:SHIFT RIGHT-ARC(dep) SHIFT RIGHT-ARC(nsubj) SHIFT LEFT-ARC(obj)
      正确树  :吃 --root--> <ROOT> | 小猫 --nsubj--> 吃 | 鱼 --obj--> 吃
      出错树  :小猫 --nsubj--> 吃 | 鱼 --dep--> 小猫 | 鱼 --obj--> 吃
      与标准树一致? 否 ;是一棵合法的依存树吗? 否

读输出:

错误是否致命,取决于它有没有"提前挂弧"。

这个不对称性解释了转移式解析器的实际行为:它主要的错误来源是"过早决定",
而非"动作顺序错"。改进方向因此是:
1. Beam search:不贪心,保留 $k$ 个最好的配置(但错误 B 那种弧一旦挂上还是不可撤销);
2. 换转移系统:arc-eager(Nivre 2003)允许更晚才决定左弧;
3. 更好的分类器(Chen & Manning 2014):减少"过早决定"的概率。

动作空间那一节说明了为什么必须用分类器:20 词句的动作序列有 $10^{65}$ 种可能,
不可能搜索。这和 §4 的 Viterbi 形成对比:HMM 能用动态规划是因为它满足
最优子结构(标签序列马尔可夫),而转移式解析的动作序列不满足
(同一个栈的后续可能性取决于已经挂过的弧,不只是栈和缓冲),
所以只能用贪心 + 学出来的分类器。

6.4 边界与批评

(a) 非投射性。有些真实的依存关系交叉(比如德语、捷克语里的长距离依存),
arc-standard 系统在结构上无法产生非投射树。虽然真实语料里非投射句占比很低
(英文 <1%,部分语言可达 10%+,出处待查具体比例),但这类句子是硬约束。
解决办法:换成允许非投射的转移系统(如 arc-eager 的变体),或在后处理阶段做修正。

(b) 错误传播(error propagation)。真实解析器的输入是预测的词性
而不是正确的词性,所以词性标注的错误会传到句法层。这叫级联错误,
是流水线式 NLP 的通病。

(c) UAS 这个数字很脆。依存分析的标准指标是 UAS(unlabeled attachment
score,只看中心词对不对,不看标签)和 LAS(label 也看)。
UAS 不惩罚"挂错标签但挂对了中心词",所以看起来总比 LAS 高几个点。
报分数时必须说清是哪个。英文 UD 上,BERT 时代的 LAS 大约 0.90–0.95。

(d) 一个界面上的诚实提醒:这一节实现的解析器是用 oracle 驱动的,
也就是说它每一步都"知道标准答案"。这不是一个真的解析器。
真实系统的分类器只看到配置,看不到标准树。
本代码演示的是转移系统和 oracle 本身,用来理解"解析怎么变成序列决策"这件事。
这是一个常见的误解来源:看到输出完全正确,就以为解析问题解决了。


7 从 RNN 到 Transformer 到 BERT:预训练 + 微调范式

7.1 三种架构各自适合什么

网络结构的推导见《深度学习》§7(RNN/LSTM)和 §8(注意力/Transformer),
这一节只做 NLP 视角的分工对照。这是必考的一张表:

RNN / LSTM CNN(文本) Transformer
归纳偏置 时序性:从左到右递归 局部性:n-gram 式窗口 无(两两全连接)
长距离依赖 理论上可以,实际会丢(梯度消失,见《深度学习》§7.3) 靠堆层扩大感受野 一步直达(任意两位置的距离都是 1)
并行度 差(必须按时间步串行) 好 极好
位置信息 天然有序 天然有序(按窗口) 必须显式加(位置编码)
计算量 $O(n d^2)$ $O(n k d^2)$ $O(n^2 d)$ ← 长序列的瓶颈
在 NLP 里的地位 2014–2017 主流,现在基本退役 短暂用过(文本分类) 2017 至今的绝对主流

为什么 Transformer 赢了? 一句话:它用 $O(n^2)$ 的注意力换来了
"任意两个位置直接交互" + "完全并行"。前者解决了长距离依赖,
后者让"在 40GB 文本上训练"在工程上成为可能。算力能买,串行结构买不到。

位置编码为什么必须加(这是考点):注意力是置换等变的,
打乱输入顺序,输出只是跟着打乱,模型本身看不出顺序。
所以必须把位置信息加进输入(见《深度学习》§8.6)。

7.2 预训练 + 微调:为什么这个范式改变了 NLP

这是本课最重要的一个范式转变,值得单独说清楚。

旧范式(2000–2017):每个任务从零训练一个模型。

   任务 A(情感分类)  ->  标注数据 A  ->  训练模型 A
   任务 B(命名实体)  ->  标注数据 B  ->  训练模型 B
   任务 C(句法分析)  ->  标注数据 C  ->  训练模型 C
                          ↑
              每个任务都要成千上万条人工标注,而且互不通用

问题:标注数据是 NLP 最贵的资源。低资源语言、专业领域(医学、法律)
根本拿不到足够的标注数据。

新范式(2018 至今):先在大规模无标注文本上预训练,再在小规模标注数据上微调。

   阶段一:预训练(pre-train)
     输入:几十 GB 的**无标注**文本(维基百科、网页、书籍……)
     目标:自己造监督信号(猜被遮住的词 / 猜下一个词)
     产出:一个通用的语言表示模型
                              │
                              ▼
   阶段二:微调(fine-tune)
     输入:任务相关的**少量**标注数据(几百到几万条)
     做法:把预训练模型接一个小的输出层,**整个模型继续训练**几步
     产出:该任务的模型

为什么它有效? 三个层次的解释:

  1. 工程上:标注数据的需求从"几万条"降到"几百条",NLP 的门槛塌了。
  2. 表示上:预训练学到了通用的词法和句法知识。探针实验(probing)显示,
    BERT 的中间层能线性地解码出词性、依存树等结构(Tenney et al. 2019, ACL,
    "BERT Rediscovers the Classical NLP Pipeline", arXiv:1905.05950;
    Hewitt & Manning 2019, NAACL, "A Structural Probe for Finding Syntax in
    Word Representations", arXiv:1904.01500)。"重新发现经典 NLP 流水线"
    这个标题本身就是结论:模型的层序和语言学分析层次是同构的。
  3. 统计上:预训练相当于一个很强的先验,微调只需要在这个先验上做小幅调整,
    所以小数据也能泛化。

架构的分化(这张表是 GPT 家族和 BERT 家族的分水岭):

   编码器(Encoder,双向)   BERT / RoBERTa / ELECTRA
        -> 每个位置能看左右两边
        -> 适合【理解】类任务:分类、标注、抽取、句法
        -> 预训练任务:MLM(掩码语言模型)

   解码器(Decoder,单向)   GPT / GPT-2 / GPT-3 / LLaMA
        -> 每个位置只能看左边(因果掩码)
        -> 适合【生成】类任务:续写、翻译、对话
        -> 预训练任务:自回归语言模型(就是个 n-gram 模型的神经版)

为什么解码器最后赢了? 因为生成可以统一一切任务:
把"分类"改写成"续写答案",把"翻译"改写成"续写译文"。
这就是 GPT-3(Brown et al. 2020, NeurIPS, arXiv:2005.14165)的
in-context learning,不做微调,只在提示里给几个例子。
(这条线索通向《大语言模型》那门课,本课到此为止。)

7.3 实跑:BERT-base 的 1.1 亿参数从哪来

"BERT-base 有 1.1 亿参数"这句话你会看到无数次。它到底怎么算出来的?
下面把每一块加起来核对一遍,这也是"能不能读懂论文里的数字"的基本功。

L, H, A, I, Vocab = 12, 768, 12, 3072, 30522   # BERT-base 的配置

emb = Vocab * H                       # 词嵌入表
pos = 512 * H                         # 位置嵌入(最长 512 个位置)
typ = 2 * H                           # 句对嵌入(A/B 两段)
emb_total = emb + pos + typ

qkv = H * (H * 3) + 3 * H             # W_q, W_k, W_v 拼在一起 + 3 个偏置
att_out = H * H + H                   # 输出投影 + 偏置
ffn = H * I + I + I * H + H           # 两层前馈(升维再降维)+ 偏置
ln = 4 * H                            # 两个 LayerNorm,每个有 gamma 和 beta
per_layer = qkv + att_out + ffn + ln
layer_total = per_layer * L

pooler = H * H + H                    # [CLS] 的 pooler 层(预训练用)

total = emb_total + layer_total + pooler
print("BERT-base:L=%d 层,隐藏维 H=%d,注意力头 A=%d,FFN 中间维 I=%d,词表 %d\n"
      % (L, H, A, I, Vocab))
print("词嵌入表      %10s  (%d × %d)" % (format(emb, ","), Vocab, H))
print("位置嵌入      %10s  (512 × %d)" % (format(pos, ","), H))
print("段落嵌入      %10s  (2 × %d)" % (format(typ, ","), H))
print("每层:QKV %s + 输出投影 %s + FFN %s + LayerNorm %s = %s"
      % (format(qkv, ","), format(att_out, ","), format(ffn, ","), format(ln, ","),
         format(per_layer, ",")))
print("%d 层合计    %10s" % (L, format(layer_total, ",")))
print("pooler        %10s" % format(pooler, ","))
print("-" * 52)
print("总计          %10s  ≈ %.1f 亿" % (format(total, ","), total / 1e8))
print("\n其中约 %.0f%% 的参数在 12 个 Transformer 层里,而不是在词嵌入表里。"
      % (100 * layer_total / total))

实跑输出

BERT-base:L=12 层,隐藏维 H=768,注意力头 A=12,FFN 中间维 I=3072,词表 30522

词嵌入表      23,440,896  (30522 × 768)
位置嵌入         393,216  (512 × 768)
段落嵌入           1,536  (2 × 768)
每层:QKV 1,771,776 + 输出投影 590,592 + FFN 4,722,432 + LayerNorm 3,072 = 7,087,872
12 层合计    85,054,464
pooler           590,592
----------------------------------------------------
总计          109,480,704  ≈ 1.1 亿

其中约 78% 的参数在 12 个 Transformer 层里,而不是在词嵌入表里。

读输出,三个要点:

  1. 算出来是 $109{,}480{,}704 \approx 1.1$ 亿,和原论文(Devlin et al. 2019)
    报的 110M 吻合。论文里的数字是可以自己复算的,这是本课要养成的习惯。
  2. 每层 7,087,872 个参数,其中 FFN 占 $4{,}722{,}432$,也就是 67%。
    这很反直觉:大家谈 Transformer 都在谈注意力,但参数最多的其实是前馈层
    ($d \to 4d \to d$ 那个升维降维)。这个比例在 GPT 系列里也基本成立。
  3. 78% 的参数在层里,不在词嵌入表里。这条解释了为什么"加一个领域的词表"
    (比如医学词表)几乎不能提升模型能力,你能改的只有那 21%。
    新知识要靠继续预训练或微调写进那 78% 里。

7.4 边界与批评

(a) 预训练的成本被隐藏了。"微调只要几百条数据"是真的,但预训练烧掉的
算力和电力是真实的(BERT-base 大约 64 块 TPU 跑 4 天;GPT-3 的估计值在
数千 petaflop-days 量级,具体数字各家估算不同,出处待查)。
"低资源"这个词只对下游成立,对上游不成立。

(b) 微调其实没那么"轻"。后续研究发现 BERT 的微调非常不稳定:
换一个随机种子,性能可能差几个点。这类问题催生了 Adapter、LoRA 等
"只训练一小部分参数"的方法。

(c) 静态词向量的偏见问题没有消失。 BERT 也是从网上文本学的,
偏见照样学进去,而且更难检测(因为表示随上下文变,不再是固定方向)。

(d) "预训练"学到的到底是什么? 探针研究说"学到了句法",但探针本身有争议,
一个足够强的探针可能自己学会了任务,而不是"读出了"模型里的信息
(Hewitt & Liang 2019, EMNLP, "Designing and Interpreting Probes with Control
Tasks", arXiv:1909.03368)。"模型里有 X"这句话永远要问:是模型有,还是探针有?


8 评测:BLEU、ROUGE、F1 以及它们的已知缺陷

8.1 为什么评测值得单独一节

一句话:没有评测,NLP 就没有对错。而选错指标比模型差更危险,
你会在错误的方向上优化几个月。这一节讲三个标准指标的定义和它们各自会怎么骗你。
它对应 §③ 的能力 4。

8.2 BLEU:机器翻译的经典指标

BLEU(Papineni, Roukos, Ward & Zhu, 2002, ACL)的设计思想:
机器译文里有多少 n-gram 出现在参考译文里。

第一步:修正的 n-gram 精度(modified n-gram precision)。

$$\mathrm{Prec}_n = \frac{\sum_{\text{n-gram} \in \text{候选}} \min\big(\mathrm{count}_{\text{候选}}(\text{n-gram}),\ \max_{\text{参考}} \mathrm{count}_{\text{参考}}(\text{n-gram})\big)}{\sum_{\text{n-gram} \in \text{候选}} \mathrm{count}_{\text{候选}}(\text{n-gram})}$$

逐项读,重点是那个 $\min$:
- 分子:候选里每个 n-gram 的出现次数,被参考里的最大次数截断(clipping);
- 为什么要截断:否则模型只要重复一个高频词就能刷高精度。
候选 "the the the the the the" 的一元精度如果不截断就是 1.0
(六个 "the" 都在参考里),这显然荒谬。截断之后,参考里只有一个 "the",
所以最多只能算 1 次命中,精度变成 $1/6$。
- 分母:候选里所有 n-gram 的数量。注意分母永远是候选自己的,
所以 BLEU 是精度,不是召回,它不惩罚"少译",只惩罚"译错"
(少译由下面的简短惩罚处理)。

第二步:几何平均 + 简短惩罚(brevity penalty, BP)。

$$\mathrm{BLEU} = \mathrm{BP} \cdot \exp\left(\frac{1}{N}\sum_{n=1}^{N}\log \mathrm{Prec}_n\right), \quad \mathrm{BP} = \begin{cases} 1 & c > r \\ e^{1 - r/c} & c \le r\end{cases}$$

逐项读:
- $N$ 通常取 4(BLEU-4 是默认)。
- 几何平均(而不是算术平均):只要有任何一阶精度为 0,整个 BLEU 就是 0。
这是一个很硬的惩罚,也正是下面"缺陷 1"的来源。
- $c$ 是候选长度,$r$ 是参考长度(多个参考时取长度最接近 $c$ 的那个)。
$c \le r$ 时惩罚。为什么需要它:光看精度的话,
译文越短,能犯的错越少,精度越容易高。极端的例子是一个词都不译,
精度是 0/0。BP 就是防这个的。

8.3 实跑:BLEU、ROUGE-L、F1 以及它们的失效场景

import math
from collections import Counter

def ngrams(tokens, n):
    return Counter(tuple(tokens[i:i + n]) for i in range(len(tokens) - n + 1))

def bleu(cand, refs, N=4):
    c = cand.split(); R = [r.split() for r in refs]
    precisions = []
    for n in range(1, N + 1):
        cn = ngrams(c, n)
        if not cn:
            precisions.append(0.0); continue
        maxref = Counter()
        for r in R:                       # 截断:不超过参考里的最大次数
            for g, k in ngrams(r, n).items():
                maxref[g] = max(maxref[g], k)
        hit = sum(min(k, maxref[g]) for g, k in cn.items())
        precisions.append(hit / sum(cn.values()))
    geo = 0.0 if min(precisions) == 0 else math.exp(
        sum(math.log(p) for p in precisions[:N]) / N)
    c_len = len(c)
    r_len = min((abs(len(r) - c_len), len(r)) for r in R)[1]
    bp = 1.0 if c_len > r_len else math.exp(1 - r_len / c_len)
    return bp * geo, precisions

REF = ["the cat is on the mat"]
CASES = [
    ("the cat is on the mat", "完全一致的参考译文"),
    ("the cat is not on the mat", "只多了一个否定词 not —— 意思反了"),
    ("a cat is resting on the mat", "同义改写(resting on = on)"),
    ("on the cat the sat mat is", "词序全打乱(词袋完全一样)"),
    ("the the the the the the", "重复同一个高频词"),
    ("the cat", "只译了一半(很短)"),
]
print("参考译文:%s\n" % REF[0])
print("   候选译文                             BLEU-1 BLEU-2 BLEU-4 |   P1     P2     P3     P4")
for sent, note in CASES:
    b1, _ = bleu(sent, REF, N=1); b2, _ = bleu(sent, REF, N=2); b4, p = bleu(sent, REF, N=4)
    print("   %-34s %6.3f %6.3f %6.3f | %s   %s"
          % (sent, b1, b2, b4, "  ".join("%.3f" % x for x in p), note))

# ---------- ROUGE-L(最长公共子序列) ----------
def lcs(a, b):
    m, n = len(a), len(b)
    dp = [[0] * (n + 1) for _ in range(m + 1)]
    for i in range(1, m + 1):
        for j in range(1, n + 1):
            dp[i][j] = dp[i-1][j-1] + 1 if a[i-1] == b[j-1] else max(dp[i-1][j], dp[i][j-1])
    return dp[m][n]

def rouge_l(cand, ref):
    c, r = cand.split(), ref.split()
    L = lcs(c, r); P, R = L / len(c), L / len(r)
    return P, R, (2 * P * R / (P + R) if P + R else 0)

print("\n【ROUGE-L:摘要评测常用,基于最长公共子序列】")
SUMMARY = "the cat is on the mat"
for sent, note in [("the cat is on the mat", "完全一致"),
                   ("the mat is on the cat", "词序颠倒"),
                   ("a resting cat lies upon the mat", "同义改写")]:
    P, R, F = rouge_l(sent, SUMMARY)
    print("   %-34s ROUGE-L P=%.3f R=%.3f F=%.3f   (%s)" % (sent, P, R, F, note))

# ---------- F1:序列标注的标准指标 ----------
print("\n【F1:序列标注(NER / 词性标注),按实体算而不是按 token 算】")
GOLD = ["B-PER", "I-PER", "O", "B-ORG", "O", "B-LOC"]
PRED = ["B-PER", "I-PER", "O", "B-ORG", "I-ORG", "O"]
def spans(tags):
    out, cur = [], None
    for i, t in enumerate(tags + ["O"]):
        if t.startswith("B-"):
            if cur: out.append(cur)
            cur = (t[2:], i, i)
        elif t.startswith("I-") and cur and cur[0] == t[2:]:
            cur = (cur[0], cur[1], i)
        else:
            if cur: out.append(cur)
            cur = None
    return set(out)
sg, sp = spans(GOLD), spans(PRED)
tp = len(sp & sg)
P = tp / len(sp); R = tp / len(sg); F = 2 * P * R / (P + R)
print("   标准标签:%s" % "  ".join(GOLD))
print("   预测标签:%s" % "  ".join(PRED))
print("   标准实体:%s" % sorted(sg))
print("   预测实体:%s" % sorted(sp))
print("   实体级  P=%.3f R=%.3f F1=%.3f" % (P, R, F))
acc = sum(1 for a, b in zip(GOLD, PRED) if a == b) / len(GOLD)
print("   逐 token 准确率 = %.3f  <- 看着不错,但它把 O 标签的功劳也算进去了。" % acc)
print("   (6 个位置里有 3 个是 O,只要全判 O 就有 3/6 = 0.500 的准确率。)")

实跑输出

参考译文:the cat is on the mat

   候选译文                             BLEU-1 BLEU-2 BLEU-4 |   P1     P2     P3     P4
   the cat is on the mat               1.000  1.000  1.000 | 1.000  1.000  1.000  1.000   完全一致的参考译文
   the cat is not on the mat           0.857  0.756  0.000 | 0.857  0.667  0.400  0.000   只多了一个否定词 not —— 意思反了
   a cat is resting on the mat         0.714  0.598  0.000 | 0.714  0.500  0.200  0.000   同义改写(resting on = on)
   on the cat the sat mat is           0.857  0.535  0.000 | 0.857  0.333  0.000  0.000   词序全打乱(词袋完全一样)
   the the the the the the             0.333  0.000  0.000 | 0.333  0.000  0.000  0.000   重复同一个高频词
   the cat                             0.135  0.135  0.000 | 1.000  1.000  0.000  0.000   只译了一半(很短)

【ROUGE-L:摘要评测常用,基于最长公共子序列】
   the cat is on the mat              ROUGE-L P=1.000 R=1.000 F=1.000   (完全一致)
   the mat is on the cat              ROUGE-L P=0.667 R=0.667 F=0.667   (词序颠倒)
   a resting cat lies upon the mat    ROUGE-L P=0.429 R=0.500 F=0.462   (同义改写)

【F1:序列标注(NER / 词性标注),按实体算而不是按 token 算】
   标准标签:B-PER  I-PER  O  B-ORG  O  B-LOC
   预测标签:B-PER  I-PER  O  B-ORG  I-ORG  O
   标准实体:[('LOC', 5, 5), ('ORG', 3, 3), ('PER', 0, 1)]
   预测实体:[('ORG', 3, 4), ('PER', 0, 1)]
   实体级  P=0.500 R=0.333 F1=0.400
   逐 token 准确率 = 0.667  <- 看着不错,但它把 O 标签的功劳也算进去了。
   (6 个位置里有 3 个是 O,只要全判 O 就有 3/6 = 0.500 的准确率。)

读输出,每一行都对应一个真实存在的缺陷:

  1. 否定词那一行:BLEU-1 = 0.857,BLEU-2 = 0.756,意思却完全反了。
    这是 BLEU 最著名的缺陷:它没有任何语义表示。
    "the cat is not on the mat" 和参考只差一个 token,n-gram 重合度极高。
    在安全相关的场景(医疗、法律翻译)里,这个缺陷是致命的。
  2. 词序打乱那一行:$P_1 = 0.857$(词都有),$P_2 = 0.333$(顺序不对)。
    这展示了 $n$ 的作用:$n$ 越高越能抓住语序。但代价是
    $P_4 = 0$ 一票否决,BLEU-4 = 0。几何平均让最差的那一阶说了算,
    这在短句上极其不稳定。
  3. 重词那一行:截断机制生效了,$P_1$ 从理论上的 1.0 被压到 $0.333$。
    没有截断的话,BLEU 就是一个可以被"重复刷分"的指标。
  4. 短句那一行:所有合法 n-gram 的精度都是 $1.000$,
    但 BP 把 BLEU-1 压到 $0.135$。BP 是必要的,但它也惩罚合法的短译文,
    如果一个语言里表达同一意思本来就短(比如中文 vs 英文),BP 会系统性偏向长的那边。
    这是 BLEU 在非英语语言对上被诟病的一个点。
  5. BLEU-4 在这么短的句子上全体归零(除了完全一致的那行)。
    句子级 BLEU 方差极大,所以论文里从不报句子级 BLEU,
    只报语料级(把所有句子的 n-gram 计数先加起来再算一次),
    而且通常要报多个参考译文下的分数。
  6. F1 那一节展示了"准确率"为什么会骗人:逐 token 准确率是 $0.667$,
    看起来不错,但全判 "O" 也有 $0.500$。原因是 O 标签("不是实体")
    在自然文本里占绝大多数(通常 85%+),所以准确率被 O 主导。
    实体级 F1 是 $0.400$,这才反映了真实水平。
    注意预测的 ('ORG', 3, 4) 和标准的 ('ORG', 3, 3):
    边界错了一个位置,这个实体就完全不算命中,这是 NER 评测的正确做法,
    也是为什么 NER 的 F1 看起来比"准确率"低很多。

8.4 BLEU 的已知缺陷(一张清单)

缺陷 具体表现 本页证据 缓解办法
无语义 否定、反义、同义改写无法区分 输出第 2 行:BLEU-2 = 0.756 但意思相反 BERTScore、COMET 等神经指标
无召回 只算精度,漏译靠 BP 间接惩罚 输出第 6 行:BP 而非精度惩罚了短译 显式的召回指标
几何平均脆弱 任一阶为 0 则整体为 0 全部 BLEU-4 为 0 的行 报 BLEU-1/2/4 三个数
句子级不稳定 短句方差极大 BLEU-4 全为 0 只报语料级
多参考敏感 参数量不同分数不可比 — 固定参考集
不处理词形 未做词形还原时 "cat/cats" 不匹配 — 预处理做 stemming
与人工判断相关性有限 某些情况下 BLEU 高但人工评分低 Callison-Burch et al. 2006, EACL;Reiter 2018, Computational Linguistics 44(3):393–401 用人工评估或神经指标

替代指标(知道名字和思路就够了):
- METEOR(Banerjee & Lavie 2005):引入同义词、词干匹配,比 BLEU 更好地对齐人工判断;
- ROUGE(Lin 2004):面向摘要,基于召回(BLEU 面向翻译,基于精度);
- BERTScore(Zhang et al. 2020, ICLR, arXiv:1904.09675):用 BERT 的
上下文向量算候选和参考的逐词相似度,能捕捉同义改写;
- COMET(Rei et al. 2020, arXiv:2009.09025):直接训练一个模型来预测人工评分。

一条方法论上的提醒:指标是任务定义的代理,不是任务本身。
当你看到"BLEU 涨了 2 分",要问的第一个问题是"人工评估涨了吗"。
这是 Reiter (2018) 那篇综述的核心论点。


9 和你专业的关系

这一节是这门课对认知科学专业学生最有价值的部分。分三层讲:
NLP 怎么当试验场、认知科学怎么反过来影响模型设计、以及最激烈的那场争论。

9.1 第一层:NLP 是验证"语言加工理论"的试验场

这是最实在、最容易上手的一层。

核心桥梁是"意外度"(surprisal)。定义很简单:一个词在上下文中的意外度
就是它的负对数概率

$$\text{surprisal}(w_i) = -\log P(w_i \mid w_1, \dots, w_{i-1})$$

注意这个式子和 §3.3 的困惑度只差一步:困惑度是 surprisal 的平均值再取 exp。
同一个量,一边是 NLP 的评价指标,另一边是心理语言学的自变量。

理论预言(Hale 2001, NAACL, "A Probabilistic Earley Parser as a
Psycholinguistic Model";Levy 2008, Cognition 106(3):1126–1177,
"Expectation-based syntactic comprehension"):

读者对下一个词的加工代价(阅读时间 / 眼动注视时间 / 脑电反应)随 surprisal 线性增加。

这个预言的检验方式(你可以自己做一个):

   步骤 1  拿一个语料,训练一个语言模型(哪怕就是 §3 的 bigram)
   步骤 2  对一篇人类阅读过的文本,逐词算出 surprisal
   步骤 3  把 surprisal 和人类数据做回归

             人类数据从哪来?
             ├─ 眼动:注视时间、回视率(《心理语言学》§4 的指标表)
             ├─ 自定步速阅读:每词按键时间
             └─ 脑电:N400 波幅(《语言认知神经科学》§2)

   结论(已有大量文献支持):
             surprisal 能显著预测阅读时间,而且【模型越好,预测越准】
             (Frank et al. 2015, Brain and Language 140:1–11,
               用 n-gram 和 PCFG 的 surprisal 预测 N400 波幅)

为什么这个框架对认知科学特别重要? 因为它把"语言学理论"和"心理学数据"
之间的中介变成了一个可以计算的量。

这门课给你的正是工具:§3 的困惑度、§2 的词向量、§6 的预训练模型,
全都是算 surprisal 的现成机器。《心理语言学》§6 讲的"意外度框架"
和这一节是同一个东西,那门课讲实验设计,这门课讲怎么造那个模型。

第二个接口:探针(probing)。 BERT 的中间层能不能线性解码出句法树?
(Tenney et al. 2019; Hewitt & Manning 2019)。这件事的语言学意义是:
句法信息是"预测下一个词"这个任务的必要中间产物。如果不需要,模型不会学。
这对"句法是不是独立于语义的模块"这个老问题提供了计算层面的证据。
(注意《心理语言学》和《认知语言学概论》对这个问题立场不同,
《认知语言学概论》§1 的构式语法就反对句法自治,这个分歧正好可以用探针实验去检验。)

第三:脑对齐。把语言模型的内部表示和 fMRI/MEG 数据做表征相似性分析
(RSA,《深度学习》§10.1 介绍过这个方法):
- Mitchell et al. (2008, Science 320:1191–1195):用词向量的组合预测
fMRI 体素对名词短语的反应。这是"词向量"和"脑数据"最早的成功对接。
- Schrimpf et al. (2021, PNAS 118(45)):把 11 个语言模型对每个词的
预测误差(surprisal)和人类阅读时的 MEG/fMRI 反应逐层对齐,
发现模型的层序和脑区反应的时序对应(早期层 ~ 早期感觉区,高层 ~ 顶颞区)。
- Goldstein et al. (2022, Nature Neuroscience 25:369–380):用颅内电极(ECoG)
发现人脑的预测编码和自回归语言模型的下一词预测在时间尺度上分层对应。

这一层的诚实提醒:这些研究是相关性研究。"模型第 7 层和某个脑区相关"
不等于"脑区在做第 7 层做的事"。RSA 的解释力边界见《深度学习》§10.3。

9.2 第二层:认知科学的发现反过来影响模型设计

(a)注意力这个名字就是从认知心理学借来的。
《深度学习》§8.1 讲过注意力的直觉是"软性查字典"。
但要注意:Transformer 里的 attention 和心理学里的 attention 只有
"资源分配"这个直觉相同,机制完全不同。心理学里的注意力有容量限制、
有抑制机制、有自上而下的目标调制;Transformer 的 attention 是一个
归一化的加权平均,没有容量限制($n^2$ 个位置全都算),也没有目标调制。
这个名字带来的是误导,不是洞察。这一点和《深度学习》§10.3 的
"它像不像大脑"是同一个问题的两面。

(b)"工作记忆"这个约束确实被借鉴过。早期基于 RNN 的语言模型
(Elman 1990, Cognitive Science 14(2):179–211,"Finding structure in time")
的出发点就是"用循环状态模拟工作记忆"。LSTM 的三个门也可以读成
"读/写/遗忘"的记忆控制。但这些借鉴是隐喻层面的,不是机制层面的。

(c)预测编码(predictive coding)已成为主流叙事。
Rao & Ballard (1999, Nature Neuroscience 2(1):79–87) 的视觉皮层预测编码理论
(见《深度学习》§10.3)和语言模型的"预测下一个词"在目标函数层面是同构的:
都是"最小化预测误差"。这是一个真正有内容而非仅名称的对应。
GPT 系列的成功让"预测即理解"这个认知科学命题变成了一个有工程后果的假设
(因为如果预测不蕴含理解,那我们花在预训练上的算力就用错了地方)。

(d)反向的教训:认知科学能提供"不可能性论证"。
比如语言的层级结构(《语言学概论》§5 的句法树)在纯序列模型里
原则上表示不了(这是形式语言理论里的经典结论:有限状态机不能识别
$a^n b^n$ 这类嵌套结构,见 Chomsky 1956/1957)。
Transformer 靠什么绕过这一条? 靠多层 + 位置编码 + 注意力,
它是一个有足够计算能力的近似器,但没有结构上的保证。
这是一个认知科学可以向 AI 提出的真问题:模型是"学会了"层级,
还是"用统计近似绕过了"层级?这两种情况在行为上几乎不可区分,
但在分布外泛化上差别巨大。

9.3 第三层:最激烈的争议,模型到底"懂"语言吗

这是《心理语言学》§6 那个"关键争议"的正面处理。

争论的一方("形式 vs 功能"的区分,Mahowald et al. 2024, Trends in
Cognitive Sciences
28(6):517–540, "Dissociating language and thought in
large language models"):

语言能力要分成两种。形式能力(formal competence)指掌握语音、形态、
句法的规则;功能能力(functional competence)指把语言用于交际和推理。
大模型在形式能力上已经很接近人类,在功能能力上明显不同。
所以"它懂不懂语言"这个问题本身问错了,要问"懂哪一部分"。

另一方(Bender & Koller 2020, ACL, "Climbing towards NLU",
arXiv:2005.04118):

纯形式(form-only)的训练不可能学到意义。论证是:
如果训练信号只包含语言形式,那么一个模型和一个"什么都没学到、
只是记住了形式对应关系"的模型在训练目标上无法区分
(他们用了"章鱼"思想实验:一只章鱼在完全隔绝的房间里读海量文本,
学会了完美地预测下一个词,但它不知道这些词说的是什么)。
意义来自语言和外部世界的联系,而不在语言内部。

第三条路(Chomsky 的立场):语言的核心是离散无限性和层级结构,
统计模型只是在表面分布上做近似,不是"语言能力"
(Chomsky, Roberts & Watumull 2023, The False Promise of ChatGPT,
The New York Times, 2023-03-08;这是一篇评论文章,不是同行评议论文,
引用时要说清楚)。

本页给出的可检验判据(它把哲学争论变成了实验):

判据 怎么测 现在的结果
组合泛化 训练集里没出现过的成分组合,模型能不能正确处理 明显弱于人类,但随规模改善
一致性 同一个事实换一种问法,答案是否一致 差(这与 surprisal 意义上的"会预测"并不矛盾)
对形式扰动 vs 语义扰动的敏感度 打乱词序 vs 换同义词,性能掉多少 模型对形式扰动过于敏感
探针能否解码句法 线性探针 能(Tenney et al. 2019)
分布外泛化 换领域、换语法 明显下降

对认知科学专业学生,最有利的位置是:不要站队,去做那些两边都会承认的
实验。比如:
- 把一个语言模型的 surprisal 拿来预测失语症病人的加工困难模式。
如果模型能预测,说明它抓住了人脑实际使用的那个计算层级;
- 用探针找模型里的句法表示,和 N400/P600 的双分离
(《语言认知神经科学》§2、§3)对照,模型有没有类似的"双通路"?
- 测模型在你母语的某种特异结构(比如汉语的"把"字句,《现代汉语》§7)
上的表现,和人类被试比,这是本专业独有的、AI 专业做不了的题目。

NLP 对认知科学的价值不在于"模型会思考了",
而在于它第一次给了我们一个足够强、足够可计算的"语言加工"的对照系统。
有了对照系统,"什么是人特有的"才第一次变成一个可以做的实验。


10 习题(带答案)

怎么用:先盖住答案在纸上做。第 1、2、3、5 题必须能手推
(数字都是我实跑核对过的)。第 8 题没有标准答案,但必须写出可检验的方案。


10.1 第 1 题(one-hot 与嵌入层的等价)★

问:为什么 one-hot 表示无法表达"猫"和"狗"比"猫"和"车"更相似?
然后证明:"把 one-hot 送进一个线性层"和"查一个嵌入表"是同一件事。

答案:

第一部分:one-hot 向量两两正交(内积为 0),所以任意两个不同词的
余弦相似度恒等于 0。这是结构上的不可能,不是"不够准确"。
one-hot 把 $V$ 个词放在 $V$ 个互相垂直的坐标轴上,
词与词之间没有任何共享的方向。而"相似"在几何上意味着"方向接近",
所以 one-hot 的坐标系里根本没有"接近"这个概念的容身之处。

第二部分:设嵌入矩阵 $E \in \mathbb{R}^{V \times d}$,
词 $i$ 的 one-hot 是 $\mathbf{x} = \mathbf{e}_i \in \mathbb{R}^V$。
一个线性层(无偏置)算的是 $\mathbf{y} = E^\top \mathbf{x}$。
由于 $\mathbf{x}$ 只有第 $i$ 位是 1,这个矩阵乘法只在第 $i$ 列上取值:

import numpy as np
V, dim = 4, 3
E = np.arange(V * dim, dtype=float).reshape(V, dim)
print("嵌入矩阵 E =\n", E)
for i in range(V):
    x = np.zeros(V); x[i] = 1.0
    print("  one-hot(%d) 过线性层 E^T x = %s ; E 的第 %d 行 = %s" % (i, E.T @ x, i, E[i]))

实跑输出

嵌入矩阵 E =
 [[ 0.  1.  2.]
 [ 3.  4.  5.]
 [ 6.  7.  8.]
 [ 9. 10. 11.]]
  one-hot(0) 过线性层 E^T x = [0. 1. 2.] ; E 的第 0 行 = [0. 1. 2.]
  one-hot(1) 过线性层 E^T x = [3. 4. 5.] ; E 的第 1 行 = [3. 4. 5.]
  one-hot(2) 过线性层 E^T x = [6. 7. 8.] ; E 的第 2 行 = [6. 7. 8.]
  one-hot(3) 过线性层 E^T x = [ 9. 10. 11.] ; E 的第 3 行 = [ 9. 10. 11.]

关键结论:$\mathbf{y} = E^\top \mathbf{e}_i = E_{i,:}$,
也就是"乘 one-hot"($V d$ 次乘法)和"取第 $i$ 行"($d$ 次读取)结果完全一样,
但计算量差 $V$ 倍。这就是为什么 PyTorch 里的 nn.Embedding 在数学上就是
nn.Linear,只是实现成了查表。这一段之所以重要:它说明词向量是"把离散符号表示成连续向量的最自然的方式",而不是"额外的
魔法"。
第一层线性变换的权重矩阵,就是词向量表。


10.2 第 2 题(负采样梯度推导)★★

问:写出负采样损失
$$\ell = -\log\sigma(\mathbf{u}_o^\top\mathbf{v}_c) - \sum_{k=1}^{K}\log\sigma(-\mathbf{u}_{w_k}^\top\mathbf{v}_c)$$
对 $\mathbf{u}_o$、$\mathbf{u}_{w_k}$、$\mathbf{v}_c$ 的梯度,并用数值梯度核对。

答案:

先记 $\sigma'(x) = \sigma(x)(1-\sigma(x))$,且 $\dfrac{d}{dx}\log\sigma(x) = 1-\sigma(x)$。

(a) 对正样本上下文向量 $\mathbf{u}_o$:
$$\frac{\partial\ell}{\partial\mathbf{u}_o} = -\big(1-\sigma(\mathbf{u}_o^\top\mathbf{v}_c)\big)\mathbf{v}_c = \big(\sigma(\mathbf{u}_o^\top\mathbf{v}_c)-1\big)\mathbf{v}_c$$
方向解读:括号里恒为负,所以梯度方向是 $-\mathbf{v}_c$,
梯度下降减掉它,等价于把 $\mathbf{u}_o$ 推向 $+\mathbf{v}_c$。
而且模型越判错($\sigma$ 越小),推力越大,这就是"学习"。

(b) 对负样本上下文向量 $\mathbf{u}_{w_k}$:
$$\frac{\partial\ell}{\partial\mathbf{u}_{w_k}} = \sigma(\mathbf{u}_{w_k}^\top\mathbf{v}_c)\,\mathbf{v}_c$$
(注意 $-\log\sigma(-x)$ 求导时负号两次抵消。)方向是 $+\mathbf{v}_c$,
梯度下降把它减掉,等于把负样本推离 $\mathbf{v}_c$。
同样,模型越判错(把负样本判得太高),推力越大。

(c) 对中心词向量 $\mathbf{v}_c$:
$$\frac{\partial\ell}{\partial\mathbf{v}_c} = \big(\sigma(\mathbf{u}_o^\top\mathbf{v}_c)-1\big)\mathbf{u}_o + \sum_{k=1}^{K}\sigma(\mathbf{u}_{w_k}^\top\mathbf{v}_c)\,\mathbf{u}_{w_k}$$
即:拉向正样本的上下文向量,推离所有负样本的上下文向量。

数值核对(全部通过):

dL/du_o 解析 = [-0.068999  0.072498 -0.351457 -0.057568  0.29397  -0.19844 ]
dL/du_o 数值 = [-0.068999  0.072498 -0.351457 -0.057568  0.29397  -0.19844 ]  偏差 6.45e-11
dL/du_n 解析 = [ 0.034787 -0.036551  0.177194  0.029024 -0.14821   0.100047]
dL/du_n 数值 = [ 0.034787 -0.036551  0.177194  0.029024 -0.14821   0.100047]  偏差 6.17e-11
dL/dv_c 解析 = [-1.358917 -0.580284  0.041481  0.491844  0.191459 -0.110194]
dL/dv_c 数值 = [-1.358917 -0.580284  0.041481  0.491844  0.191459 -0.110194]  偏差 1.01e-10

注意和 §2.2.4 代码里那一行的关系:代码里写的是
g = (lab - p) * lr,其中 lab 是 0 或 1、p 是 $\sigma$。
对正样本 lab=1:$1-p = -(\sigma-1)$,正是 (a) 里的括号项(差一个负号,
因为代码里是"减去梯度")。代码里的一个数组表达式同时覆盖了正样本和负样本,
这就是向量化写法优雅的地方。


10.3 第 3 题(困惑度手算)★

问:语料只有两句话:the cat sat、the dog sat。
用 unigram + 加 1 平滑,计算测试句 the cat sat 的困惑度。
(句首句尾符号忽略不计。)

答案:

第一步:数数。词表 $V = \{\text{the, cat, dog, sat}\}$,共 4 个词;
总词次 $N = 2 + 1 + 1 + 2 = 6$。

词 count
the 2
cat 1
dog 1
sat 2

第二步:加 1 平滑求概率,公式 $P(w) = \dfrac{\mathrm{count}(w)+1}{N + V} = \dfrac{\mathrm{count}(w)+1}{10}$:

词 计算 概率
the $(2+1)/10$ 0.300000
cat $(1+1)/10$ 0.200000
sat $(2+1)/10$ 0.300000

第三步:

$$\mathrm{PPL} = \exp\left(-\frac{\log 0.3 + \log 0.2 + \log 0.3}{3}\right) = \exp\left(\frac{1.203973 + 1.609438 + 1.203973}{3}\right) = \exp(1.339128) = 3.8157$$

读这个数字:$3.8157$ 介于 $1$ 和 $V=4$ 之间,比均匀基线 $4$ 只好了一点点。
原因:测试句的三个词都是高频词(the、sat 各出现 2 次),
换个低频词 PPL 会立刻变差。这正说明 unigram 只是在背词频,没有用上下文。

一个坑:$V$ 必须用词表大小(4),不是"测试句里的词数"(3)。
分母 $N + V = 10$ 里的 $V$ 来自"分子给每个可能的词都加了 1"。


10.4 第 4 题(困惑度的性质)★★

问:(a) 证明:如果模型对词表里每个词都给出均匀概率 $1/V$,
则任意测试集的困惑度都是 $V$。
(b) 为什么"用词切分的模型"和"用 BPE 子词切分的模型"的 PPL 不能直接比较?
(c) 一个模型的 PPL 更低,是否意味着它在所有任务上都更好?举例说明。

答案:

(a) 均匀模型下每个词的 $P(w_i \mid \cdot) = 1/V$。代入定义:

$$\mathrm{PPL} = \exp\left(-\frac{1}{N}\sum_{i=1}^{N}\log\frac{1}{V}\right) = \exp\left(-\frac{1}{N}\cdot N \cdot (-\log V)\right) = \exp(\log V) = V$$

所以 $V$ 是 PPL 的"零信息基线":任何 PPL 高于 $V$ 的模型(可能因为平滑过头
或分布不匹配)比什么都不学还差。§3.4 的【四】里,
加 1 平滑的 unigram 得到 $10.14$,而 $V=11$,离基线只差 8%,
说明那个模型几乎什么都没学到。

(b) PPL 的定义是"平均每个词的负对数似然再取 exp",
其中的"每个词"就是分词器定义的单位。两种分词下,"一步"的长度完全不同:

   词语表:  [北京] [大学]         ->  2 步
   字词表:  [北] [京] [大] [学]   ->  4 步

字级别的模型每一步面对的选择少得多(更容易猜中),所以字级 PPL 天生比词级低,
这跟模型好坏没关系。换分词 = 换了度量单位,数字不可比。
论文里报 PPL 必须同时说明:分词方案、词表大小、测试集、是否做了未知词处理。
这是读论文时必须养成的习惯。

(c) 不意味着。三个反例:
1. PPL 是平均量,掩盖分布尾部。一个模型在常见结构上极准、在长距离依存上
极差,平均下来 PPL 可能仍然很好。而长距离依存正是语言学最关心的部分。
2. PPL 不测"一致性"。一个模型可以给两种互相矛盾的答案都很高的概率
(比如 "巴黎是法国的首都" 和 "巴黎不是法国的首都" 都给高概率),
PPL 完全察觉不到。
3. 数据泄漏。测试集如果和预训练语料重叠,PPL 会非常漂亮但毫无意义。


10.5 第 5 题(Viterbi 手算)★★

问:两个隐状态 $\{1,2\}$,观测序列 $o_1 o_2 o_3$。参数如下:
$\pi = (0.6,\ 0.4)$;
$A = \begin{pmatrix} 0.7 & 0.3 \\ 0.4 & 0.6\end{pmatrix}$(行是当前状态,列是下一状态);
$B = \begin{pmatrix} 0.5 & 0.4 & 0.1 \\ 0.1 & 0.3 & 0.6\end{pmatrix}$
(行是状态,列是 $o_1,o_2,o_3$)。
用 Viterbi 求最优状态序列(保留 4 位小数即可)。

答案:

初始化($\delta_1(t) = \pi_t B_{t,o_1}$):

第 2 步($\delta_2(t) = \max_{t'}\big[\delta_1(t')A_{t',t}\big]\cdot B_{t,o_2}$):

第 3 步:

回溯:$\delta_3$ 的最大值在状态 2($0.01512$);
$\psi_3(2)=1$,$\psi_2(1)=1$。

最优路径:$(1,\ 1,\ 2)$,概率 $= 0.015120$。

代码核对:

   delta_1 = [0.3  0.04]
   delta_2 = [0.084 0.027]   psi_2 = [0, 0]
   delta_3 = [0.00588 0.01512]   psi_3 = [0, 0]
   最优路径: [0, 0, 1]  概率 = 0.015120

(代码里状态下标是 0/1,对应上面的 1/2。)

三个要看清的点:
1. 第 3 步在状态 2 上取胜,靠的是发射概率 $B_{2,o_3}=0.6$ 远大于 $B_{1,o_3}=0.1$。
转移概率其实是偏向留在状态 1 的,但观测证据压倒了先验。
2. Viterbi 只保留每步每个状态的"最优前驱",扔掉其他所有路径。
这一步是基于最优子结构:如果某条路径在第 2 步就已经被同一终点的另一条压过,
那么它们后面乘的因子完全相同,它永远翻不了盘。
3. 复杂度是 $O(n|S|^2)$,这里是 $3 \times 4 = 12$ 次乘法;
穷举是 $2^3 = 8$ 条路径(这里穷举还更快)。Viterbi 的优势在长序列上才体现:
$n=20, |S|=4$ 时,Viterbi 约 $20\times16 = 320$ 次,穷举是 $4^{20} \approx 10^{12}$ 次。


10.6 第 6 题(计算量对比)★★

问:词表 $V = 50000$,词向量维度 $d = 300$。
(a) 用全词表 softmax,算一个词对 $P(o\mid c)$ 的前向 + 反向需要多少次浮点运算?
(b) 用负采样 $K=5$ 呢?比值是多少?
(c) 如果语料有 10 亿个词对,两种做法各需要多少次运算?给出量级。

答案:

(a) 前向要算 $V$ 个内积,每个内积 $d$ 次乘法 + $d$ 次加法 $\approx 2d$ 次运算:

$$V \times 2d = 50000 \times 600 = 3\times10^{7}$$

反向还要对每个词表的向量算梯度并更新,量级相同,所以前向 + 反向 $\approx 6\times10^7$。
下面的比较统一用"前向"这个口径(§2.2.4 代码里算的也是 $Vd\times 2$ 这个量),
因为两边同时乘 2 不改变比值。

(b) 负采样只涉及 $K+1 = 6$ 个向量,前向:

$$6 \times 2 \times 300 = 3600$$

比值:$3\times10^7 / 3600 \approx 8333$ 倍。
(如果两边都算前向 + 反向,比值是 $6\times10^7 / 7200 = 8333$,一样。)

(c) 10 亿个词对:

量级差 $10^4$。以每秒 $10^{12}$ 次运算(现代 GPU 的一个保守估计)计,
负采样约 1 小时,全 softmax 约 8 小时,而这只是一遍,
真实训练要跑很多遍。这就是 §2.2.3 那个推导的全部意义。

代码核对:

  V=50000 d=300 K=5 -> full 3.000e+07, neg 3600, 比值 8333 倍
  V=50000 d=300 K=1 -> full 3.000e+07, neg 1200, 比值 25000 倍

追问:负采样是唯一的办法吗?不是。还有层次 softmax
(把 $V$ 个词组织成一棵二叉树,复杂度降到 $O(\log V)$)
和稀疏梯度更新(只更新出现过的词,这是所有实现都在做的优化)。
负采样胜在简单且效果好。


10.7 第 7 题(评测指标的判读)★

问:判断下面每个说法对不对,并说明理由。

  1. 「A 系统的 BLEU-4 是 0.32,B 系统是 0.29,所以 A 的翻译质量更好。」
  2. 「我们的 NER 模型准确率 92%,效果很好。」
  3. 「我们的分词 F1 是 0.97,比 SIGHAN 冠军的 0.95 高,说明我们更强。」
  4. 「模型 A 在句子级测试上 BLEU-4 是 0,所以它完全不会翻译。」

答案:

  1. 不一定对。四个问题:
    (a) 参考译文集是否相同? 不同参考集下的 BLEU 不可比。
    (b) 分词/预处理是否相同? 不同的 tokenization 直接改变 BLEU。
    (c) 报告的是句子级还是语料级? 句子级 BLEU 方差极大,不能这么比。
    (d) 差值 0.03 是否显著? BLEU 的置信区间通常需要 bootstrap 估计,
    小数据上的 0.03 可能在噪声范围内。正确的说法是:
    "在同一参考集、同一预处理、语料级 BLEU-4 下,A 比 B 高 0.03",
    并且最好补一句人工评估或显著性检验。

  2. 不对(信息不足)。这正是 §7.3 那个例子的教训:
    NER 里绝大多数 token 是 "O"(非实体),全判 O 的"模型"在典型语料上
    准确率就有 85% 以上。92% 可能比瞎猜好不了多少。
    必须报实体级 P/R/F1(标准做法是严格匹配实体边界 + 类型)。

  3. 不对。分词 F1 强依赖于标注规范(还记得"看书"那个例子吗?)。
    不同规范的数据集上,F1 不可比,甚至同一个数据集的不同版本之间都可能有差异。
    要比较必须在同一份标准测试集上(比如 SIGHAN Bakeoff 的公开数据)
    且说明用的是哪个版本。

  4. 不对。 BLEU-4 需要 4-gram 命中,短句上几乎必然为 0
    (§7.3 那张表里 6 行有 5 行是 0)。句子级 BLEU 本来就不该单独解读,
    它是个高方差、对短句极不友好的量。要看句子级质量,
    应该用人工评估或者 BERTScore 这类神经指标。


10.8 第 8 题(认知科学的设计题)★★★

问:你的毕业论文想做"大语言模型的语言加工和人类是否相似"。
请设计一个可执行的实验方案,要求:
(a) 用上本页至少两个技术(surprisal / 探针 / 词向量 / 注意力);
(b) 明确自变量、因变量、控制变量;
(c) 给出能证伪的预言;
(d) 说明至少一个已知的混淆因素及应对。

参考答案(要点,不唯一):

(a) 技术选择:用 surprisal(§3.3 的困惑度)作为核心自变量,
用探针(§8.1)作为补充测量。

(b) 变量设计:

内容
自变量 1 词的 surprisal(由语言模型算,连续变量)
自变量 2 加工层级:词汇 / 句法 / 语义违例(三水平,被试内)
因变量 (i) 自定步速阅读的按键时间;(ii) 可选:N400/P600 波幅
控制变量 词长、词频、位置、不可预测性以外的句法复杂度

(c) 可证伪的预言(这一条是关键,"能证伪"意味着说清楚什么结果会推翻假设):

预言:surprisal 对阅读时间的预测能力,在同一层级内显著,
跨层级泛化时显著下降。

什么结果会推翻它:
- 如果一个只在大规模文本上训练的模型的 surprisal 能同时预测
三个层级的加工代价,且幅度一致,那说明人的加工机制就是一个
统一的下一词预测器,层级区分是多余的;
- 否则说明人的加工至少有两个不同的机制在起作用,
单靠链条式的 surprisal 解释不了。

(d) 混淆因素与应对(至少写一个):

(e) 为什么这是一个认知科学专业学生有优势的题目:
AI 专业的学生能训练模型,但设计出在语言学上干净的材料、
并把结果和人类数据对齐,需要语言学 + 实验方法的训练,
这正是你四年学到的东西。 §8.3 那张判据表里的每一条,
都可以变成一篇论文。


11 出处索引

本页提到的所有文献,按主题分类。标注了本页出现的位置。
凡是我不能确认的,明确写"出处待查",没有编造。

11.1 词表示与词向量

文献 出处 本页位置
Harris, Distributional Structure Word 10(2–3):146–162, 1954 §1.3
Firth, A Synopsis of Linguistic Theory 1930–1955 Studies in Linguistic Analysis, 1957 §1.3
Mikolov, Chen, Corrado & Dean, Efficient Estimation of Word Representations in Vector Space arXiv:1301.3781, 2013(Word2Vec 原始论文) §2.2
Mikolov, Sutskever, Chen, Corrado & Dean, Distributed Representations of Words and Phrases and their Compositionality NIPS 2013, arXiv:1310.4546(提出负采样与高频词下采样) §2.2
Pennington, Socher & Manning, GloVe: Global Vectors for Word Representation EMNLP 2014 §2.2(提及)
Levy & Goldberg, Neural Word Embedding as Implicit Matrix Factorization NIPS 2014(证明 SGNS ≈ PMI 矩阵分解) §2.2.3
Arora, Li, Liang, Ma & Risteski, A Latent Variable Model Approach to PMI-based Word Embeddings TACL 4:385–399, 2016 §2.2.3(提及)
Bolukbasi, Chang, Zou, Saligrama & Kalai, Man is to Computer Programmer as Woman is to Homemaker? Debiasing Word Embeddings NeurIPS 2016 §2.2.6
Peters et al., Deep Contextualized Word Representations (ELMo) NAACL 2018, arXiv:1802.05365 §2.3
Sennrich, Haddow & Birch, Neural Machine Translation of Rare Words with Subword Units (BPE) ACL 2016, arXiv:1508.07909 §2.4
Schuster & Nakamura, Japanese and Korean Voice Search (WordPiece) ICASSP 2012 §2.4
Linzen, Issues in evaluating semantic spaces using word analogies 出处待查(我确认不了确切的会议信息,只记得是 2016 年前后的论文) §2.2.5

11.2 语言模型与序列标注

文献 出处 本页位置
Shannon, A Mathematical Theory of Communication Bell System Technical Journal 27:379–423, 623–656, 1948 §3.1(背景)
Zipf, Human Behavior and the Principle of Least Effort Addison-Wesley, 1949 §导论③
Good, The Population Frequencies of Species and the Estimation of Population Parameters Biometrika 40(3–4):237–264, 1953 §3.2
Katz, Estimation of probabilities from sparse data for the language model component of a speech recognizer IEEE Trans. ASSP 35(3):400–401, 1987 §3.2
Kneser & Ney, Improved backing-off for m-gram language modeling ICASSP 1995 §3.2
Chen & Goodman, An empirical study of smoothing techniques for language modeling Computer Speech & Language 13(4):359–394, 1999 §3.2
Bengio, Ducharme, Vincent & Janvin, A Neural Probabilistic Language Model JMLR 3:1137–1155, 2003 §3.5
Viterbi, Error bounds for convolutional codes and an asymptotically optimum decoding algorithm IEEE Trans. Information Theory 13(2):260–269, 1967 §4.3
Baum & Petrie, Statistical Inference for Probabilistic Functions of Finite State Markov Chains Annals of Mathematical Statistics 37(6):1554–1563, 1966 §4.2
Rabiner, A Tutorial on Hidden Markov Models and Selected Applications in Speech Recognition Proceedings of the IEEE 77(2):257–286, 1989 §4.2
Lafferty, McCallum & Pereira, Conditional Random Fields: Probabilistic Models for Segmenting and Labeling Sequence Data ICML 2001 §4.5
Huang, Xu & Yu, Bidirectional LSTM-CRF Models for Sequence Tagging arXiv:1508.01991, 2015 §4.5
Xue, Chinese Word Segmentation as Character Tagging Computational Linguistics and Chinese Language Processing 8(1):29–48, 2003 §4.1
Low, Ng & Guo, A Maximum Entropy Approach to Chinese Word Segmentation SIGHAN Workshop 2005 §4.6
Elman, Finding Structure in Time Cognitive Science 14(2):179–211, 1990 §8.2
最大匹配法(正向最大匹配)的中文分词早期文献 出处待查(这是 1980 年代中文信息处理的常规做法,我看到的多为中文期刊文献,但无法确认一篇权威原始出处,故不列具体条目) §4.4

11.3 句法分析

文献 出处 本页位置
Chomsky, Syntactic Structures Mouton, 1957 §5.1
Tesnière, Éléments de syntaxe structurale Klincksieck, 1959 §5.1
Nivre, An Efficient Algorithm for Projective Dependency Parsing IWPT 2003(arc-eager) §5.2
Nivre, Incrementality in Deterministic Dependency Parsing ACL Workshop on Incremental Parsing, 2004(arc-standard 与 oracle) §5.2
Chen & Manning, A Fast and Accurate Dependency Parser using Neural Networks EMNLP 2014 §5.2
Nivre et al., Universal Dependencies v1: A Multilingual Treebank Collection LREC 2016 §1.3

11.4 预训练与 Transformer 时代

文献 出处 本页位置
Vaswani et al., Attention Is All You Need NeurIPS 2017, arXiv:1706.03762(完整推导见《深度学习》§8) §6.1
Devlin, Chang, Lee & Toutanova, BERT: Pre-training of Deep Bidirectional Transformers for Language Understanding NAACL 2019, arXiv:1810.04805 §2.3, §6
Liu et al., RoBERTa: A Robustly Optimized BERT Pretraining Approach arXiv:1907.11692, 2019 §2.3
Radford et al., Language Models are Unsupervised Multitask Learners (GPT-2) OpenAI 技术报告, 2019 §6.2
Brown et al., Language Models are Few-Shot Learners (GPT-3) NeurIPS 2020, arXiv:2005.14165 §6.2
Raffel et al., Exploring the Limits of Transfer Learning with a Unified Text-to-Text Transformer (T5) JMLR 21:1–67, 2020, arXiv:1910.10683 §6.2(提及)
Tenney, Das & Pavlick, BERT Rediscovers the Classical NLP Pipeline ACL 2019, arXiv:1905.05950 §1.4, §6.2
Hewitt & Manning, A Structural Probe for Finding Syntax in Word Representations NAACL 2019, arXiv:1904.01500 §6.2
Hewitt & Liang, Designing and Interpreting Probes with Control Tasks EMNLP 2019, arXiv:1909.03368 §6.4

11.5 评测

文献 出处 本页位置
Papineni, Roukos, Ward & Zhu, BLEU: a Method for Automatic Evaluation of Machine Translation ACL 2002, pp. 311–318 §7.2
Lin, ROUGE: A Package for Automatic Evaluation of Summaries ACL Workshop "Text Summarization Branches Out", 2004 §7.4
Banerjee & Lavie, METEOR: An Automatic Metric for MT Evaluation with Improved Correlation with Human Judgments ACL Workshop 2005 §7.4
Callison-Burch, Osborne & Koehn, Re-evaluating the Role of BLEU in Machine Translation Research EACL 2006 §7.4
Reiter, A Structured Review of the Validity of BLEU Computational Linguistics 44(3):393–401, 2018 §7.4
Zhang, Kishore, Wu, Weinberger & Artzi, BERTScore: Evaluating Text Generation with BERT ICLR 2020, arXiv:1904.09675 §7.4
Rei, Stewart, Farinha & Lavie, COMET: A Neural Framework for MT Evaluation EMNLP 2020, arXiv:2009.09025 §7.4

11.6 认知科学交叉

文献 出处 本页位置
Hale, A Probabilistic Earley Parser as a Psycholinguistic Model NAACL 2001 §8.1
Levy, Expectation-based syntactic comprehension Cognition 106(3):1126–1177, 2008 §8.1
Frank, Otten, Galli & Vigliocco, The ERP response to the amount of information conveyed by words in sentences Brain and Language 140:1–11, 2015 §8.1
Mitchell et al., Predicting Human Brain Activity Associated with the Meanings of Nouns Science 320:1191–1195, 2008 §8.1
Rao & Ballard, Predictive coding in the visual cortex Nature Neuroscience 2(1):79–87, 1999 §8.2
Schrimpf et al., The neural architecture of language: Integrative modeling converges on predictive processing PNAS 118(45), 2021 §8.1
Goldstein et al., Shared computational principles for language processing in humans and deep language models Nature Neuroscience 25:369–380, 2022 §8.1
Bender & Koller, Climbing towards NLU: On Meaning, Form, and Understanding in the Age of Data ACL 2020, arXiv:2005.04118 §2.2.6, §8.3
Bender, Gebru, McMillan-Major & Shmitchell, On the Dangers of Stochastic Parrots FAccT 2021 §8.3(背景)
Mahowald et al., Dissociating language and thought in large language models Trends in Cognitive Sciences 28(6):517–540, 2024 §8.3
Chomsky, Roberts & Watumull, The False Promise of ChatGPT The New York Times, 2023-03-08(评论文章,非同行评议) §8.3
Chomsky, Three Models for the Description of Language IRE Transactions on Information Theory 2(3):113–124, 1956 §8.2

11.7 三处我明确没能完全确认的地方,如实标注

  1. "每当我解雇一个语言学家,系统性能就上升一点"(§1.4)。
    这句话通常归于 Frederick Jelinek,但我确认不了首次出现的正式出处。
    该说法在 NLP 圈广泛流传,可能的原始场合是 1980 年代的演讲或访谈。
    引用时请写"据传为 Jelinek 所言"。

  2. 最大匹配法的原始文献(§4.4)。这是 1980 年代中文信息处理的通行做法,
    我能找到的记载多为中文期刊论文(如《中文信息学报》早期卷),
    但无法确认一篇权威的首次提出文献,因此没有列出具体条目。
    如果你要引用,建议用综述性来源(如宗成庆《统计自然语言处理》教材)。

  3. 词类比评测的批评文献(§2.2.5)。我确认存在这样一篇工作
    (Linzen 2016 前后,讨论类比任务的评测偏差,包括"随机向量也能拿分"的对照),
    但无法确认确切的会议/期刊信息,故标"出处待查"。
    §2.2.5 里关于"排除输入词后准确率下降"的论断,是本页代码
    (near(vec, exc=(...)) 那个参数)实际演示出来的,不依赖那篇文献。

最后一句:这一页的所有代码我都实跑过,
所有引用的数字要么来自我实际运行的输出,要么标注了文献出处。
如果哪一处你复现不出来,请以你的实际输出为准,这可能是我漏掉的环境差异。

12 相关

最后更新:2026-09-26