首页/知识库/句法分析:依存分析与成分分析

句法分析:依存分析与成分分析

✍️ AI Master📅 创建 2026-04-12📖 18 min 阅读
💡

文章摘要

从 CFG 到神经句法分析,理解句子的结构之美

1什么是句法分析?NLP 的结构之眼

句法分析(Syntactic Parsing)是自然语言处理的核心任务之一,其目标是分析句子的语法结构,揭示词与词之间的组织关系。与词性标注只关注单个词的语法角色不同,句法分析关注的是整个句子的结构——哪些词是核心,哪些词是修饰,它们之间如何连接。

句法分析有两种主要范式:成分分析(Constituency Parsing)将句子递归地分解为短语成分(名词短语、动词短语等),形成一棵短语结构树;依存分析(Dependency Parsing)则直接建模词与词之间的依存关系(主谓、动宾、定中等),形成一棵依存树。两者各有优势:成分分析适合需要短语边界信息的任务(如机器翻译),依存分析更适合语义角色标注和信息抽取。

理解句法分析的关键在于认识到:语言不是线性排列的词串,而是具有层次结构的系统。"猫追老鼠" 和 "老鼠追猫" 用同样的三个词,但句法结构完全不同,含义也截然相反。句法分析就是要捕捉这种结构差异。

python
# 句法分析示例:同一个句子的两种视角
sentence = "猫 追 老鼠"

# 成分分析视角(短语结构)
#       S
#     /   \
#    NP    VP
#    |    /  \
#    猫   V    NP
#        |     |
#        追    老鼠
constituency_tree = {
    "type": "S",
    "children": [
        {"type": "NP", "word": "猫"},
        {"type": "VP", "children": [
            {"type": "V", "word": "追"},
            {"type": "NP", "word": "老鼠"}
        ]}
    ]
}

# 依存分析视角(依存关系)
#   追 <--root--
#  /  \
# 猫  老鼠
# ↑    ↑
# 主谓  动宾
dependency_tree = {
    "head": "追",
    "relations": [
        {"head": "追", "dep": "nsubj", "child": "猫"},
        {"head": "追", "dep": "dobj", "child": "老鼠"}
    ]
}
python
# 用 nltk 进行快速句法分析演示
import nltk
from nltk import CFG

# 定义一个简单的上下文无关文法
grammar = CFG.fromstring("""
  S -> NP VP
  NP -> Det N | Det N PP
  VP -> V NP | V NP PP
  PP -> P NP
  Det -> 'the' | 'a'
  N -> 'cat' | 'dog' | 'park' | 'ball'
  V -> 'chased' | 'saw'
  P -> 'in' | 'with'
""")

sentence = "the cat chased the dog".split()
print(f"句子: {' '.join(sentence)}")
print(f"词数: {len(sentence)}")

# 成分分析输出会显示所有可能的短语结构树
# 一个句子可能有多个合法的句法分析(歧义)
特性成分分析依存分析

结构单位

短语(NP、VP 等)

词与词之间的边

树节点

非终结符 + 终结符

仅词(终结符)

适用场景

机器翻译、语法检查

信息抽取、语义分析

树形特点

分支较多,层次深

更扁平,更接近语义

经典算法

CKY、Earley

转移系统、MST、Eisner

成分分析和依存分析不是竞争关系——它们从不同视角刻画句子结构。实际工程中,依存分析因其简洁性和与语义的直接对应关系,在现代 NLP 中使用更广泛。

句法分析不等于语义分析!句法关注的是语法结构的合法性,一个句法正确的句子可能语义荒谬(如「颜色无色的绿色想法激烈地睡觉」,Chomsky 的经典例子)。

2上下文无关文法(CFG):句法的形式化基石

上下文无关文法(Context-Free Grammar, CFG)是描述自然语言句法结构的最基础形式化工具。一个 CFG 由四部分组成:终结符集合(实际的词)、非终结符集合(语法范畴如 NP、VP)、产生式规则集合(如 S → NP VP)、以及起始符号(通常是 S)。

CFG 的核心思想是「递归分解」:从起始符号 S 开始,用产生式规则逐步替换非终结符,直到所有符号都是终结符。例如 S → NP VP,然后 NP → Det N,VP → V NP,最终得到 "the cat chased the dog"。这个过程恰好对应成分树的自顶向下构建。

为什么说 CFG 是「上下文无关」的?因为产生式规则的适用只取决于当前的非终结符本身,而不取决于它周围的上下文。这意味着规则 NP → Det N 在任何位置都可以使用,无论 NP 前面是动词还是介词。这一特性既是 CFG 的优势(简洁、通用),也是其局限(自然语言中存在大量上下文依赖现象)。

