想象一下,一家新公司刚入职了四名员工:小猫、喜欢、吃、鱼。HR 今天最头疼的任务是:确定每个人的直属上司,画出一张全员汇报关系图——谁是 CEO?谁向谁汇报?谁管着谁?对自然语言处理来说,每个句子就像一个迷你公司,每个词是一名员工,而“句法分析”就是帮这个“句子公司”画出组织架构图。 2017 年,Dozat 和 Manning 提出了 Deep Biaffine Attention 模型,相当于给 NLP 界打造了一套超级智能的 HR 系统:它给每个词发两张“名牌”——一张当老板用的、一张当下属用的,然后通过一个叫“双仿射”的配对打分器,计算任意两个人搭班的和谐度,最后选出最合理的上下级关系,形成一棵结构清晰的“组织树”。这套模型至今仍是依存句法分析领域的经典基线与教科书级架构。这篇文章,我们就用“给每个词找老板”的故事,把 Deep Biaffine 的任务、原理、架构、训练和应用一次讲透。

岗位关联

掌握 Deep Biaffine,你就在 NLP 句法分析领域拿到了“经典驾驶执照”——复现论文、搭建信息抽取管线、面试谈架构设计时,都能直接加分。

目录
  1. 可视化演示
  2. 任务定义
  3. 依存语法基础
  4. 模型架构
  5. Biaffine核心
  6. 训练与解码
  7. 应用价值
  8. AI 应用场景
  9. Python 演示
  10. 常见误区
  11. 面试题

可视化演示

展示Dozat & Manning 2017提出的Deep Biaffine Attention依存句法分析模型:四层架构(输入→BiLSTM→投影→Biaffine)、弧评分矩阵计算、以及依存句法树构建。

本节配有交互动效(进入视口后自动加载,约 3KB)。

'+captions.arch+'

任务定义

HR 面前是一团乱麻:小猫、喜欢、吃、鱼四个人都互相认识,但没人说得清谁该向谁汇报。HR 的任务很明确——画一张图:图上每个员工只能有一个箭头指向自己的直属老板,全公司只能有一个人头顶上没有箭头,那就是 CEO。这张图,就是句法分析领域说的“依存树”:节点是词,有向弧是“谁支配谁”。

从技术上讲,给出一句词序列 $w_1, w_2, \dots, w_n$,依存句法分析要输出三样东西:哪些词对之间需要连一条有向弧、弧的指向(谁是“头”谁是“依存”)、以及弧上贴着什么关系标签。所有弧合在一起,必须构成一棵以唯一根节点为顶点的树——因为一个员工不能同时向两个老板汇报,也不能让整个公司出现“你管我、我管你”的循环。

为了保证这一点,模型在数学上强制了两条约束:每个非根词恰好有一个“老板”(单一父节点),整个图连通且无环。实际系统中,人们还会给句子加一个“虚拟根”节点,让真正的核心动词都指向它,这样树就有了统一的顶点,打分矩阵也更好构造。

所以 Deep Biaffine 模型干的事情,说白了就是“组织架构图自动生成”。HR 先给每个员工准备好名牌,再用一套打分系统决定谁是谁的老板——下面我们就去看看,这套系统中的“岗位惯例”和“打分规则”分别是什么。

∃ r∈V:deg⁡−(r)=0,∀ i≠r: deg⁡−(i)=1\exists\, r \in V:\quad \deg^{-}(r) = 0, \qquad \forall\, i \neq r:\ \deg^{-}(i) = 1
一句话说就是:除了 CEO 没有老板之外,每个员工(词)都恰好有一个老板,且全公司连通成一棵树。
  1. 句子词集合 V$V = \{w_1, w_2, \dots, w_n\}$ 表示把句子拆成 n 个词组成的集合,每个词就是公司里的一名“员工”。
    V={w1,w2,…,wn}V = \{w_1, w_2, \dots, w_n\}
  2. 入度 deg⁻$\deg^{-}(i)$ 统计指向词 i 的弧的数量,也就是这个员工头上顶着的“老板人数”。
    deg⁡−(i)=∣{ j∣arc(j→i) }∣\deg^{-}(i) = \left|\{\, j \mid \text{arc}(j \to i) \,\}\right|
  3. 单一父节点约束$\forall\, i \neq r:\ \deg^{-}(i) = 1$ 表示除了根以外,每个词恰好有一个老板,不能多也不能少。
    ∀ i≠r: deg⁡−(i)=1\forall\, i \neq r:\ \deg^{-}(i) = 1
  4. 唯一根节点$\deg^{-}(r) = 0$ 表示恰好存在一个“无人领导”的 CEO;配合“连通无环”,整张图就是一棵标准组织树。
    ∃ r∈V: deg⁡−(r)=0\exists\, r \in V:\ \deg^{-}(r) = 0

