一、先说说为什么这事值得折腾
做自然语言处理的人,迟早会遇到一个头疼的问题:怎么让机器理解句子的语法结构?
比如这句话:"我喜欢自然语言处理。" 人一眼就能看出来------"喜欢"是核心动词,"我"是它的主语,"自然语言处理"是它的宾语。但机器呢?它看到的只是一串token ID,没有任何结构信息。
传统的做法是做特征工程:人工设计几百上千条规则,比如"如果栈顶是动词,缓冲区第一个是名词,且POS标签是NN,那么可能建立dobj关系"。这种办法活累效果差,换个语言、换个领域,特征就得重新设计。
那有没有可能,让神经网络自己学?不需要人工特征,只需要把句子丢进去,网络自己决定下一步该干什么------是继续读入新词?还是把栈顶两个词连起来?连的时候又是什么关系?
答案是:完全可以。而且早在2015年,就有人用一套叫栈式LSTM的架构,把这个事做透了。
二、这到底是个什么东西
用最直白的话说,这是一个基于转移的依存句法分析器。它的核心思想是:
把"分析句子结构"这件事,变成一场"打牌"游戏。
你手里有三摞牌:
- Buffer(缓冲区):还没处理的词,按顺序排着队。
- Stack(栈):正在处理的词,以及已经部分组装好的"小树苗"(局部依存树)。
- Action History(动作历史):你之前每一步干了什么。
每一步,你看着这三摞牌的状态,决定下一步动作:
- SHIFT:从Buffer顶部拿一张牌,放到Stack顶部。
- LEFT-ARC(label):Stack顶部两张牌,左边那张依赖右边那张,建立一条带标签的弧,然后把左边那张扔掉。
- RIGHT-ARC(label):Stack顶部两张牌,右边那张依赖左边那张,建立一条带标签的弧,然后把右边那张扔掉。
反复执行这些动作,直到Buffer空了、Stack只剩ROOT和一棵完整的树,游戏结束。这棵树就是句子的依存结构。
2.1 和传统方法的区别在哪
传统方法(比如SVM、感知机)做这件事时,每一步决策都要人工设计特征------看栈顶几个词、看缓冲区前几个词、看它们的POS标签组合。特征设计得好不好,直接决定 parser 的准确率。
而这套方案的思路是:用神经网络自动学习"状态表示"。不再人工设计特征,而是把Buffer、Stack、Action History各自编码成一个向量,拼接起来,丢进一个MLP,让它自己决定下一步该干什么。
2.2 关键技术:Stack LSTM
普通LSTM只能顺序读入数据,读完就完了,不能回退。但Stack LSTM不一样------它像真正的栈一样,支持Push(入栈)和Pop(出栈)。
- Push:新元素进来,LSTM状态正常更新。
- Pop:栈顶元素出去,LSTM状态回退到之前保存的某个历史状态。
这个设计非常关键,因为依存分析中的Stack需要频繁Push和Pop------SHIFT是Push,LEFT/RIGHT-ARC也是先Pop再Push(弹出两个元素,压入一个组合后的新元素)。
三、整体架构设计原理图
3.1 系统整体架构 + 转移系统流程

从这张图能看出来,整个系统分成左右两部分:
左半部分:神经网络架构
- 输入句子 → 词嵌入 + POS标签 → 分别送入三个Stack LSTM
- 三个Stack LSTM的输出拼接 → ReLU → MLP → Softmax → 预测下一步动作
- 执行动作后,更新Stack/Buffer/Action,循环往复
右半部分:转移系统示例
- 以"我喜欢自然语言处理"为例,展示了从初始状态到最终依存树的完整动作序列
3.2 Stack LSTM 内部机制