CFG 的形式化定义使其具备数学上的可分析性:我们可以精确判断一个句子是否属于某个文法生成的语言,可以枚举所有可能的分析树,可以计算解析的时间复杂度。这些理论性质是构建高效句法分析算法的基础。

python
# CFG 的完整 Python 实现
from typing import List, Dict, Set, Tuple
from collections import defaultdict

class ContextFreeGrammar:
    """上下文无关文法的实现"""
    
    def __init__(self):
        self.productions: Dict[str, List[List[str]]] = defaultdict(list)
        self.start_symbol = "S"
    
    def add_rule(self, lhs: str, rhs: List[str]):
        """添加产生式规则:lhs → rhs"""
        self.productions[lhs].append(rhs)
    
    def get_nonterminals(self) -> Set[str]:
        """获取所有非终结符"""
        return set(self.productions.keys())
    
    def get_terminals(self) -> Set[str]:
        """获取所有终结符"""
        terminals = set()
        for rhs_list in self.productions.values():
            for rhs in rhs_list:
                for sym in rhs:
                    if sym not in self.get_nonterminals():
                        terminals.add(sym)
        return terminals
    
    def is_chomsky_normal(self) -> bool:
        """检查是否为乔姆斯基范式(CNF)"""
        nonterminals = self.get_nonterminals()
        for lhs, rhs_list in self.productions.items():
            for rhs in rhs_list:
                if len(rhs) == 1:
                    if rhs[0] not in nonterminals:
                        continue  # 允许终结符
                    return False
                elif len(rhs) == 2:
                    if not all(s in nonterminals for s in rhs):
                        return False
                else:
                    return False  # CNF 只允许 1 或 2 个符号
        return True

# 构建一个简单英语 CFG
grammar = ContextFreeGrammar()
grammar.add_rule("S", ["NP", "VP"])
grammar.add_rule("NP", ["Det", "N"])
grammar.add_rule("NP", ["Det", "N", "PP"])
grammar.add_rule("VP", ["V", "NP"])
grammar.add_rule("PP", ["P", "NP"])
print(f"CNF 检查: {grammar.is_chomsky_normal()}")
python
# 将 CFG 转换为乔姆斯基范式(CNF)
def to_cnf(grammar_rules: Dict[str, List[List[str]]]) -> Dict[str, List[List[str]]]:
    """将 CFG 转换为乔姆斯基范式
    CNF 要求每条规则要么是 A → BC(两个非终结符),要么是 A → a(一个终结符)
    """
    cnf = {}
    counter = [0]  # 用于生成新的非终结符名
    
    def new_symbol():
        counter[0] += 1
        return f"X{counter[0]}"
    
    # 第一步:处理长规则(>2个非终结符)
    for lhs, rhs_list in grammar_rules.items():
        cnf[lhs] = []
        for rhs in rhs_list:
            if len(rhs) > 2:
                # A → B C D E 变为:
                # A → B X1, X1 → C X2, X2 → D E
                current = rhs[0]
                for i in range(1, len(rhs) - 2):
                    new_sym = new_symbol()
                    cnf[lhs].append([current, new_sym])
                    lhs = new_sym
                    current = rhs[i]
                cnf[lhs].append([current, rhs[-2], rhs[-1]])
            else:
                cnf[lhs].append(rhs)
    
    return cnf

# 测试转换
rules = {"S": [["NP", "VP"]], "VP": [["V", "NP", "PP"]]}
cnf_rules = to_cnf(rules)
for lhs, rhs_list in cnf_rules.items():
    for rhs in rhs_list:
        print(f"{lhs} -> {' '.join(rhs)}")
文法类型规则形式表达能力解析复杂度

正则文法(3 型)

A → aB 或 A → a

最低(正则语言)

O(n)

上下文无关文法(2 型)

A → α(任意串)

可描述嵌套结构

O(n³)

上下文有关文法(1 型)

αAβ → αγβ

更高(但很少用)

指数级

无限制文法(0 型)

α → β(无限制)

图灵完备

不可判定

CKY 算法要求 CFG 必须是乔姆斯基范式(CNF)。任何 CFG 都可以等价转换为 CNF,转换过程中可能引入新的非终结符,但不改变文法生成的语言。

CFG 无法捕获自然语言中的某些现象,如跨距依赖(cross-serial dependencies)和某些一致关系(agreement)。这也是为什么后续发展出了树邻接文法(TAG)等更强的形式化方法。

3CKY 算法:自底向上的高效成分分析