依存语法基础

光画出汇报关系还不够,HR 还得在每条弧上贴一个“岗位标签”:这是直线汇报(部门主管),还是项目虚线汇报(横向协作)?语言学家早就为句子里的上下级关系写好了一本“岗位手册”,这便是依存语法。

手册的第一条铁律叫“动词中心”:一个句子的骨架通常由核心谓词(谓语动词)搭起来。比如“小猫喜欢吃鱼”里,“喜欢”是总经理、“吃”是部门经理,名词、副词、形容词都挂在它们周围。句法树的根节点,绝大多数情况下就是那个核心动词——所以模型其实有个隐含预期:CEO 该由句子里的骨干动词来当。

手册的第二条是“关系类型”:弧上需要标注具体是哪种上下级关系。常见的有 nsubj(主语关系,如“小猫”之于“喜欢”)、obj(宾语关系,如“鱼”之于“吃”)、det(限定关系,“这条鱼”的“这”)、advmod(状语关系,“开心地吃”的“开心地”)等等。每条弧带着标签,组织架构图的信息量立刻翻倍。

手册里还有一条温柔的规律叫“投影性”:正常情况下,把弧画在词上方时它们不会互相交叉——就像大公司的管理线总是层级分明、很少出现“隔层乱报”的混乱局面。这为后面的解码算法提供了很好的先验。

有了这本岗位手册,HR 给每条弧贴职称时就有了标准答案。但真正难的是:面对一个全新的句子,怎么自动算出“谁该向谁汇报”?这就要靠下面这套神经网络流水线了。

r=root(喜欢),s=nsubj(小猫→喜欢),o=obj(鱼→吃)r = \mathrm{root}(\text{喜欢}), \quad s = \mathrm{nsubj}(\text{小猫} \to \text{喜欢}), \quad o = \mathrm{obj}(\text{鱼} \to \text{吃})
这套记号是在说:“喜欢”是根;小猫以“主语”身份向喜欢汇报;鱼以“宾语”身份归吃管。弧的标签就是岗位头衔。
  1. 根标记$\mathrm{root}(\text{喜欢})$ 表示“喜欢”是整棵树的根节点,对应公司里那个没有老板的 CEO。
    root(喜欢)\mathrm{root}(\text{喜欢})
  2. 主谓关系$\mathrm{nsubj}(\text{小猫} \to \text{喜欢})$ 表示“小猫”以主语身份挂在“喜欢”下面,是它的直接下属,弧上贴着“主谓”标签。
    nsubj(小猫→喜欢)\mathrm{nsubj}(\text{小猫} \to \text{喜欢})
  3. 动宾关系$\mathrm{obj}(\text{鱼} \to \text{吃})$ 表示“鱼”是“吃”的宾语——“鱼”是“吃”管着的对象,标签为“动宾”。
    obj(鱼→吃)\mathrm{obj}(\text{鱼} \to \text{吃})

模型架构

新句子入职,HR 的流程分三段:前台登记信息(词嵌入),秘书通读全公司档案(BiLSTM),主管给每个人发两种角色的名牌(双 MLP)。三段各司其职,共同完成“看懂句子、分饰两角”的准备工作。

第一段是词嵌入。每个词 $w_t$ 被映射成一个稠密向量 $\mathbf{x}_t$。Dozat 和 Manning 的原始实现里,输入通常由三部分拼接而成:随机初始化的词向量、词性标注(POS)向量、以及预训练好的词向量——相当于前台上传了员工的三份材料:姓名、职位、历史履历。

第二段是 BiLSTM 编码。双向 LSTM 先从左往右读一遍句子,再从右往左读一遍,然后把两个方向的隐状态拼起来,得到每个位置 $t$ 的上下文表示 $\mathbf{h}_t$。这一步让每个词“见过全公司所有人”——“小猫”知道自己是主语还是宾语,不再只是孤立的名字。

第三段是双 MLP 角色分派。对同一个 $\mathbf{h}_i$,用两套独立的多层感知机各压一次:$\mathrm{MLP}_{\mathrm{head}}$ 产出“老板牌”表示,$\mathrm{MLP}_{\mathrm{dep}}$ 产出“下属牌”表示。这两个 MLP 还会把向量维度从几百维压缩到一百维左右,既减少参数,又强迫表示只聚焦“这个人适不适合当老板/当下属”这一件事。

到这一步,HR 手上已经有了全体员工的两份档案:一份考察他当老板的资质,一份考察他当下属的适配度。接下来的核心问题就是:怎么用这两份档案,给任意一对“下属 × 老板”打出和谐度分数?答案就是本篇文章的主角——Biaffine 打分器。

