扔掉特征工程!我用三个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是PushLEFT/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→h4Stack 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系统动作效果前提条件SHIFTBuffer顶部元素移入StackBuffer非空LEFT-ARC®Stack顶部两个元素s1, s0建立s1→s0的弧标签r弹出s1Stack至少有2个元素s1不是ROOTRIGHT-ARC®Stack顶部两个元素s1, s0建立s0→s1的弧标签r弹出s0Stack至少有2个元素对于n个词的句子恰好需要2n次动作n次SHIFT n次ARC就能构造出一棵合法的依存树。4.3 Stack LSTM支持Push/Pop的序列编码器Stack LSTM的核心创新在于它不仅维护当前LSTM状态还维护一个状态栈。// 伪代码Stack LSTM的核心操作classStackLSTM{std::vectorLSTMStatestate_stack;// 状态栈每个元素是一个完整LSTM状态public:// Push和普通LSTM一样读入新输入更新状态voidpush(Vector input){LSTMState new_state;if(state_stack.empty()){new_statelstm_initial_state(input);}else{new_statelstm_step(state_stack.back(),input);}state_stack.push_back(new_state);}// Pop弹出栈顶状态回退Vectorpop(){Vector top_embeddingstate_stack.back().hidden;state_stack.pop_back();returntop_embedding;}// 获取当前栈顶状态用于编码整个Stack的内容Vectorget_top_state(){returnstate_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需要被组合成一个新的表示代表一棵局部子树。// 伪代码组合函数Vectorcompose(Vector head,Vector dependent,Vector relation){// 拼接 head dependent relation_labelVector concatenatedconcatenate(head,dependent,relation);// 线性变换 tanh非线性Vector composedtanh(W*concatenatedb);returncomposed;}这个组合表示有两个作用替代原来的Head成为Stack上的新元素编码了Head带着一个Dependent的完整信息后续如果还有词依赖这个Head组合表示能记住已经有谁挂在它下面了实验表明有组合函数的版本比没有组合函数的版本LAS带标签依存准确率高1-2个百分点。这说明组合函数确实帮助网络记住了子树的结构信息。4.5 解析器状态表示三步拼接每一步决策前解析器需要把当前状态编码成一个固定维度的向量p_t ReLU(W · [s_t; b_t; a_t] d)其中s_tStack的Stack LSTM编码栈顶状态b_tBuffer的Stack LSTM编码栈顶状态即下一个待处理词a_tAction 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给定依存树自动生成的标准动作序列LASLabeled Attachment Score带标签依存准确率UASUnlabeled Attachment Score不带标签依存准确率SHIFT转移动作Buffer→StackLEFT-ARC转移动作Stack顶部建立左弧弹出左元素RIGHT-ARC转移动作Stack顶部建立右弧弹出右元素SWAP转移动作交换Stack顶部两个元素用于非投影树CoNLL格式依存句法分析的标准数据格式每行一个词含ID、FORM、LEMMA、POS、HEAD、DEPREL等字段预训练词向量在大语料上预训练的词嵌入如Word2Vec、skip-gramOOVOut-Of-Vocabulary训练时没见过的词六、设计思路与工程亮点6.1 三个Stack LSTM各司其职Stack LSTM编码对象作用Buffer LSTM待处理词序列知道后面还有什么词Stack LSTM部分构建的依存树知道已经拼出了哪些子树Action LSTM历史动作序列知道之前干了什么这三个编码拼接起来解析器对当前局面的理解是全局的——不是只看栈顶两三个词而是看整个Buffer、整个Stack、整个历史。6.2 组合函数让子树有记忆没有组合函数时建立依存弧后Dependent的信息就丢了Stack上只剩Head的词向量。有了组合函数Head的表示被更新为HeadDependent关系的组合后续如果还有词依赖这个Head网络能记得它下面已经挂了谁。6.3 贪心解码线性时间复杂度这个解析器是贪心的——每一步选概率最高的动作不回头。好处是速度快O(n)时间复杂度n是句子长度。坏处是可能陷入局部最优一步错步步错。后续有工作通过Dynamic Oracle动态Oracle来缓解这个问题训练时不总是跟着标准答案走而是让解析器探索自己的错误状态学会从错误中恢复。6.4 字符级模型的泛化能力对于形态丰富的语言一个词有前缀、后缀、词根变化字符级BiLSTM能捕捉这些模式比单纯查表更鲁棒。实验表明字符级模型在英语上提升不大因为英语形态简单但在捷克语、阿拉伯语等语言上提升显著。七、能用在哪句法分析Pipeline作为NLP系统的第一步为下游任务语义角色标注、关系抽取、机器翻译提供结构特征低资源语言字符级模型对OOV友好适合训练数据少的语言教学演示代码结构清晰适合学习基于转移的神经网络解析器的工作原理研究基线作为更复杂模型如Biaffine Parser的对比基线八、手把手跑起来8.1 环境准备操作系统Linux / macOSWindows需自行适配编译器g 5.3.0支持C11依赖库BoostEigen3线性代数库CMakeJava用于生成Oracle转移序列8.2 安装依赖Ubuntu示例sudoapt-getinstalllibboost-all-dev cmake g openjdk-8-jre# Eigen3可以apt装也可以手动下载sudoapt-getinstalllibeigen3-dev8.3 编译mkdirbuildcdbuild cmake..-DEIGEN3_INCLUDE_DIR/usr/include/eigen3make-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格式的依存树自动生成# 生成训练集的Oraclejava-jarParserOracleArcStdWithSwap.jar-t-1-l1-ctraining.conlltrainingOracle.txt# 生成验证集的Oraclejava-jarParserOracleArcStdWithSwap.jar-t-1-l1-cdevelopment.conlldevOracle.txt参数说明-t -1使用Arc-Standard转移系统-l 1生成带标签的转移序列-c输入CoNLL文件8.6 训练模型./parser/lstm\-TtrainingOracle.txt\-ddevOracle.txt\--hidden_dim100\--lstm_input_dim100\-wsskip.100.vectors\--pretrained_dim100\--rel_dim20\--action_dim20\-t\-P参数说明参数含义-T训练Oracle文件-d验证Oracle文件--hidden_dim隐藏层维度MLP--lstm_input_dimLSTM输入维度-w预训练词向量文件--pretrained_dim预训练词向量维度--rel_dim关系标签嵌入维度--action_dim动作嵌入维度-t训练模式-P输出验证集结果训练过程中每轮迭代会输出验证集上的LAS/UAS。通常训练到验证集结果不再提升时停止大约5500轮左右。8.7 不用预训练词向量如果没有预训练词向量去掉-w选项即可网络会从头学习词嵌入./parser/lstme\-TtrainingOracle.txt\-ddevOracle.txt\--hidden_dim100\--lstm_input_dim100\--rel_dim20\--action_dim20\-t\-P8.8 解析新数据训练完成后模型文件会保存在当前目录文件名类似parser_pos_2_32_100_20_100_12_20-pidXXXX.params。用这个模型解析新数据# 先生成测试集的Oracle用于评估实际解析时不需要java-jarParserOracleArcStdWithSwap.jar-t-1-l1-ctest.conlltestOracle.txt# 解析./parser/lstm\-TtrainingOracle.txt\-dtestOracle.txt\--hidden_dim100\--lstm_input_dim100\-wsskip.100.vectors\--pretrained_dim100\--rel_dim20\--action_dim20\-P\-mparser_pos_2_32_100_20_100_12_20-pidXXXX.params解析结果会以CoNLL格式输出到标准输出包含每个词的HEAD和DEPREL预测。8.9 评估结果注意程序输出的结果包含标点符号。而学术论文通常报告不包含标点的LAS/UAS。需要用CoNLL-X Shared Task的eval.pl脚本才能得到标准数字perl eval.pl-ggold.conll-ssystem_output.conll8.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【程序猿编码】