CKY 算法(Cocke-Kasami-Younger 算法)是句法分析领域最经典的动态规划算法之一。它的核心思想非常优雅:对于一个长度为 n 的句子,我们不需要尝试所有可能的树(指数级),而是可以通过填充一个 n×n 的上三角表格,在 O(n³) 的时间内找到所有可能的成分分析。

算法的工作方式是自底向上的:首先填充对角线(每个单词本身可能属于哪些语法范畴),然后依次填充宽度为 2、3、…、n 的对角线。对于每个跨度 (i, j),我们枚举所有可能的分割点 k,检查是否存在规则 A → BC 使得 B 覆盖了 (i, k) 且 C 覆盖了 (k, j)。如果是,就将 A 加入 (i, j) 的单元格中。

CKY 算法的关键优势在于它天然支持歧义处理——每个单元格存储的是所有可能的非终结符集合,而不是单一分析。这意味着一个句子如果有多种合法的句法分析(如经典的 "I saw the man with the telescope"),CKY 可以高效地找到所有分析。

然而,CKY 算法也有明显的局限:它要求文法必须是 CNF 形式,且纯 CKY 不考虑概率信息。在实际应用中,我们通常使用 PCFG(概率上下文无关文法)的 CKY 变体,通过概率来选择最优分析树。

python
# CKY 算法的完整实现
def cky_parse(words: list, grammar: dict, start: str = "S"):
    """CKY 解析算法
    
    Args:
        words: 输入句子(词列表)
        grammar: 文法规则 {A: [(B, C), ...]} 仅 CNF 形式
        start: 起始符号
    
    Returns:
        table: n×n 的解析表
        backpointers: 回溯指针(用于重建树)
    """
    n = len(words)
    # table[i][j] = 覆盖 words[i..j] 的所有非终结符
    table = [[set() for _ in range(n)] for _ in range(n)]
    # backpointers[i][j][A] = (B, C, k) 表示 A→BC 在 k 处分割
    backpointers = [[{} for _ in range(n)] for _ in range(n)]
    
    # 反转规则索引:(B, C) → [A]
    bin_rules = {}
    for A, rules in grammar.items():
        for B, C in rules:
            bin_rules.setdefault((B, C), []).append(A)
    
    # 步骤1: 填充对角线(单个词)
    for i, word in enumerate(words):
        # 假设 lexicon 将词映射到可能的词性
        lexicon = {"the": ["Det"], "cat": ["N"], "chased": ["V"], "dog": ["N"]}
        for tag in lexicon.get(word, []):
            table[i][i].add(tag)
    
    # 步骤2: 填充上层对角线
    for span in range(1, n):  # 跨度 1, 2, ..., n-1
        for i in range(n - span):
            j = i + span
            for k in range(i, j):
                for B in table[i][k]:
                    for C in table[k + 1][j]:
                        if (B, C) in bin_rules:
                            for A in bin_rules[(B, C)]:
                                table[i][j].add(A)
                                backpointers[i][j][A] = (B, C, k)
    
    return table, backpointers

# 测试
words = ["the", "cat", "chased", "the", "dog"]
grammar = {
    "S": [("NP", "VP")],
    "NP": [("Det", "N")],
    "VP": [("V", "NP")],
}
table, bp = cky_parse(words, grammar)
print(f"完整句子的可能分析: {table[0][4]}")
python
# 概率 CKY(PCFG 版本)- 选择最优分析树
import math

def probabilistic_cky(words, pcfg_rules, lexicon, start="S"):
    """概率 CKY 算法:为每个单元格保留最优概率"""
    n = len(words)
    # score[i][j][A] = 以 A 为根覆盖 words[i..j] 的最大概率
    score = [[{} for _ in range(n)] for _ in range(n)]
    bp = [[{} for _ in range(n)] for _ in range(n)]
    
    # 填充对角线:词 → 词性的概率
    for i, word in enumerate(words):
        for tag, prob in lexicon.get(word, {}).items():
            score[i][i][tag] = prob
    
    # 动态规划
    for span in range(1, n):
        for i in range(n - span):
            j = i + span
            for k in range(i, j):
                for (B, C), rules in pcfg_rules.items():
                    if B not in score[i][k] or C not in score[k+1][j]:
                        continue
                    for A, rule_prob in rules:
                        prob = rule_prob * score[i][k][B] * score[k+1][j][C]
                        if A not in score[i][j] or prob > score[i][j][A]:
                            score[i][j][A] = prob
                            bp[i][j][A] = (B, C, k)
    
    if start in score[0][n-1]:
        print(f"最优分析概率: {score[0][n-1][start]:.6f}")
    else:
        print("无法解析该句子")
    return score, bp