ht=BiLSTM(x1,…,xn)t\mathbf{h}_t = \mathrm{BiLSTM}(\mathbf{x}_1, \dots, \mathbf{x}_n)_t
rihead=MLPhead(hi),ridep=MLPdep(hi)\mathbf{r}_i^{\mathrm{head}} = \mathrm{MLP}_{\mathrm{head}}(\mathbf{h}_i), \qquad\mathbf{r}_i^{\mathrm{dep}} = \mathrm{MLP}_{\mathrm{dep}}(\mathbf{h}_i)
双向编码让每个词看到完整句子;双 MLP 把同一份上下文“投影”成两种角色视角——同一份员工档案,从管理者和执行者两个角度各写一份点评。
  1. BiLSTM 编码$\mathbf{h}_t = \mathrm{BiLSTM}(\mathbf{x}_1, \dots, \mathbf{x}_n)_t$ 表示把第 t 个词放进双向循环网络,输出一个同时看过左右邻居的上下文向量。
    ht=BiLSTM(x1,…,xn)t\mathbf{h}_t = \mathrm{BiLSTM}(\mathbf{x}_1, \dots, \mathbf{x}_n)_t
  2. 老板视角$\mathbf{r}_i^{\mathrm{head}} = \mathrm{MLP}_{\mathrm{head}}(\mathbf{h}_i)$ 用一套参数把 $\mathbf{h}_i$ 压成低维向量,得到“当老板的简历”。
    rihead=MLPhead(hi)\mathbf{r}_i^{\mathrm{head}} = \mathrm{MLP}_{\mathrm{head}}(\mathbf{h}_i)
  3. 下属视角$\mathbf{r}_i^{\mathrm{dep}} = \mathrm{MLP}_{\mathrm{dep}}(\mathbf{h}_i)$ 用另一套参数得到“当下属的简历”,两套参数互不共享。
    ridep=MLPdep(hi)\mathbf{r}_i^{\mathrm{dep}} = \mathrm{MLP}_{\mathrm{dep}}(\mathbf{h}_i)
  4. 维度压缩两个 MLP 的输出维度远小于 $\mathbf{h}_i$ 的维度,迫使表示丢掉无关细节、聚焦“角色适配度”。
    dim(rihead)≪dim(hi)\mathrm{dim}(\mathbf{r}_i^{\mathrm{head}}) \ll \mathrm{dim}(\mathbf{h}_i)

Biaffine核心

HR 手里有一本厚厚的“双向偏好辞典” $\mathbf{W}$。辞典里记录着规则:老板牌的某个特质和下属牌的某个特质搭配在一起,该给多少分。打分时,把下属牌向量和老板牌向量送进辞典翻查,翻出来的数值就是这对组合的和谐度——这就是“双仿射”这个名字的来历:先把向量做一次线性变换(仿射),再做一次两两交叉(双线性),合称“双仿射”。

为什么不能像普通注意力那样直接做点积?因为点积只允许“老板牌的第 k 个维度”和“下属牌的第 k 个维度”自己跟自己比,太死板。Biaffine 用矩阵 $\mathbf{W}$ 做中介,允许“下属牌的第 k 个维度”与“老板牌的第 l 个维度”任意两两交互——这正是判断“两个人性格合不合”最需要的建模能力。

还有一个优雅的小技巧:把每个向量末尾拼接一个常数 1,也就是 $[\mathbf{r}; 1]$。这样一来,原本需要单独写的偏置项就被悄悄吸收进 $\mathbf{W}$ 的最后一列和最后一行里,公式变得极其简洁。展开后它其实包含三部分:双线性交叉项 $a^{\top}\mathbf{W}_1 b$、只依赖下属的线性项、只依赖老板的线性项,以及全局偏置。

把所有“下属 i × 候选老板 j”的组合都算一遍,就得到一张 $n \times n$ 的得分矩阵。每一行取最大值,就圈定了每个员工独一无二的老板。但打分器不是天生就会打分的——它需要经过严格培训,这就是下一节“训练与解码”要讲的事。

