扔掉特征工程!我用三个LSTM“栈“写了一个依存句法分析器,句子结构一眼看穿

一、先说说为什么这事值得折腾

做自然语言处理的人,迟早会遇到一个头疼的问题:怎么让机器理解句子的语法结构?

比如这句话:"我喜欢自然语言处理。" 人一眼就能看出来------"喜欢"是核心动词,"我"是它的主语,"自然语言处理"是它的宾语。但机器呢?它看到的只是一串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;
}

这个组合表示有两个作用:

  1. 替代原来的Head,成为Stack上的新元素
  2. 编码了"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系统下)。

训练过程就是监督学习

  1. 把句子丢进解析器
  2. 解析器预测一个动作
  3. 和Oracle的标准动作对比
  4. 如果预测错了,反向传播更新参数
  5. 执行标准动作,进入下一个状态,重复

损失函数是交叉熵

复制代码
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【程序猿编码

相关推荐
水如烟2 小时前
孤能子视角:华夏“科学“回望·篇外之说明书与呼吸节律——六步框架在中西理论创建史中的两种显影
人工智能
晴天162 小时前
LLM 与世界模型:从“会说话“到“会理解世界“-Day27
人工智能·机器学习
练习时长两年半的RL练习生2 小时前
与AI对话后对 Actor-Critic 中 TD Target 的 a‘ 来源总结
开发语言·人工智能·php
liulilittle3 小时前
长上下文的成本结构与「甜点区间」——从推理引擎的物理约束看 200K/256K/400K
c++·人工智能·ai·llm·注意力·qkv
AIsoft_86883 小时前
可以把会议内容整理成摘要的APP推荐:AI总结功能实测
人工智能
Eloudy3 小时前
全文 - OpenROAD README.md
人工智能·ic agent·ai eda agent
HiDev_3 小时前
【非标自动化】2、认识元器件(液压泵和液压阀)
大数据·人工智能·自动化
这张生成的图像能检测吗3 小时前
即插即用模块 + 改进思路汇总目录
图像处理·人工智能·深度学习·目标检测·机器学习
Geek-Chow3 小时前
12 开源 vs 闭源:同一份权重,两种交付
人工智能·llm·大语言模型