# PCFG 规则:(B, C) → [(A, 概率), ...]
pcfg = {
    ("Det", "N"): [("NP", 1.0)],
    ("V", "NP"): [("VP", 0.7), ("VP", 0.3)],
    ("NP", "VP"): [("S", 1.0)],
}
lex = {"the": {"Det": 1.0}, "cat": {"N": 1.0}, "chased": {"V": 1.0}, "dog": {"N": 1.0}}
probabilistic_cky(["the", "cat", "chased", "the", "dog"], pcfg, lex)
算法时间复杂度空间复杂度特点

CKY

O(n³|G|)

O(n²|G|)

自底向上,需 CNF 文法

Earley

O(n³|G|)

O(n²|G|)

支持任意 CFG,自顶向下

概率 CKY

O(n³|G|)

O(n²|G|)

返回最优分析树

Beam Search CKY

O(n³b|G|)

O(nb|G|)

b 为 beam 宽度,节省内存

CKY 算法的三重循环(span → i → k)是经典的动态规划模式。理解这个循环顺序是掌握 CKY 的关键:外层枚举跨度大小,确保计算 (i,j) 时所有更小的子问题已经解决。

CKY 的 O(n³) 复杂度对长句子来说仍然很慢。实际系统中通常使用带剪枝的 CKY(只保留高概率候选)或直接使用神经句法分析器。

4依存分析 vs 成分分析:两种结构观

依存分析和成分分析是句法分析的两大范式,它们对句子结构有着本质不同的理解方式。成分分析将句子视为短语的递归组合,每个节点代表一个语法范畴(如 NP、VP),叶子节点是词。依存分析则直接建模词与词之间的二元关系,每个节点就是一个词,边标记了语法关系类型。

依存分析的核心优势在于其与语义的天然对应。在依存树中,「动词」通常是句子的核心(根节点),名词短语作为其论元直接连接到动词上。这种结构直接反映了「谁对谁做了什么」的语义关系。例如在 "The cat chased the dog" 中,依存树的根是 "chased","cat" 是其主语(nsubj),"dog" 是其宾语(dobj)——这与语义角色几乎一一对应。

成分分析则在捕捉短语边界方面更具优势。如果你需要知道 "the big red ball" 是否构成一个完整的名词短语,成分分析可以直接回答,而依存分析需要额外推理。此外,成分分析对于处理嵌套结构(如嵌套的定语从句)更加自然。

现代 NLP 实践中,依存分析因其简洁性和与下游任务的良好兼容性而更受青睐。Universal Dependencies(UD)项目为 100+ 种语言提供了统一的依存标注体系,已成为依存分析的事实标准。

python
# 对比同一句子的两种分析结果
sentence = "The big cat quickly chased the small dog"

# === 成分分析(短语结构树)===
#              S
#       _______|_______
#      NP              VP
#   ___|___        ____|_____
#  Det   AdjP     Adv       VP
#   |     |       |     ___|___
#  The  big     quickly V     NP
#               |      |   ___|___
#             chased Det  Adj    N
#                   |     |      |
#                  the  small   dog
constituency_repr = {
    "S": {
        "NP": {"Det": "The", "AdjP": "big", "N": "cat"},
        "VP": {
            "Adv": "quickly",
            "VP": {
                "V": "chased",
                "NP": {"Det": "the", "AdjP": "small", "N": "dog"}
            }
        }
    }
}

# === 依存分析(依存关系)===
#    chased (ROOT)
#    /  |   \
#  cat  |   dog
#  / \     / \
#The big quickly the small
dependency_repr = [
    ("chased", "ROOT", "ROOT"),
    ("cat", "nsubj", "chased"),
    ("dog", "dobj", "chased"),
    ("The", "det", "cat"),
    ("big", "amod", "cat"),
    ("quickly", "advmod", "chased"),
    ("the", "det", "dog"),
    ("small", "amod", "dog"),
]
print("=== 依存关系 ===")
for child, rel, head in dependency_repr:
    print(f"  {child} --[{rel}]--> {head}")
python
# 使用 spaCy 对比两种分析
import spacy

nlp = spacy.load("en_core_web_sm")
doc = nlp("The cat chased the dog in the park")

print("=== 依存分析 ===")
for token in doc:
    print(f"  {token.text:10s} | "
          f"{token.pos_:8s} | "
          f"head={token.head.text:10s} | "
          f"dep={token.dep_}")