sij=[ridep; 1]⊤W[rjhead; 1]s_{ij} = \left[\mathbf{r}_i^{\mathrm{dep}};\, 1\right]^{\top} \mathbf{W} \left[\mathbf{r}_j^{\mathrm{head}};\, 1\right]
把下属 i 的“下属牌”和候选老板 j 的“老板牌”放进矩阵 W 里做二次型运算,得到的分数就是“i 向 j 汇报”的契合度。
  1. 下属牌向量$\mathbf{r}_i^{\mathrm{dep}}$ 是词 i 的“当下属”表示,也就是配对中的一方。
    ridep\mathbf{r}_i^{\mathrm{dep}}
  2. 老板牌向量$\mathbf{r}_j^{\mathrm{head}}$ 是词 j 的“当老板”表示,也就是配对的另一方。
    rjhead\mathbf{r}_j^{\mathrm{head}}
  3. 拼接常数 1$[\,\cdot\,; 1]$ 把向量维度加 1 并固定填 1,这样偏置项就被吸收进 W,无需单独声明。
    [ridep; 1],[rjhead; 1]\left[\mathbf{r}_i^{\mathrm{dep}};\, 1\right], \quad \left[\mathbf{r}_j^{\mathrm{head}};\, 1\right]
  4. 双仿射矩阵$\mathbf{W}$ 是维度为 $(d+1)\times(d+1)$ 的“双向偏好辞典”,储存所有角色特征两两组合的权重。
    W∈R(d+1)×(d+1)\mathbf{W} \in \mathbb{R}^{(d+1)\times(d+1)}
  5. 整体二次型$(a)^{\top}\mathbf{W}\, b$ 展开后等于 $a^{\top} W_1 b + u^{\top}a + v^{\top}b + c$——双线性交叉、单方偏好、全局偏置一次算清。
    [ridep;1]⊤W[rjhead;1]=(ridep)⊤W1rjhead+u⊤ridep+v⊤rjhead+b\left[\mathbf{r}_i^{\mathrm{dep}}; 1\right]^{\top} \mathbf{W} \left[\mathbf{r}_j^{\mathrm{head}}; 1\right] = (\mathbf{r}_i^{\mathrm{dep}})^{\top} \mathbf{W}_1 \mathbf{r}_j^{\mathrm{head}} + \mathbf{u}^{\top}\mathbf{r}_i^{\mathrm{dep}} + \mathbf{v}^{\top}\mathbf{r}_j^{\mathrm{head}} + b

训练与解码

HR 系统上岗前要培训:拿一堆历史公司“已经验证过的正确组织架构图”当标准答案,反复练习“把正确答案排到第一名”。每次练习,就是让打分器对每个词的所有候选老板做一个 softmax 分类,然后要求真实老板对应的概率尽量接近 1——这个目标,正是交叉熵损失。

公式里,$y_i$ 是词 i 的真实老板编号(标准答案),$s_{i,j}$ 是上一节算出的 Biaffine 分数。模型把第 i 行的分数压成概率分布,再用负对数似然让“真实老板”成为高概率事件。对句子里的每个非根词都这么做,然后把所有词的损失加起来,就是整句的损失。

推理时没有标准答案,模型采用贪心解码:对每个词,直接选分数最高的那个位置当老板。这里有个细节值得注意:贪心选出来的结果可能违反树结构——比如 A 认为 B 是老板,B 又认为 A 是老板,两人都不向根汇报,形成“管理循环”。为了保证永远输出合法组织树,可以改用最大生成树(MST)解码,在整张得分图上选一棵全局最优的树。Dozat 和 Manning 在论文中证明了一个有趣的结论:加上合适的训练约束后,贪心解码在多数语言上已经足够好,MST 带来的提升很小。

培训让打分器越来越精准,解码规则又保证了输出永远是合法组织架构图。那怎么衡量 HR 画得准不准呢?答案就是下一节要讲的准确率指标——UAS 和 LAS。

L=−∑i=1nlog⁡exp⁡(si, yi)∑j=1nexp⁡(si, j)\mathcal{L} = -\sum_{i=1}^{n} \log \frac{\exp\left(s_{i,\, y_i}\right)}{\sum_{j=1}^{n} \exp\left(s_{i,\, j}\right)}
这段公式是在说:让每个词的真实老板在 softmax 概率里越高越好,把“猜错老板”的代价变成损失信号,反向传播给 W。
  1. 真实头索引$y_i$ 表示训练语料中词 i 的标准答案老板编号,相当于 HR 培训教材里的正确答案。
    yi∈{1,2,…,n}y_i \in \{1, 2, \dots, n\}
  2. softmax 归一化$\frac{\exp(s_{i,y_i})}{\sum_{j}\exp(s_{i,j})}$ 把第 i 行分数压成概率分布,表示每个候选老板“当选”的可能性。
    P(yi)=exp⁡(si, yi)∑j=1nexp⁡(si, j)P(y_i) = \frac{\exp(s_{i,\,y_i})}{\sum_{j=1}^{n}\exp(s_{i,\,j})}
  3. 负对数似然$-\log(\cdot)$ 让真实老板概率越接近 1,损失越接近 0——这就是 HR 的“奖励正确、惩罚错误”机制。
    −log⁡P(yi)-\log P(y_i)
  4. 全句求和$\sum_{i=1}^{n}$ 把所有词的损失加起来,形成整句的训练信号,驱动梯度下降更新打分矩阵 W。
    L=∑i=1nLi\mathcal{L} = \sum_{i=1}^{n} \mathcal{L}_i