这张图揭示了三个核心机制:
普通LSTM vs Stack LSTM:普通LSTM只能顺序前进(h1→h2→h3→h4),Stack LSTM可以在任意时刻Pop回退。
组合函数(Composition):当建立依存弧时,把Head和Dependent的向量拼接,加上关系标签,通过tanh变换,生成一个新的组合表示。这个组合表示替代原来的Head,成为Stack上的新元素。
三个编码对象:Buffer、Stack、Action History,各自有一个Stack LSTM负责编码。
四、核心模块原理详解
4.1 依存句法分析:句子结构的"树形表示"
依存句法分析的目标,是把一句话变成一棵有向树:
- 每个词是一个节点
- 每个节点有一个"头"(head),表示它依赖谁
- 每条边有一个标签,表示什么关系(主语nsubj、宾语dobj、定语amod等)
比如"我喜欢自然语言处理":
ROOT
|
喜欢
/ \
我 自然语言处理
nsubj dobj
"我"依赖"喜欢",关系是nsubj(主语);"自然语言处理"依赖"喜欢",关系是dobj(宾语);"喜欢"依赖ROOT,关系是root。
4.2 转移系统:把树构造变成动作序列
基于转移的方法,把树构造问题转化为状态转移问题。
状态 = (Stack, Buffer, Action History, 已建立的弧)
初始状态:
- Stack = ROOT
- Buffer = w1, w2, ..., wn(整个句子,ROOT在底部)
- Action History = \[\]
- 弧集合 = ∅
终止状态:
- Stack = ROOT, 完整树
- Buffer = ∅
- 弧集合 = 完整的依存树
动作集合(Arc-Standard系统):
| 动作 | 效果 | 前提条件 |
|---|---|---|
| SHIFT | Buffer顶部元素移入Stack | Buffer非空 |
| LEFT-ARC® | Stack顶部两个元素s1, s0,建立s1→s0的弧,标签r,弹出s1 | Stack至少有2个元素,s1不是ROOT |
| RIGHT-ARC® | Stack顶部两个元素s1, s0,建立s0→s1的弧,标签r,弹出s0 | Stack至少有2个元素 |
对于n个词的句子,恰好需要2n次动作(n次SHIFT + n次ARC)就能构造出一棵合法的依存树。
4.3 Stack LSTM:支持Push/Pop的序列编码器
Stack LSTM的核心创新在于:它不仅维护当前LSTM状态,还维护一个"状态栈"。
cpp
// 伪代码:Stack LSTM的核心操作
class StackLSTM {
std::vector<LSTMState> state_stack; // 状态栈,每个元素是一个完整LSTM状态
public:
// Push:和普通LSTM一样,读入新输入,更新状态
void push(Vector input) {
LSTMState new_state;
if (state_stack.empty()) {
new_state = lstm_initial_state(input);
} else {
new_state = lstm_step(state_stack.back(), input);
}
state_stack.push_back(new_state);
}
// Pop:弹出栈顶,状态回退
Vector pop() {
Vector top_embedding = state_stack.back().hidden;
state_stack.pop_back();
return top_embedding;
}
// 获取当前栈顶状态(用于编码整个Stack的内容)
Vector get_top_state() {
return state_stack.empty() ? empty_vector : state_stack.back().hidden;
}
};
为什么需要Pop? 因为在LEFT-ARC和RIGHT-ARC中,Stack顶部的两个元素会被弹出,然后压入一个组合后的新元素。普通LSTM没法"撤销"之前的状态,但Stack LSTM通过保存历史状态,可以精确回退。
4.4 组合函数:把两个词"粘"成一棵树
当执行LEFT-ARC或RIGHT-ARC时,Stack顶部的两个元素(一个是Head,一个是Dependent)需要被"组合"成一个新的表示,代表一棵局部子树。
cpp
// 伪代码:组合函数
Vector compose(Vector head, Vector dependent, Vector relation) {
// 拼接 head + dependent + relation_label
Vector concatenated = concatenate(head, dependent, relation);
// 线性变换 + tanh非线性
Vector composed = tanh(W * concatenated + b);
return composed;
}
这个组合表示有两个作用:
- 替代原来的Head,成为Stack上的新元素
- 编码了"Head带着一个Dependent"的完整信息,后续如果还有词依赖这个Head,组合表示能"记住"已经有谁挂在它下面了
实验表明:有组合函数的版本比没有组合函数的版本,LAS(带标签依存准确率)高1-2个百分点。这说明组合函数确实帮助网络记住了子树的结构信息。
4.5 解析器状态表示:三步拼接
每一步决策前,解析器需要把当前状态编码成一个固定维度的向量:
p_t = ReLU(W · [s_t; b_t; a_t] + d)
其中:
s_t:Stack的Stack LSTM编码(栈顶状态)b_t:Buffer的Stack LSTM编码(栈顶状态,即下一个待处理词)a_t:Action History的Stack LSTM编码(栈顶状态,即上一个动作)[;]:向量拼接W, d:可学习的参数ReLU:非线性激活
这个p_t就是解析器对当前"局面"的理解。把它再过一个线性层 + Softmax,就得到下一步动作的概率分布。
4.6 训练:跟着"标准答案"学
训练需要Oracle------也就是"黄金标准动作序列"。给定一个句子和它对应的依存树,可以自动生成唯一正确的动作序列(在Arc-Standard系统下)。
训练过程就是监督学习:
- 把句子丢进解析器
- 解析器预测一个动作
- 和Oracle的标准动作对比
- 如果预测错了,反向传播更新参数
- 执行标准动作,进入下一个状态,重复
损失函数是交叉熵:
Loss = -Σ log P(a_t* | p_t)
其中a_t*是标准动作,P(a_t* | p_t)是解析器预测的标准动作概率。
4.7 词表示:从查表到字符级
词表示有两种方案:
方案A:查表(Lookup Table)
- 每个词对应一个预训练的词向量(如Word2Vec、GloVe)
- 优点:直接、高效
- 缺点:OOV(未登录词)没法处理
方案B:字符级LSTM
- 把每个词拆成字符序列,用BiLSTM编码
- 优点:能处理OOV,对形态丰富的语言(如德语、土耳其语)特别有效
- 缺点:计算量大一些
在这个项目里,两种方案都支持,通过编译选项切换。
五、相关领域知识点全面总结
| 概念 | 解释 |
|---|---|
| 依存句法分析 | 把句子变成一棵有向树,每个词依赖一个"头" |
| 基于转移的方法 | 把分析过程变成一系列状态转移动作 |
| 基于图的方法 | 给所有可能的弧打分,用算法(如MST)找最优树 |
| Arc-Standard | 一种转移系统,LEFT/RIGHT-ARC在子树完整后才执行 |
| Stack LSTM | 支持Push/Pop的LSTM变体,能编码栈内容 |
| 组合函数 | 把Head和Dependent组合成子树表示的神经网络 |
| Oracle | 给定依存树,自动生成的标准动作序列 |
| LAS | Labeled Attachment Score,带标签依存准确率 |
| UAS | Unlabeled Attachment Score,不带标签依存准确率 |
| SHIFT | 转移动作:Buffer→Stack |
| LEFT-ARC | 转移动作:Stack顶部建立左弧,弹出左元素 |
| RIGHT-ARC | 转移动作:Stack顶部建立右弧,弹出右元素 |
| SWAP | 转移动作:交换Stack顶部两个元素,用于非投影树 |
| CoNLL格式 | 依存句法分析的标准数据格式,每行一个词,含ID、FORM、LEMMA、POS、HEAD、DEPREL等字段 |
| 预训练词向量 | 在大语料上预训练的词嵌入,如Word2Vec、skip-gram |
| OOV | Out-Of-Vocabulary,训练时没见过的词 |
六、设计思路与工程亮点
6.1 三个Stack LSTM,各司其职
| Stack LSTM | 编码对象 | 作用 |
|---|---|---|
| Buffer LSTM | 待处理词序列 | 知道"后面还有什么词" |
| Stack LSTM | 部分构建的依存树 | 知道"已经拼出了哪些子树" |
| Action LSTM | 历史动作序列 | 知道"之前干了什么" |
这三个编码拼接起来,解析器对"当前局面"的理解是全局的------不是只看栈顶两三个词,而是看整个Buffer、整个Stack、整个历史。
6.2 组合函数让子树有"记忆"
没有组合函数时,建立依存弧后,Dependent的信息就丢了,Stack上只剩Head的词向量。有了组合函数,Head的表示被更新为"Head+Dependent+关系"的组合,后续如果还有词依赖这个Head,网络能"记得"它下面已经挂了谁。
6.3 贪心解码,线性时间复杂度
这个解析器是贪心的------每一步选概率最高的动作,不回头。好处是速度快,O(n)时间复杂度,n是句子长度。坏处是可能陷入局部最优(一步错,步步错)。
后续有工作通过Dynamic Oracle(动态Oracle)来缓解这个问题:训练时不总是跟着标准答案走,而是让解析器探索自己的错误状态,学会从错误中恢复。
6.4 字符级模型的泛化能力
对于形态丰富的语言(一个词有前缀、后缀、词根变化),字符级BiLSTM能捕捉这些模式,比单纯查表更鲁棒。实验表明,字符级模型在英语上提升不大(因为英语形态简单),但在捷克语、阿拉伯语等语言上提升显著。
七、能用在哪
- 句法分析Pipeline:作为NLP系统的第一步,为下游任务(语义角色标注、关系抽取、机器翻译)提供结构特征
- 低资源语言:字符级模型对OOV友好,适合训练数据少的语言
- 教学演示:代码结构清晰,适合学习"基于转移的神经网络解析器"的工作原理
- 研究基线:作为更复杂模型(如Biaffine Parser)的对比基线
八、手把手跑起来
8.1 环境准备
- 操作系统:Linux / macOS(Windows需自行适配)
- 编译器:g++ 5.3.0+(支持C++11)
- 依赖库 :
- Boost
- Eigen3(线性代数库)
- CMake
- Java:用于生成Oracle转移序列
8.2 安装依赖(Ubuntu示例)
bash
sudo apt-get install libboost-all-dev cmake g++ openjdk-8-jre
# Eigen3可以apt装,也可以手动下载
sudo apt-get install libeigen3-dev
8.3 编译
bash
mkdir build
cd build
cmake .. -DEIGEN3_INCLUDE_DIR=/usr/include/eigen3
make -j2
编译完成后,会生成parser/lstm可执行文件。
8.4 准备数据
需要CoNLL格式的数据文件,例如:
training.conll:训练集development.conll:验证集test.conll:测试集
CoNLL格式每行代表一个词,列包括:ID、FORM、LEMMA、CPOSTAG、POSTAG、FEATS、HEAD、DEPREL、PHEAD、PDEPREL。句子之间用空行分隔。
8.5 生成Oracle转移序列
解析器训练需要"标准动作序列",通过Java工具从CoNLL格式的依存树自动生成:
bash
# 生成训练集的Oracle
java -jar ParserOracleArcStdWithSwap.jar -t -1 -l 1 -c training.conll > trainingOracle.txt
# 生成验证集的Oracle
java -jar ParserOracleArcStdWithSwap.jar -t -1 -l 1 -c development.conll > devOracle.txt
参数说明:
-t -1:使用Arc-Standard转移系统-l 1:生成带标签的转移序列-c:输入CoNLL文件
8.6 训练模型
bash
./parser/lstm \
-T trainingOracle.txt \
-d devOracle.txt \
--hidden_dim 100 \
--lstm_input_dim 100 \
-w sskip.100.vectors \
--pretrained_dim 100 \
--rel_dim 20 \
--action_dim 20 \
-t \
-P
参数说明:
| 参数 | 含义 |
|---|---|
-T |
训练Oracle文件 |
-d |
验证Oracle文件 |
--hidden_dim |
隐藏层维度(MLP) |
--lstm_input_dim |
LSTM输入维度 |
-w |
预训练词向量文件 |
--pretrained_dim |
预训练词向量维度 |
--rel_dim |
关系标签嵌入维度 |
--action_dim |
动作嵌入维度 |
-t |
训练模式 |
-P |
输出验证集结果 |
训练过程中,每轮迭代会输出验证集上的LAS/UAS。通常训练到验证集结果不再提升时停止(大约5500轮左右)。
8.7 不用预训练词向量
如果没有预训练词向量,去掉-w选项即可,网络会从头学习词嵌入:
bash
./parser/lstme \
-T trainingOracle.txt \
-d devOracle.txt \
--hidden_dim 100 \
--lstm_input_dim 100 \
--rel_dim 20 \
--action_dim 20 \
-t \
-P
8.8 解析新数据
训练完成后,模型文件会保存在当前目录(文件名类似parser_pos_2_32_100_20_100_12_20-pidXXXX.params)。用这个模型解析新数据:
bash
# 先生成测试集的Oracle(用于评估,实际解析时不需要)
java -jar ParserOracleArcStdWithSwap.jar -t -1 -l 1 -c test.conll > testOracle.txt
# 解析
./parser/lstm \
-T trainingOracle.txt \
-d testOracle.txt \
--hidden_dim 100 \
--lstm_input_dim 100 \
-w sskip.100.vectors \
--pretrained_dim 100 \
--rel_dim 20 \
--action_dim 20 \
-P \
-m parser_pos_2_32_100_20_100_12_20-pidXXXX.params
解析结果会以CoNLL格式输出到标准输出,包含每个词的HEAD和DEPREL预测。
8.9 评估结果
注意:程序输出的结果包含标点符号 。而学术论文通常报告不包含标点 的LAS/UAS。需要用CoNLL-X Shared Task的eval.pl脚本才能得到标准数字:
bash
perl eval.pl -g gold.conll -s system_output.conll
8.10 字符级模型(进阶)
如果需要字符级词表示,切换到对应分支后重新编译,训练时加上字符级相关参数即可。
If you need the complete source code, please add the WeChat number (c17865354792)
九、写在最后
这套方案最大的价值,在于证明了神经网络可以自动学习解析器的状态表示,不需要人工设计复杂的特征模板。
三个Stack LSTM分别编码Buffer、Stack和Action History,组合函数把局部子树"粘"成整体,MLP根据全局状态决定下一步动作------这套架构虽然诞生于2015年,但其中的设计思想(用神经网络编码结构化状态、用组合函数建模层次结构)至今仍在影响后续的句法分析模型。
对于想深入理解"基于转移的神经网络解析器"的人来说,这个项目是绝佳的入门材料。它代码量适中、逻辑清晰、依赖少,而且完全用C++实现,没有Python框架的黑盒封装,每一行你都能看懂它在干什么。
如果你正在寻找一条从"调包做NLP"到"真正理解Parser内部机制"的进阶路径,这篇文章涉及的知识点和技术细节,应该能帮你少走很多弯路。
Welcome to follow WeChat official account【程序猿编码】