print(" === 成分分析 ===")
# spaCy 的成分分析需要额外模型
# 这里展示如何提取名词短语作为成分的近似
for np in doc.noun_chunks:
    print(f"  NP: {np.text} (root={np.root.text})")

print(" === 依存树可视化 ===")
# 构建邻接表
adj = {}
for token in doc:
    if token.head != token:
        adj.setdefault(token.head.i, []).append(token.i)
for head_idx, children in adj.items():
    print(f"  {doc[head_idx].text} → {[doc[c].text for c in children]}")
对比维度成分分析依存分析

节点

短语范畴(NP、VP)

具体的词

组成关系

依存关系(带标签)

根节点

S(句子)

句子的核心动词

歧义表示

多棵树

多棵依存树

标注体系

Penn Treebank

Universal Dependencies

下游任务

翻译、语法检查

信息抽取、问答

Universal Dependencies 是当前最主流的依存标注体系,覆盖了 100+ 种语言。如果你的项目涉及多语言 NLP,强烈建议以 UD 作为依存分析的标注标准。

成分树和依存树之间可以相互转换,但转换过程中会丢失信息。不要假设一种表示可以完全无损地转换为另一种。

5转移-based 依存分析:Arc-Standard 与 Arc-Eager

转移系统(Transition-Based Parsing)是依存分析中最实用的方法之一。它的核心思想是将句法分析建模为一个状态机的搜索过程:从一个初始状态(缓冲区中有所有词,栈为空,依存边集合为空)开始,通过一系列「转移动作」逐步构建依存树,直到缓冲区为空且栈中只剩根节点。

最经典的两种转移动作系统是 Arc-Standard 和 Arc-Eager。Arc-Standard 只有三个动作:SHIFT(将缓冲区的第一个词压入栈)、LEFT-ARC(将栈顶词作为栈次顶词的头,并弹出栈顶)、RIGHT-ARC(将栈次顶词作为栈顶词的头,并弹出次顶)。Arc-Standard 是确定性(deterministic)的——对于给定的状态和正确的 oracle,它总是能构建出正确的依存树。

Arc-Eager 则更加激进:它在 RIGHT-ARC 时不弹出栈顶词,允许同一个词在后续步骤中成为其他词的左依存对象。这使得 Arc-Eager 可以处理 Arc-Standard 无法直接处理的某些结构,但也引入了更多的状态空间。

转移系统的最大优势是效率:它可以在 O(n) 的时间内完成一个 n 词句子的依存分析,远优于基于图的 O(n²) 或 O(n³) 方法。现代神经依存分析器(如 spaCy 的默认分析器)都基于转移系统。

python
# Arc-Standard 转移系统实现
from typing import List, Tuple

class ArcStandardParser:
    """Arc-Standard 转移依存分析器"""
    
    def __init__(self):
        self.stack = ["ROOT"]  # 栈,初始包含 ROOT
        self.buffer = []       # 缓冲区
        self.arcs = []         # 依存边集合
    
    def shift(self):
        """SHIFT: buffer[0] → stack"""
        if self.buffer:
            self.stack.append(self.buffer.pop(0))
            return True
        return False
    
    def left_arc(self, label: str = "dep") -> bool:
        """LEFT-ARC: stack[-2] → stack[-1](stack[-1] 是头)"""
        if len(self.stack) >= 2 and self.stack[-2] != "ROOT":
            self.arcs.append((self.stack[-1], label, self.stack[-2]))
            self.stack.pop(-2)  # 弹出次顶元素
            return True
        return False
    
    def right_arc(self, label: str = "dep") -> bool:
        """RIGHT-ARC: stack[-1] → stack[-2](stack[-1] 是头)"""
        if len(self.stack) >= 2:
            self.arcs.append((self.stack[-2], label, self.stack[-1]))
            self.stack.pop()  # 弹出栈顶
            return True
        return False
    
    def parse(self, words: List[str], oracle: List[str]):
        """使用 oracle 指导的解析过程
        
        Args:
            words: 输入词序列
            oracle: 转移动作序列,如 ["S", "S", "LA", "RA"]
        """
        self.buffer = list(words)
        self.stack = ["ROOT"]
        self.arcs = []
        
        for action in oracle:
            if action == "S":
                self.shift()
            elif action == "LA":
                self.left_arc()
            elif action == "RA":
                self.right_arc()
        
        return self.arcs

# 示例:"cat chased dog" 的 Arc-Standard 解析
parser = ArcStandardParser()
# Oracle: S(shift cat), S(shift chased), LA(cat←chased), S(shift dog), RA(chased→dog)
arcs = parser.parse(["cat", "chased", "dog"], ["S", "S", "LA", "S", "RA"])
for head, label, child in arcs:
    print(f"  {child} --[{label}]--> {head}")