应用价值

组织架构图画好之后,下游部门就可以放心使用了:法务部拿它抽取人脉关系(信息抽取),翻译部拿它调整语序(机器翻译),人事部拿它判断谁承担什么职能(语义角色标注)。一张清晰的汇报关系图,让整个 NLP 流水线少走很多弯路。

在信息抽取场景中,依存树直接给出主谓宾骨架:从“小猫喜欢吃鱼”可以稳定抽取出“喜欢”的主语是“小猫”、宾语是“吃鱼”这样的三元组,支撑知识图谱构建和事件抽取。传统正则表达式面对千变万化的句式极易漏抽,而依存句法分析天然具备泛化能力。

在机器翻译与语义理解场景中,句子内部的修饰关系、否定关系、程度副词都藏在依存弧里。翻译长定语从句时,可以依据依存树整体搬移子树来调整语序;语义角色标注则以动词为根,给每个论元贴上“施事”“受事”等角色。近年来还有一些工作把句法信息注入预训练模型,进一步增强模型的泛化能力。评估这套 HR 系统的“画图准度”,最常用的指标就是 UAS:

回到公司的比喻:Deep Biaffine 之所以经典,是因为它把“谁向谁汇报”这件看似琐碎的事,变成了一套高效、可训练、可评估的流水线。无论下游是知识图谱、机器翻译还是智能客服,这张组织架构图都能作为地基,让上层建筑盖得又快又稳。

UAS=1N∑k=1N1[y^k=yk]\mathrm{UAS} = \frac{1}{N}\sum_{k=1}^{N} \mathbb{1}\left[\hat{y}_k = y_k\right]
UAS(无标签依存准确率)统计的是:预测的弧头跟标准答案完全一致的词,占全部词的比例。如果还要求弧上的关系标签也对,那就是 LAS。
  1. 指示函数$\mathbb{1}[\cdot]$ 在括号内条件成立时取 1、否则取 0,相当于一句“对不对”的计数器。
    1[y^k=yk]\mathbb{1}[\hat{y}_k = y_k]
  2. 预测头与真实头$\hat{y}_k$ 是模型预测的第 k 个词的老板,$y_k$ 是标注里的真实老板。
    y^k, yk\hat{y}_k,\ y_k
  3. 平均正确率对全部 N 个待预测的词取平均,得到 UAS;若还要求弧标签正确,则记作 LAS。
    UAS=弧头全对的词数N\mathrm{UAS} = \frac{\text{弧头全对的词数}}{N}

AI 应用场景

金融舆情事件抽取

阶段:数据预处理与信息抽取

解决的问题:传统做法靠人工写正则抽取“谁收购了谁”的事件,句式一变就漏抽,扩展性极差。用 Deep Biaffine 先做依存句法分析,再沿 nsubj/obj 弧抽取主谓宾三元组,可以快速搭建通用的事件抽取管线。

具体案例:某券商 NLP 团队需要对 10 万篇公告做并购事件抽取。句子“甲公司收购了乙公司 30% 股权”经过依存分析后,得到“甲--nsubj-->收购”和“乙--obj-->收购”两条关键弧,直接产出结构化三元组(收购,收购方=甲,被收购方=乙),准确率远超规则模板。

机器翻译中的语序调整

阶段:特征工程与解码优化

解决的问题:中文和英文的语序差异大,长定语、嵌套状语让序列到序列模型顾此失彼。借助依存树标记核心动词与修饰成分之间的关系,解码时按树结构整体搬移子树,显著减少“逐词硬译”造成的语序混乱。

具体案例:翻译“昨天在公园里开心地唱歌的小女孩”这类长定语从句时,先用依存树理清“唱歌”是核心、其余成分都是它的修饰节点,再按树结构重排为英文语序,翻译流畅度明显提升。

智能客服的语义角色理解

阶段:意图识别与参数抽取

解决的问题:客服问题中常见的否定、程度、对象等语义信息常以修饰和宾语关系出现,平面分类容易误判。依存树帮对话系统定位“动词+宾语+状语”结构,避免把“不要退款”误解为“要退款”。

具体案例:用户输入“我想申请延迟发货”,依存分析得到“申请”支配“发货”,且“发货”被“延迟”修饰,系统据此正确抽取意图为“修改发货时间”,参数为“延迟”,而不是直接触发退款流程。

Python 演示

