句法分析与依存解析 - 结构化预测任务 | 自在学句法分析与依存解析
当你阅读“我看见了那个拿着望远镜的人”这个句子时,大脑瞬间就理解了句子结构——“我”是主语、“看见”是谓语、“那个拿着望远镜的人”是宾语,而“拿着望远镜的”修饰“人”而非“看见”。
这种对句子语法结构的理解是如此自然,以至于我们常常意识不到它的存在。但对计算机来说,理解句法结构是一个巨大的挑战,也是许多NLP任务的基础。
句法分析(Syntactic Parsing) 就是揭示句子语法结构的过程——识别哪些词构成短语,词与词之间如何相互依存,谁修饰谁、谁依赖谁。
这不仅是语言学研究的核心问题,也对许多下游任务至关重要。机器翻译需要知道句子结构才能正确调整词序(如将英语的主谓宾翻译成日语的主宾谓);信息抽取需要识别实体之间的关系(如“谁做了什么”);语义分析需要理解句法结构才能构建形式化的语义表示。
这节课我们将介绍依存语法和依存解析,特别关注基于转换的解析方法及其神经网络实现。这是我们第一次将前面学习的词向量和神经网络应用到结构化预测任务——不再是简单的分类,而是预测一个复杂的树结构。

两种句法表示
短语结构:层次化的嵌套
短语结构语法(Constituency Grammar) 将句子看作嵌套的短语成分。句子首先被分解为名词短语(NP)和动词短语(VP),名词短语进一步分解为限定词和名词,动词短语分解为动词和宾语...最终形成一个树结构,叶子节点是词,内部节点是短语类型。
例如,“我看见了那个人”的短语结构树:
S (句子)
/ \
NP VP (动词短语)
| / | \
我 看见 了 NP
|
那个人
这种表示基于上下文无关文法(Context-Free Grammar, CFG),用产生式规则定义句子结构,如“S → NP VP”(句子由名词短语和动词短语构成)、“VP → V NP”(动词短语由动词和名词短语构成)。
短语结构语法在英语等语序相对固定的语言中运作良好,但对自由语序语言(如拉丁语、俄语、土耳其语)则显得笨拙——词序变化不改变语义,但短语树结构会完全不同。
依存结构:词与词的直接关系
依存语法(Dependency Grammar)采用完全不同的视角:通过词与词之间的依存关系表示结构。每个词依赖于另一个词(称为核心词或中心词),依存关系形成一棵树——树的节点是词本身,边表示依存关系。
例如,“我看见了那个人”的依存树:
看见 (root)
/ | \
我 了 人
|
那个
这里“看见”是根节点(通常是句子的主要动词),“我”依赖于“看见”(主语关系),“人”也依赖于“看见”(宾语关系),“那个”依赖于“人”(定语关系)。每条边可以标注关系类型,如nsubj(nominal subject,名词性主语)、dobj(direct object,直接宾语)、det(determiner,限定词)。
依存语法的优势在于:首先,它更接近语义关系——“我”执行“看见”动作,“人”是“看见”的对象,这些依存关系直接反映了语义角色。其次,它对自由语序语言更友好——无论“我看见了人”还是“人我看见了”(强调语序),依存关系都是一样的。第三,依存树结构更紧凑——没有中间的短语节点,直接连接词。这些特点使依存语法在现代NLP中更受欢迎,特别是在多语言场景中。
形式化定义
形式化地说,给定句子w1,w2,…,wn,依存树T是一个有向图,满足:
- 单根性:存在唯一的根节点(通常是主要动词)
- 单父性:除根节点外,每个词恰好有一个父节点
- 连通性:从根到任何节点存在唯一路径
- 无环性:不存在环路
这些约束保证了依存树是一个合法的树结构——每个词都有明确的依存关系,没有歧义或冲突。
依存解析:如何构建依存树
给定一个句子,如何自动构建其依存树?这就是依存解析问题。主要有两类方法:

基于图的方法:全局优化
基于图的方法将所有可能的依存关系看作一个完全图——每对词之间都有一条可能的边,每条边有一个分数(表示这个依存关系的可能性)。解析的目标是找到一棵总分数最高的树。
经典算法如Eisner算法使用动态规划,在O(n3)时间内找到最优树。这种方法的优点是能够全局优化——考虑所有依存关系之间的一致性,缺点是计算复杂度高,难以加入复杂的特征。
基于转换的方法:逐步构建
基于转换的方法使用状态机逐步构建依存树,就像人类阅读句子时逐词处理、逐步理解结构一样。这种方法更符合认知过程,而且非常适合用神经网络实现——每一步的决策可以看作分类问题,用神经网络预测下一步动作。
本讲重点介绍Nivre的Arc-standard转换系统,这是基于转换方法的经典代表。
Arc-standard转换系统:用状态机构建依存树
状态表示
转换系统维护三个数据结构:
- 栈(Stack)σ:存储正在处理的词,后进先出
- 缓冲区(Buffer)β:存储待处理的词,先进先出
- 依存弧集合(Arcs)A:已构建的依存关系
初始状态时,栈只包含特殊的ROOT符号,缓冲区包含所有词(按原序),弧集为空:
- 栈:[ROOT]
- 缓冲区:[w1,w2,…,w
终止状态时,缓冲区为空,栈只剩ROOT,弧集包含完整的依存树:
- 栈:[ROOT]
- 缓冲区:[]
- 弧集:完整的依存树
解析过程就是通过一系列转换动作,从初始状态到达终止状态。
三种转换动作
系统有三种基本动作:
1. SHIFT(移进):
将缓冲区的第一个词移到栈顶。
- 前提:缓冲区非空
- 状态变化:(σ,wi∣β,A)⇒(σ∣wi,β,A
这个动作说:“当前词先不处理它的依存关系,把它压栈,先看后面的词。”
2. LEFT-ARCr(左弧):
在栈顶两个词间建立依存关系,核心词是栈顶第一个词,依赖词是栈顶第二个词。关系类型是r(如nsubj、dobj)。建立关系后,依赖词(栈顶第二个)从栈中移除——因为它的依存关系已确定,不再需要。
- 前提:栈至少有两个元素,且栈顶第二个不是ROOT
- 状态变化:(σ∣wi∣wj,β,A)⇒
3. RIGHT-ARCr(右弧):
在栈顶两个词间建立依存关系,核心词是栈顶第二个词,依赖词是栈顶第一个词。建立关系后,依赖词(栈顶第一个)从栈中移除。
- 前提:栈至少有两个元素
- 状态变化:(σ∣wi∣wj,β,A)⇒
关键洞察是:依存关系一旦建立就不再改变,依赖词可以从栈中移除。这保证了解析的单调性——每一步都前进,不回退。
具体解析示例
让我们用“我爱学习”这个句子走一遍完整的解析过程:
步骤 栈 缓冲区 动作 弧集
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
1 [ROOT] [我,爱,学习] SHIFT {}
2 [ROOT,我] [爱,学习] SHIFT {}
3 [ROOT,我,爱] [学习] LEFT-ARC(nsubj) {爱→我}
4 [ROOT,爱] [学习] SHIFT {爱→我}
5 [ROOT,爱,学习] [] RIGHT-ARC(dobj) {爱→我,爱→学习}
6 [ROOT,爱] [] RIGHT-ARC(root) {爱→我,爱→学习,ROOT→爱}
7 [ROOT] [] 完成
让我们理解每一步的决策:
步骤1-2:先把“我”和“爱”都压栈。此时我们有了足够的上下文——“我”在“爱”的左边,根据中文语法,“我”很可能是“爱”的主语。
步骤3:执行LEFT-ARC(nsubj),建立“爱→我”(“我”是“爱”的主语)。“我”的依存关系已确定,从栈中移除。
步骤4:将“学习”压栈。
步骤5:栈顶是“学习”和“爱”。“学习”在“爱”的右边,是“爱”的宾语,执行RIGHT-ARC(dobj),建立“爱→学习”。“学习”从栈中移除。
步骤6:现在只剩“爱”在栈中(和ROOT)。“爱”是句子的主要动词,应该是ROOT的子节点,执行RIGHT-ARC(root),建立“ROOT→爱”。
步骤7:缓冲区为空,栈只剩ROOT,解析完成。
最终依存树:
ROOT
|
爱 (root)
/ \
我 学习
(nsubj) (dobj)
这个过程看起来复杂,但本质是一系列局部决策——每一步只需要看栈顶的元素和缓冲区的第一个词,决定执行哪个动作。这种局部性使得我们可以用分类器(如神经网络)来预测动作,将结构化预测问题转化为序列决策问题。
神经依存解析器
传统的依存解析器(如MaltParser)依赖人工设计的特征:栈顶词的词性、词形、前缀后缀;栈顶第二个词的特征;缓冲区第一个词的特征;已有依存关系的特征...数百维稀疏特征,需要语言学专家精心设计。
Chen & Manning在2014年提出了神经依存解析器,抛弃了人工特征,直接从词和词性学习表示。核心思想极其简单:
- 将栈、缓冲区中的词用词向量表示
- 将这些词向量拼接成一个大向量
- 通过多层神经网络预测下一步动作
模型架构
具体来说,在每个解析状态:
提取元素:从栈顶取前2个词,从缓冲区取前3个词,取这5个词的前2个子节点(如果有)——总共约15-20个词。
嵌入表示:每个词用词向量ew和词性向量e(词性也可以嵌入!)表示。
拼接:将所有词向量和词性向量拼接成一个大向量x(维度约为15×dw+15,如果词向量50维、词性向量10维,总共约900维)。
神经网络预测:
h=ReLU(W1x
y=W2h
输出y是每个可能动作的分数(如SHIFT、LEFT-ARC(nsubj)、RIGHT-ARC(dobj)等,通常有几十个动作)。选择分数最高的合法动作执行。
训练
训练数据是带标注的依存树(如Penn Treebank)。给定一棵依存树,我们可以反推出正确的动作序列(这叫oracle,神谕)——在每个状态,选择能最终构建出正确树的动作。
然后将问题转化为多类分类:给定状态x,预测正确动作y,最小化交叉熵损失:
L=−i∑logP(yi∣xi)
通过在大量(状态,动作)对上训练,神经网络学会了何时SHIFT、何时建立什么类型的弧。
Chen & Manning的模型在英文Penn Treebank上达到92.0%的无标记依存准确率(UAS),接近传统方法,但速度快得多——因为神经网络前向传播极快,不需要复杂的特征提取。
PyTorch实现:神经依存解析器
让我们实现一个简化的神经依存解析器核心:
import torch
import torch.nn as nn
import torch.optim as optim
class NeuralDependencyParser(nn.Module):
"""神经依存解析器"""
def __init__(self, vocab_size, pos_size, word_embed_dim=50, pos_embed_dim=10,
hidden_dim=200, num_actions=20):
"""
Args:
vocab_size: 词汇表大小
这个实现虽然简化(实际需要更复杂的特征提取、beam search、动态预言等),但展示了神经依存解析的核心思想——用神经网络将状态映射到动作,通过监督学习训练分类器。
练习与思考
-
解释短语结构语法和依存语法的主要区别。在什么情况下依存语法更有优势?
-
用Arc-standard系统手工解析句子"我喜欢学习自然语言处理",写出每一步的栈、缓冲区和弧集状态。
-
为什么基于转换的依存解析可以看作序列决策问题?这与普通的文本分类有什么本质区别?
-
神经依存解析器相比传统方法(基于人工特征)有什么优势和劣势?
-
实现一个简单的依存解析评估函数,计算UAS(Unlabeled Attachment Score)和LAS(Labeled Attachment Score)。
n
]
)
(σ∣wj,β,A∪
wi})
含义:wj是核心词,wi依赖于wj (σ∣wi,β,A∪
wj})
含义:wi是核心词,wj依赖于wi t
×
dt
+
+
pos_size: 词性标签数量
word_embed_dim: 词向量维度
pos_embed_dim: 词性向量维度
hidden_dim: 隐藏层维度
num_actions: 可能的动作数量(SHIFT + 各种ARC)
"""
super(NeuralDependencyParser, self).__init__()
# 词嵌入和词性嵌入
self.word_embedding = nn.Embedding(vocab_size, word_embed_dim)
self.pos_embedding = nn.Embedding(pos_size, pos_embed_dim)
# 假设我们提取15个词和15个词性(栈顶3个+缓冲区3个+子节点等)
input_dim = 15 * (word_embed_dim + pos_embed_dim)
# 两层神经网络
self.fc1 = nn.Linear(input_dim, hidden_dim)
self.fc2 = nn.Linear(hidden_dim, num_actions)
self.dropout = nn.Dropout(0.5)
def forward(self, words, pos_tags):
"""
前向传播
Args:
words: (batch, 15) 提取的词ID
pos_tags: (batch, 15) 提取的词性ID
Returns:
action_scores: (batch, num_actions) 每个动作的分数
"""
# 嵌入 (batch, 15, word_embed_dim) 和 (batch, 15, pos_embed_dim)
word_embeds = self.word_embedding(words)
pos_embeds = self.pos_embedding(pos_tags)
# 拼接 (batch, 15, word_embed_dim + pos_embed_dim)
combined = torch.cat([word_embeds, pos_embeds], dim=2)
# 展平 (batch, input_dim)
flattened = combined.view(combined.size(0), -1)
# 通过神经网络
hidden = torch.relu(self.fc1(flattened))
hidden = self.dropout(hidden)
action_scores = self.fc2(hidden)
return action_scores
class ParsingState:
"""解析状态"""
def __init__(self, sentence):
"""
Args:
sentence: List[str] 句子的词列表
"""
self.stack = [0] # 0代表ROOT
self.buffer = list(range(1, len(sentence) + 1)) # 1开始的词索引
self.arcs = [] # (head, dependent, relation)列表
self.sentence = ['ROOT'] + sentence # 添加ROOT
def is_terminal(self):
"""是否到达终止状态"""
return len(self.buffer) == 0 and len(self.stack) == 1
def can_shift(self):
"""能否SHIFT"""
return len(self.buffer) > 0
def can_left_arc(self):
"""能否LEFT-ARC"""
# 栈至少2个元素,且栈顶第二个不是ROOT
return len(self.stack) >= 2 and self.stack[-2] != 0
def can_right_arc(self):
"""能否RIGHT-ARC"""
return len(self.stack) >= 2
def shift(self):
"""执行SHIFT"""
if self.can_shift():
self.stack.append(self.buffer.pop(0))
def left_arc(self, relation):
"""执行LEFT-ARC"""
if self.can_left_arc():
dependent = self.stack.pop(-2)
head = self.stack[-1]
self.arcs.append((head, dependent, relation))
def right_arc(self, relation):
"""执行RIGHT-ARC"""
if self.can_right_arc():
dependent = self.stack.pop()
head = self.stack[-1]
self.arcs.append((head, dependent, relation))
def extract_features(self, word_vocab, pos_vocab):
"""
提取当前状态的特征(简化版本)
Args:
word_vocab: 词到ID的映射
pos_vocab: 词性到ID的映射
Returns:
words: (15,) 词ID
pos_tags: (15,) 词性ID
"""
# 这里简化:只提取栈顶3个和缓冲区前3个
# 实际实现需要更复杂的特征提取
features = []
# 栈顶3个
for i in range(3):
if len(self.stack) > i:
idx = self.stack[-(i+1)]
word = self.sentence[idx]
features.append(word_vocab.get(word, word_vocab['<UNK>']))
else:
features.append(word_vocab['<PAD>'])
# 缓冲区前3个
for i in range(3):
if i < len(self.buffer):
idx = self.buffer[i]
word = self.sentence[idx]
features.append(word_vocab.get(word, word_vocab['<UNK>']))
else:
features.append(word_vocab['<PAD>'])
# 填充到15个(这里简化,实际需要更多特征)
while len(features) < 15:
features.append(word_vocab['<PAD>'])
# 简化:词性特征用相同的(实际需要真实词性标注)
pos_features = features.copy()
return torch.LongTensor(features), torch.LongTensor(pos_features)
def train_parser(model, training_data, epochs=10, lr=0.001):
"""
训练解析器
Args:
model: 解析器模型
training_data: List[(sentence, gold_actions)] 训练数据
epochs: 训练轮数
lr: 学习率
"""
optimizer = optim.Adam(model.parameters(), lr=lr)
criterion = nn.CrossEntropyLoss()
for epoch in range(epochs):
total_loss = 0
correct = 0
total = 0
for sentence, gold_actions in training_data:
state = ParsingState(sentence)
for gold_action in gold_actions:
# 提取特征
words, pos = state.extract_features(word_vocab, pos_vocab)
words = words.unsqueeze(0) # (1, 15)
pos = pos.unsqueeze(0)
# 预测
optimizer.zero_grad()
action_scores = model(words, pos)
# 计算损失
gold_action_id = action_to_id[gold_action]
loss = criterion(action_scores, torch.LongTensor([gold_action_id]))
# 反向传播
loss.backward()
optimizer.step()
total_loss += loss.item()
# 统计准确率
predicted_action = action_scores.argmax(dim=1).item()
correct += (predicted_action == gold_action_id)
total += 1
# 执行动作(更新状态)
execute_action(state, gold_action)
avg_loss = total_loss / total
accuracy = correct / total
print(f"Epoch {epoch+1}: Loss={avg_loss:.4f}, Accuracy={accuracy:.3f}")
# 使用示例
if __name__ == "__main__":
# 假设的词汇表和动作集合
word_vocab = {'<PAD>': 0, '<UNK>': 1, 'ROOT': 2, '我': 3, '爱': 4, '学习': 5}
pos_vocab = {'<PAD>': 0, 'ROOT': 1, 'PN': 2, 'V': 3, 'N': 4}
action_to_id = {'SHIFT': 0, 'LEFT-ARC(nsubj)': 1, 'RIGHT-ARC(dobj)': 2}
# 创建模型
model = NeuralDependencyParser(
vocab_size=len(word_vocab),
pos_size=len(pos_vocab),
num_actions=len(action_to_id)
)
print(f"模型参数量: {sum(p.numel() for p in model.parameters())}")