python
# Arc-Eager 转移系统(更激进)
class ArcEagerParser:
    """Arc-Eager 转移依存分析器
    
    四个动作:
    - LEFT-ARC: 建立依存并弹出栈顶
    - RIGHT-ARC: 建立依存但保留栈顶
    - REDUCE: 弹出已处理完的栈顶
    - SHIFT: 压入新词
    """
    
    def __init__(self):
        self.stack = ["ROOT"]
        self.buffer = []
        self.arcs = []
    
    def left_arc(self, label: str = "dep") -> bool:
        """stack[-2] 作为 stack[-1] 的孩子"""
        if len(self.stack) >= 2 and self.stack[-2] != "ROOT":
            self.arcs.append((self.stack[-1], label, self.stack[-2]))
            self.stack.pop(-2)
            return True
        return False
    
    def right_arc(self, label: str = "dep") -> bool:
        """stack[-1] 作为 buffer[0] 的头(buffer[0] 不弹出)"""
        if len(self.stack) >= 2 and self.buffer:
            self.arcs.append((self.stack[-1], label, self.buffer[0]))
            return True
        return False
    
    def reduce(self) -> bool:
        """弹出已有头的栈顶词"""
        if len(self.stack) >= 2:
            has_head = any(child == self.stack[-1] for _, _, child in self.arcs)
            if has_head:
                self.stack.pop()
                return True
        return False
    
    def shift(self):
        """buffer[0] → stack"""
        if self.buffer:
            self.stack.append(self.buffer.pop(0))
            return True
        return False

# 对比 Arc-Standard 和 Arc-Eager 的差异
print("Arc-Standard 特点:")
print("  - 动作数少(3个),状态空间小")
print("  - RIGHT-ARC 后立即弹出,效率高")
print("  - 某些结构需要特殊处理")
print("")
print("Arc-Eager 特点:")
print("  - 动作数多(4个),更灵活")
print("  - RIGHT-ARC 保留栈顶,支持后续 LEFT-ARC")
print("  - 更适合神经网络学习(动作选择更局部)")
特性Arc-StandardArc-Eager

动作集合

SHIFT, LEFT-ARC, RIGHT-ARC

SHIFT, LEFT-ARC, RIGHT-ARC, REDUCE

RIGHT-ARC 行为

弹出栈顶

保留栈顶

确定性

是(对 projective 树)

是(对 projective 树)

状态空间

较小

较大

非投射性

不支持

需扩展支持

神经实现

较少使用

spaCy 默认

转移动作的选择(oracle)是训练转移分析器的关键。Oracle 可以从 gold 依存树中自动提取——这称为动态 oracle(dynamic oracle),它允许在训练过程中容忍错误并恢复。

基础的 Arc-Standard/Arc-Eager 只能处理投射性(projective)依存树,即没有交叉边的树。自然语言中存在约 25% 的非投射结构,需要额外扩展(如 swap 动作)来处理。

6图-based 依存分析:MST 与 Eisner 算法

与转移系统逐步构建依存树不同,图-based 方法将依存分析建模为在有向图中寻找最优生成树的问题。具体来说,给定一个句子,我们首先构建一个完全有向图:每个词是一个节点,每对词之间存在两条有向边(两个方向各一条),每条边都有一个分数(由模型预测)。然后,目标是找到一棵以 ROOT 为根的有向生成树,使得树上所有边的分数之和最大。

最大生成树(Maximum Spanning Tree, MST)方法的关键优势在于它天然支持非投射性(non-projective)结构。Chu-Liu/Edmonds 算法可以在 O(n²) 的时间内找到有向图的最大生成树,无论是否存在交叉边。这对于依存关系中存在长距离依赖或非投射结构的语言(如捷克语、德语)非常重要。

对于投射性树,Eisner 算法提供了一个更高效的 O(n³) 动态规划解法。Eisner 算法的核心洞察是:投射性依存树可以递归地分解为「完整跨度」和「不完整跨度」两类子结构。完整跨度指该跨度内所有词的头都在跨度内;不完整跨度则恰好有一个词的头在跨度外。这种分解使得我们可以用动态规划高效地搜索最优投射树。

图-based 方法的另一个优势是它可以与任意打分模型结合——无论是传统的基于特征的分类器,还是现代的 BiLSTM 或 Transformer 编码器。这使得图-based 方法在神经句法分析时代仍然保持竞争力。