python104 行
import numpy as np class BiaffineParser:    """极简 Deep Biaffine 依存句法分析器(玩具版)"""     def __init__(self, vocab, dim=8):        self.vocab = vocab        self.dim = dim        self.emb = {}        for w in vocab:            self.emb[w] = np.random.randn(dim) * 0.1        self.W_bia = np.random.randn(dim + 1, dim + 1) * 0.1        self.W_head = np.random.randn(dim, dim) * 0.1        self.W_dep = np.random.randn(dim, dim) * 0.1     def _encode(self, words):        # 简化版 BiLSTM: 前向平均 + 后向平均拼接        x = np.array([self.emb[w] for w in words])        h = []        for t in range(len(words)):            left = x[:t + 1].mean(axis=0)            right = x[t:].mean(axis=0)            h.append(np.concatenate([left, right]))        return np.array(h)     def score_matrix(self, words):        h = self._encode(words)        n = len(words)        r_head = np.hstack([np.tanh(h @ self.W_head), np.ones((n, 1))])        r_dep = np.hstack([np.tanh(h @ self.W_dep), np.ones((n, 1))])        return r_dep @ self.W_bia @ r_head.T     def nll_loss(self, words, heads):        S = self.score_matrix(words)        loss = 0.0        for i in range(len(words)):            if heads[i] < 0:                continue            row = S[i] - S[i].max()            p = np.exp(row)            loss += -row[heads[i]] + np.log(p.sum())        return loss / len(words)     def train(self, corpus, steps=800, lr=0.03):        for _ in range(steps):            words, heads = corpus[np.random.randint(len(corpus))]            n = len(words)            h = self._encode(words)            z_head = h @ self.W_head            z_dep = h @ self.W_dep            r_head = np.hstack([np.tanh(z_head), np.ones((n, 1))])            r_dep = np.hstack([np.tanh(z_dep), np.ones((n, 1))])            S = r_dep @ self.W_bia @ r_head.T            dW = np.zeros_like(self.W_bia)            dH = np.zeros_like(self.W_head)            dD = np.zeros_like(self.W_dep)            for i in range(n):                if heads[i] < 0:                    continue                row = S[i] - S[i].max()                p = np.exp(row) / np.exp(row).sum()                g = p.copy()                g[heads[i]] -= 1                for j in range(n):                    dW += g[j] * np.outer(r_dep[i], r_head[j])                    grad_rh = self.W_bia.T @ r_dep[i]                    dH += g[j] * np.outer(h[j], grad_rh[:self.dim] * (1 - np.tanh(z_head[j]) ** 2))                    grad_rd = self.W_bia @ r_head[j]                    dD += g[j] * np.outer(h[i], grad_rd[:self.dim] * (1 - np.tanh(z_dep[i]) ** 2))            self.W_bia -= lr * dW / n            self.W_head -= lr * dH / n            self.W_dep -= lr * dD / n     def predict(self, words, root_idx):        S = self.score_matrix(words)        heads = []        for i in range(len(words)):            if i == root_idx:                heads.append(-1)                continue            row = S[i].copy()            row[i] = -1e9            heads.append(int(np.argmax(row)))        return heads  def main():    vocab = ["小猫", "喜欢", "吃", "鱼", "我", "猫"]    parser = BiaffineParser(vocab)    corpus = [        (["小猫", "喜欢", "吃", "鱼"], [1, -1, 1, 2]),        (["我", "喜欢", "猫"], [1, -1, 0]),        (["小猫", "吃", "鱼"], [1, -1, 1]),    ]    sent = ["小猫", "喜欢", "吃", "鱼"]    print("训练前损失:", round(parser.nll_loss(sent, [1, -1, 1, 2]), 4))    print("训练前预测:", parser.predict(sent, 1), "期望 [1, -1, 1, 2]")    parser.train(corpus, steps=800)    print("训练后损失:", round(parser.nll_loss(sent, [1, -1, 1, 2]), 4))    print("训练后预测:", parser.predict(sent, 1), "期望 [1, -1, 1, 2]")  if __name__ == "__main__":    main()
目的:用 numpy 从零实现一个迷你 Biaffine 依存句法分析器,在 3 条中文小句子上训练后,演示“训练降低损失、预测逼近正确头节点”的完整流程。
  1. self.W_bia = np.random.randn(dim + 1, dim + 1) * 0.1打分矩阵 W 比向量维度多一行一列,专门用来吸收偏置项。
  2. r_head = np.hstack([np.tanh(h @ self.W_head), np.ones((n, 1))])用一套 MLP 得到“老板牌”向量,末尾拼 1 留出偏置通道。
  3. r_dep = np.hstack([np.tanh(h @ self.W_dep), np.ones((n, 1))])用另一套 MLP 得到“下属牌”向量,两套参数互不共享。
  4. return r_dep @ self.W_bia @ r_head.T一次矩阵乘法算出所有 (下属 i, 老板 j) 组合的 Biaffine 分数。
  5. row = S[i] - S[i].max()减最大值再取 exp,是 softmax 的数值稳定技巧,防止指数溢出。
  6. g = p.copy(); g[heads[i]] -= 1交叉熵对 logits 的梯度恰好是“预测概率减真实 one-hot”,是本次更新的核心信号。
  7. dW += g[j] * np.outer(r_dep[i], r_head[j])把每个候选老板的梯度累加到打分矩阵 W 上,等价于“答错了就调整偏好辞典”。
  8. heads.append(int(np.argmax(row)))贪心解码:每个词直接选分数最高的位置当老板。
  9. parser.train(corpus, steps=800)在迷你语料上训练 800 步,观察损失下降与预测逼近真实头节点。

常见误区

误区我一开始以为 Biaffine 就是把两个向量做点积再加一个 bias,跟普通 attention 没多大区别。

正解Biaffine 的关键在“双线性交叉项”:$(\mathbf{r}^{\mathrm{dep}})^{\top} \mathbf{W}\, \mathbf{r}^{\mathrm{head}}$。这里的 W 是一个矩阵而不是一个标量,它允许“下属牌的第 k 个维度”和“老板牌的第 l 个维度”两两交互,这才是“配对兼容度”的数学化表达;线性项和偏置只是锦上添花。没有矩阵交叉项,模型就无法建模“下属的 A 特质 × 老板的 B 特质”这种组合效应。

为何常见:因为它确实属于注意力家族,展开后也有形如“向量内积 + 偏置”的项,只盯局部形式就容易忽略矩阵 W 带来的二阶交互能力。

误区我以为模型给每个词准备了两套词向量,一套专门当老板,一套专门当下属。

正解实际上所有词共享同一份词嵌入、同一个 BiLSTM。只是在 BiLSTM 输出 $\mathbf{h}_i$ 之后,用两个不同的 MLP 把同一份上下文投射成 $\mathbf{r}^{\mathrm{head}}$ 和 $\mathbf{r}^{\mathrm{dep}}$。就像同一份员工档案,HR 从“管理者”和“执行者”两个角度各写一份点评——信息来源相同,解读视角不同。

为何常见:概念名称是成对出现的(head/dep),又确实存在两套 MLP 参数,容易被想象成两套独立的输入通道。

误区既然每个词都选分数最高的老板,那结果自然是每人只有一个老板,应该永远是树吧?

正解每个词只有一个老板只满足了“单父”条件,但可能出现循环指认:A 认为 B 是老板,B 又认为 A 是老板,两个人都不向根汇报。真正的树还需要“连通”和“无环”。Deep Biaffine 在多数语言上可以直接用贪心解码,是因为训练中加入了非树结构的软约束惩罚,让循环分数天然偏低;但严格保证树结构,仍需要 MST 之类的全局解码。

为何常见:把“局部约束”误当成“全局约束”,这是刚接触图结构建模时最常见的直觉偏差。

误区我以为句法分析无非是给句子画一棵树,依存树就是短语结构树换个画法。

正解短语结构树关心“哪些词组成了一个短语”(NP、VP 的层次嵌套),依存树关心“词与词之间的支配关系”(谁是谁的头)。同一个句子,两种树的形状完全不同。Deep Biaffine 输出的是后者:图上只有节点和有向弧,没有短语边界节点。它给出的信息是“这个动词支配了哪个主语、哪个宾语”,而不是“这几个词组成了一个名词短语”。

为何常见:中学英语课接触的大多是短语结构分析,先入为主地把“句法树”默认成了“短语树”。

面试题

Q1. Deep Biaffine 模型主要解决哪类任务?

单选题 · easy

  • A.依存句法分析← 正确答案
  • B.文本主题分类
  • C.神经机器翻译
  • D.词向量预训练

答案:A。Dozat 和 Manning 2017 年提出的 Deep Biaffine 是依存句法分析模型,任务是为每个词找到它的头节点。选项 B、C、D 分别是分类、翻译、表示学习任务,与句法分析无关。用公司比喻说,它专门负责画“全员汇报关系图”。

Q2. 依存树中“单一父节点”约束指的是什么?

单选题 · easy

  • A.每个词只能有一个下属
  • B.每个词至多有一个头节点← 正确答案
  • C.句子只能有一个动词
  • D.所有词必须连接根节点

答案:B。在依存树中,除根节点外每个词恰好有一个“老板”(头节点),这保证组织架构不会出现“一个员工向两个老板汇报”的混乱。选项 A 把方向说反了,C 是无关的语言学常识,D 只描述了连通性而忽略了“老板唯一”这层约束。