python
# Chu-Liu/Edmonds MST 算法简化实现
def mst_parse(scores, n):
    """Chu-Liu/Edmonds 最大生成树算法
    
    Args:
        scores: n×n 矩阵,scores[i][j] = 词 i 作为词 j 的头的分数
        n: 词数(含 ROOT,索引 0 是 ROOT)
    
    Returns:
        heads: 每个词的头节点索引
    """
    # 简化版本:贪心选择每个节点的最高分入边
    # 注意:完整实现需要处理环的收缩
    heads = [-1] * n
    in_weights = [-float('inf')] * n
    
    for j in range(1, n):  # 每个非根节点
        for i in range(n):
            if i != j and scores[i][j] > in_weights[j]:
                in_weights[j] = scores[i][j]
                heads[j] = i
    
    # 检查是否有环(简化版,实际需要环收缩)
    visited = set()
    has_cycle = False
    for node in range(1, n):
        path = []
        curr = node
        while curr != -1 and curr not in visited:
            path.append(curr)
            visited.add(curr)
            curr = heads[curr]
        if curr in path:
            has_cycle = True
            break
    
    total_score = sum(in_weights[1:])
    return heads, total_score, has_cycle

# 示例打分矩阵
scores = [
    [0.0, 0.1, 0.8, 0.3],  # ROOT 的头分数
    [0.9, 0.0, 0.2, 0.1],  # "cat" 的头分数
    [0.1, 0.7, 0.0, 0.6],  # "chased" 的头分数
    [0.2, 0.1, 0.8, 0.0],  # "dog" 的头分数
]
words = ["ROOT", "cat", "chased", "dog"]
heads, score, cycle = mst_parse(scores, 4)
print(f"最优生成树分数: {score}")
for i, h in enumerate(heads):
    if i > 0:
        print(f"  {words[i]} --head--> {words[h]}")
python
# Eisner 算法:投射性依存分析的动态规划
def eisner(scores, n):
    """Eisner 算法寻找最优投射依存树
    
    核心思想:用两种类型的 span 来递归分解
    - complete[i][j]: words[i..j] 形成完整子树,i 或 j 是根
    - incomplete[i][j]: words[i..j] 形成不完整子树
    
    状态: dp[k][i][j]
    k=0: incomplete, j 是根 (i ≤ j)
    k=1: incomplete, i 是根 (i ≤ j)
    k=2: complete, i 是根 (i ≤ j)
    k=3: complete, j 是根 (i ≤ j)
    """
    # 初始化
    INF = float('-inf')
    dp = [[[[INF] * 4 for _ in range(n)] for _ in range(n)] for _ in range(n)]
    
    # 基线情况
    for i in range(n):
        for k in range(4):
            dp[0][i][i][k] = 0.0
    
    # 动态规划
    for span in range(1, n):
        for i in range(n - span):
            j = i + span
            for r in range(i, j):
                # incomplete: 分解为两个 complete
                dp[0][i][j][0] = max(dp[0][i][j][0],
                    dp[2][i][r][2] + dp[3][r+1][j][3])
                dp[1][i][j][1] = max(dp[1][i][j][1],
                    dp[2][i][r][2] + dp[3][r+1][j][3])
            
            for r in range(i, j + 1):
                # complete: 添加边 i→j 或 j→i
                dp[2][i][j][2] = max(dp[2][i][j][2],
                    dp[1][i][j][1] + scores[i][j])
                dp[3][i][j][3] = max(dp[3][i][j][3],
                    dp[0][i][j][0] + scores[j][i])
    
    return dp[2][0][n-1][2]

# 使用 BiLSTM 编码生成打分矩阵
import numpy as np
np.random.seed(42)
n_words = 5  # ROOT + 4 words
arc_scores = np.random.randn(n_words, n_words)
result = eisner(arc_scores, n_words)
print(f"Eisner 最优分数: {result:.4f}")
方法时间复杂度投射性非投射性核心算法

转移-based

O(n)

需扩展

Arc-Standard/Eager

Eisner

O(n³)

动态规划

Chu-Liu/Edmonds

O(n²)

MST 环收缩

Tarjan 改进

O(n²)

MST 高效实现

在实际系统中,图-based 和转移-based 方法经常结合使用:转移-based 用于快速推断(O(n)),图-based 用于生成训练数据或后处理修正。

Eisner 算法只适用于投射性树。如果你的语料包含大量非投射结构(如自由语序语言),必须使用 Chu-Liu/Edmonds 算法。

7实战:用 spaCy 和 Stanza 进行句法分析

理论学完了,让我们进入实战。spaCy 和 Stanza 是当前最流行的两个工业级 NLP 工具包,它们都提供了开箱即用的依存分析和成分分析功能,但设计理念和使用方式有所不同。