Q3. 与普通点积注意力相比,Biaffine 打分公式额外包含什么?

单选题 · mid

  • A.循环门控与记忆单元
  • B.双线性项与偏置线性项← 正确答案
  • C.位置编码与多层拼接
  • D.卷积核与归一化层

答案:B。Biaffine 的核心是双线性项 $a^{\top}\mathbf{W}b$,让两个向量的不同维度两两交叉,再叠加线性项和偏置。普通点积注意力只做同维度对齐。GNN 门控、位置编码、卷积都不是 Biaffine 的增量,故 A、C、D 均不选。

Q4. 模型产生 head 与 dep 两种角色表示的方法是什么?

单选题 · mid

  • A.两个独立 MLP 分别变换← 正确答案
  • B.一个共享 MLP 重复两次
  • C.两个 BiLSTM 输出拼接
  • D.对词向量做奇异值分解

答案:A。同一个 BiLSTM 输出 $\mathbf{h}_i$ 分别经过 $\mathrm{MLP}_{\mathrm{head}}$ 和 $\mathrm{MLP}_{\mathrm{dep}}$ 两套独立参数变换,得到“老板牌”和“下属牌”。共享 MLP 会让两种角色毫无区分度,两个 BiLSTM 和 SVD 都不是论文架构,故排除 B、C、D。

Q5. 贪心解码逐一取最高分头节点,主要风险是什么?

单选题 · mid

  • A.分数过低无法完成解码
  • B.无法处理根节点选择
  • C.可能出现环破坏树结构← 正确答案
  • D.损失函数无法继续优化

答案:C。每个词独立选分数最高的头,可能出现 A 把 B 当老板、B 又把 A 当老板的循环,违反树的“无环”要求。分数低、根节点选择是工程细节而非贪心特有风险,损失函数在训练阶段计算、与解码无关,故 A、B、D 错误。

Q6. 关于 Biaffine 矩阵 W 的说法正确的是哪一项?

单选题 · hard

  • A.它的维度与词典大小相同
  • B.它只编码词频统计信息
  • C.它替代了序列位置编码
  • D.它捕捉角色向量的二阶交互← 正确答案

答案:D。W 的维度是 (d+1)×(d+1),只与向量维度有关,与词典大小无关,A 错;它编码的是“角色特征如何组合打分”而非词频,B 错;位置编码解决词序问题,与 W 无关,C 错。双仿射的 $a^{\top}\mathbf{W}b$ 正是二阶交互,D 正确。

Q7. 句子“小猫喜欢吃鱼”的根节点通常是谁?

单选题 · easy

  • A.第一个名词小猫
  • B.核心动词喜欢← 正确答案
  • C.第二个动词吃
  • D.最后一个名词鱼

答案:B。依存语法遵循“动词中心”原则,核心谓语动词通常是整棵树的根。这里“喜欢”是全句的核心谓词,“吃”是它的从属动词,“小猫”和“鱼”分别是主语和宾语,故根节点是“喜欢”,即全公司的 CEO。

Q8. 下列哪些是依存句法分析的典型下游应用?

多选题 · mid

  • A.信息抽取与关系抽取← 正确答案
  • B.机器翻译词序调整← 正确答案
  • C.语义角色标注任务← 正确答案
  • D.正则表达式匹配文本

答案:ABC。依存树能提供主谓宾骨架,支撑信息抽取;能按子树整体搬移辅助翻译调序;也能为语义角色标注提供论元结构。正则表达式是规则匹配工具,与句法分析无直接关系,D 不选。

Q9. 关于 Deep Biaffine 的训练与评测,下列说法正确的有哪些?

多选题 · hard

  • A.对每个词在候选头上做 softmax← 正确答案
  • B.采用交叉熵损失进行训练← 正确答案
  • C.UAS 统计弧与标签均正确率
  • D.输入常拼接 POS 等额外特征← 正确答案

答案:ABD。训练时对每个词的得分行做 softmax 并用交叉熵优化,A、B 正确;输入拼接词向量、POS 向量和预训练向量是论文标准做法,D 正确。UAS 只看弧头是否预测正确,标签也正确才算的是 LAS,故 C 错误。

Q10. 下列哪些做法能提升 Biaffine 句法分析效果?

多选题 · mid

  • A.拼接预训练词向量← 正确答案
  • B.加入词性标注特征← 正确答案
  • C.采用双向 LSTM 编码← 正确答案
  • D.训练时完全不使用标注

答案:ABC。预训练词向量提供丰富的语义先验,POS 特征提供句法类别信号,双向 LSTM 让每个词看到全文上下文,三者都是论文采用或通用的增强手段。D 去掉了训练用的头节点标注,模型无从学习,必然失败,故不选。