spaCy 以其速度和工程化著称。它的依存分析基于转移系统(Arc-Eager),通过 BiLSTM 或 Transformer 编码,在 CPU 上即可实现实时分析。spaCy 的设计哲学是「默认就能用得很好」——加载模型后,一行代码即可完成依存分析。它还提供了丰富的可视化工具(displacy)和便捷的属性访问接口。

Stanza(原 StanfordNLP)由 Stanford NLP 组开发,更注重准确性和学术严谨性。它使用基于图的依存分析(BiAF/Biaffine 注意力机制),在多个基准数据集上取得了最先进的准确率。Stanza 不仅支持依存分析,还提供完整的 NLP 流水线(分词、词性标注、形态分析、依存分析、命名实体识别)。

两者都支持 Universal Dependencies 标注体系,这意味着它们的输出格式是兼容的。选择哪个取决于你的需求:追求速度和工程化选 spaCy,追求准确性和学术分析选 Stanza。

python
# spaCy 依存分析实战
import spacy
from spacy import displacy

# 加载英文模型
nlp = spacy.load("en_core_web_trf")  # Transformer 版本

# 分析句子
doc = nlp("The curious cat cautiously chased the tiny mouse")

# 获取依存关系
print(f"{'Token':<12} {'POS':<8} {'Head':<12} {'Dep Relation'}")
print("-" * 50)
for token in doc:
    print(f"{token.text:<12} {token.pos_:<8} {token.head.text:<12} {token.dep_}")

# 提取主语-动词-宾语三元组
def extract_svo(doc):
    """从依存树中提取 SVO 三元组"""
    svos = []
    for token in doc:
        if token.pos_ == "VERB":
            subject = None
            obj = None
            for child in token.children:
                if child.dep_ in ("nsubj", "nsubjpass"):
                    subject = child.text
                elif child.dep_ in ("dobj", "obj", "pobj"):
                    obj = child.text
            if subject and obj:
                svos.append((subject, token.text, obj))
    return svos

svos = extract_svo(doc)
print(f" SVO 三元组: {svos}")
# 输出: [('cat', 'chased', 'mouse')]
python
# Stanza 依存分析实战
import stanza

# 下载并加载英文模型
stanza.download("en")
nlp = stanza.Pipeline("en", processors="tokenize,pos,lemma,depparse")

# 分析句子
doc = nlp("The curious cat cautiously chased the tiny mouse")

# 遍历依存关系
print("=== Stanza 依存分析 ===")
for sentence in doc.sentences:
    print(f"句子: {' '.join(w.text for w in sentence.words)}")
    print(f"{'ID':<4} {'Form':<12} {'UPOS':<6} {'Head':<6} {'DepRel'}")
    print("-" * 45)
    for word in sentence.words:
        head_text = sentence.words[word.head - 1].text if word.head > 0 else "ROOT"
        print(f"{word.id:<4} {word.text:<12} {word.upos:<6} {head_text:<6} {word.deprel}")

# 成分分析(Stanza 也支持)
nlp_const = stanza.Pipeline("en", processors="tokenize,pos,constituency")
doc_const = nlp_const("The cat chased the dog")
for sent in doc_const.sentences:
    print(f" 成分树: {sent.constituency}")

# 批量处理
sentences = [
    "The cat chased the dog.",
    "The dog ran away quickly.",
    "Both animals lived happily.",
]
docs = nlp(" ".join(sentences))
for i, sent in enumerate(docs.sentences):
    print(f" 句子 {i+1}: {[w.text for w in sent.words]}")
特性spaCyStanza

分析器类型

转移-based (Arc-Eager)

图-based (Biaffine)

依存分析速度

极快(~1000 词/秒)

中等(~200 词/秒)

UD 准确率

~85-90%

~90-93%

成分分析

需额外模型

内置支持

GPU 加速

支持

支持

模型大小

中等(~50MB)

较大(~500MB)

多语言支持

20+ 语言

70+ 语言

易用性

★★★★★

★★★★

spaCy 的 Transformer 模型(en_core_web_trf)比默认的 en_core_web_sm 准确率更高,但速度稍慢。生产环境中可以用小模型做快速过滤,大模型做精分析。

spaCy 的依存分析模型是在特定语料上训练的,对于非标准英语(如社交媒体文本、方言)效果可能下降。如果处理特殊领域文本,建议使用领域数据微调模型。

继续你的 AI 学习之旅

浏览更多 AI 知识库文章,或者探索 GitHub 上的优质 AI 项目