前一阵子在做一个语法语义辅助标注的小工具,遇到一个特别经典的问题:句子结构看起来没问题,但就是“不像人话”。比如英文的these dogs run成立,these dog run每个词单独看都没错,放到一起就违反了一致关系;中文里“一本书”很顺,“一本猫”就是错的。这些东西正则表达式处理不了,词性标注也管不着,真正管用的,是自然语言处理里基于特征的文法(Feature-based Grammar)这套思路。今天这篇正好对应教材第 2 版系列的第 9 部分,我打算用 Python 和 NLTK 把特征结构、统一运算和带特征的句法分析从头过一遍,结合自己做项目时踩过的坑,给你一份可以直接抄走用的实操笔记。
这篇文章适合已经会做分词、词性标注和基础句法分析,但不满足于只拿到一棵标准语法树的读者。如果你在写对话系统、知识抽取管道,或者做“小而规则”的语法校验工具,特征文法是性价比很高的一层约束方案。它不依赖几十万条的标注数据,主要靠一套可读性很强的语言规则就把结构约束写清楚,这在垂直领域里非常实用。
1. 为什么句法分析需要特征机制
1.1 上下文无关语法的天花板
上下文无关语法(Context-Free Grammar,CFG)是句法分析入门必学的工具。一套 CFG 规则通过S -> NP VP、NP -> Det N、VP -> V NP这种重写方式,能描述大量句子的骨架。可是骨架归骨架,它本身不携带词与词之间的约束信息。
我最早做英文句子分析时,只靠 CFG 配合词库,结果this dogs run和these dog runs都能被解析出来,而且各自都有一棵看起来挺像样的树。原因很直白:CFG 的规则左端和右端都是非终结符/终结符,规则之间不能互相“通气”,没法表达“主语是复数,动词也得是复数”这种跨成分的一致性约束。
有人会说,那我多写几条规则不就得了:NP_pl -> Det_pl N_pl、VP_pl -> V_pl,再把*_sg和*_pl全铺开。这个思路在小规模玩具语法里能撑住,一旦语法覆盖面变大,词类和语法功能组合一多,规则数量会成倍膨胀。比如还要考虑人称、格、性、量词类别、时态特征交互,组合爆炸是必然的。一句话总结:CFG 描述的是“形”,特征机制描述的才是“神”。
1.2 特征和特征结构:给语法符号贴上属性标签
要解决上面的问题,最自然的想法是给每个语法成分附上一组属性。这些属性就是特征(feature),通常写成“属性名 = 属性值”的形式。比如:
NUM=sg表示单数NUM=pl表示复数AGR=[NUM=sg, PER=3]表示一致关系信息是一组嵌套属性
一组有序的特征放在一起,就是特征结构(Feature Structure)。用程序打比方,它很像一个字典对象或 JSON 块;用语法打比方,它像一个贴在树节点上的属性卡片。卡片可以很小,只有一个词性;也可以很大,包含格、人称、数、时态、语义角色等一堆信息。
特征结构最大的价值是共享与约束。同一个变量可以在句法树的不同位置出现,通过统一运算把两边信息合并,这样“主语单数”就自动绑定到“谓语动词单数”上。这也是为什么教材第 2 版在这个章节要专门引入统一(unification)这个运算——它是整个特征文法的发动机。
2. 统一运算:特征文法的心脏
2.1 先把特征结构写出来
NLTK 里直接用nltk.FeatStruct就能构造特征结构。我用它调试语法规则时,通常先手写一个小样例确认特征路径没写错,再贴到完整语法里,这个习惯帮我省了不少排查时间。
import nltk fs = nltk.FeatStruct("[NUM='sg', AGR=[NUM='sg', PER=3]]") print(fs) print(fs['AGR']) print(fs['AGR']['NUM'])打印结果大概是:
[ NUM='sg' ] [ AGR=[ NUM='sg' ] ] [ [ PER=3 ] ]这里AGR是一个嵌套特征结构,内部又有自己的NUM和PER属性。用方括号加字符串的方式比一层层写 Python 字典要清爽得多,也更能对应理论书里的属性-值矩阵(AVM)写法。
2.2 统一不是“合并”,而是“兼容性合并”
统一运算用unify方法完成。它的语义可以类比成两个人填同一张信息卡:如果两张卡上的信息不冲突,就合并成一张更完整的卡;如果某一位的取值矛盾,合并直接失败。
看一个成功案例:
fs1 = nltk.FeatStruct("[NUM='sg', PER=3]") fs2 = nltk.FeatStruct("[PER=3, GND='masc']") fs3 = fs1.unify(fs2) print(fs3)fs1提供了NUM=sg和PER=3,fs2提供了PER=3和GND=masc。两者都认为PER=3,没有冲突,所以结果是一张完整卡片:
[ NUM='sg' ] [ PER=3 ] [ GND='masc']再看一个失败案例:
fs1 = nltk.FeatStruct("[NUM='sg']") fs2 = nltk.FeatStruct("[NUM='pl']") print(fs1.unify(fs2)) # 输出 NoneNUM一个是单数、一个是复数,这不叫补充,这叫矛盾。unify返回None,意味着两个特征结构不能统一。这个None是整个系统的“否决权”:只要句法树某处需要共享的特征对不上,解析就失败。
提示:
unify默认返回新的特征结构,不会修改原来的对象。第一次用的时候我直接fs1.unify(fs2)然后打印fs1,发现半点变化没有,还以为是 BUG,其实是自己没接返回值。想覆盖就写fs1 = fs1.unify(fs2)。
2.3 变量:让规则表达“这里和那里必须一致”
光靠字面常量写规则,还是要枚举。特征文法真正聪明的地方,是允许在规则里声明变量。NLTK 的表示法是在变量名前加?,比如?n,写法上很像 Prolog。
规则S[NUM=?n] -> NP[NUM=?n] VP[NUM=?n]的意思是:句子节点的主语短语和谓语短语,两者的NUM值必须统一成一个共同的?n。如果NP是复数,VP也必须被约束成复数;如果VP没有填NUM,它可以吸收NP的值,等于从主语那里继承了数特征。
我习惯把?n想象成一根“数据线”,规则左端和右端通过同一根线连起来。这根线连接的特征越多,约束越强;没有连接的地方,语法反而更灵活。这种松弛度正好对应真实语言:有些一致性是强制的,有些信息只是从一个成分流向另一个成分,不需要双方啰嗦地重复一遍。
3. 动手实现一个带特征的小型解析器
3.1 英文主谓一致:最直观的验证案例
我用 NLTK 的FeatureGrammar加FeatureEarleyChartParser搭一个最小可运行的系统。这个例子的价值不在于能解析多复杂的句子,而在于能证明“把规则写对之后,错误句子真的会被拒掉”。
import nltk feature_grammar_text = """ % start S S[NUM=?n] -> NP[NUM=?n] VP[NUM=?n] NP[NUM=?n] -> Det[NUM=?n] N[NUM=?n] VP[NUM=?n] -> V[NUM=?n] Det[NUM=sg] -> 'this' Det[NUM=pl] -> 'these' N[NUM=sg] -> 'dog' N[NUM=pl] -> 'dogs' V[NUM=sg] -> 'runs' V[NUM=pl] -> 'run' """ feature_grammar = nltk.grammar.FeatureGrammar.fromstring(feature_grammar_text) parser = nltk.parse.FeatureEarleyChartParser(feature_grammar) sentences = [ "this dog runs", "these dogs run", "this dogs run", "these dog runs", "this dog run", ] for sent in sentences: tokens = sent.split() trees = list(parser.parse(tokens)) print(f"{sent}: {len(trees)} 棵分析树")运行结果是:
this dog runs: 1 棵分析树 these dogs run: 1 棵分析树 this dogs run: 0 棵分析树 these dog runs: 0 棵分析树 this dog run: 0 棵分析树看到0 棵分析树,很多初学会觉得“是不是语法写错了”?其实这正是特征一致性的作用。this dogs run里,this声明自己是NUM=sg,dogs声明自己是NUM=pl,在NP[NUM=?n] -> Det[NUM=?n] N[NUM=?n]这一步就统一失败了,树都长不出来,自然不会进入后面的动词约束环节。
这类例子特别适合做集成测试。我把语法和测试句子都写进同一个脚本,每次改完特征规则就跑一遍,保证没有“误伤”正常句子。
3.2 中文量词约束:从英文复数的坑里跳出来
中文没有明显的单复数动词变形,但中文有量词系统。“一本”配“书”,“一只”配“猫”,“一棵”配“树”,这种搭配就是典型的特征约束场景。我用类似方法做过一个很小的中文量词语法 Demo,效果很直观。
import nltk cn_grammar_text = """ % start S S -> NP[CLS=?c] NP[CLS=?c] -> Num[CLS=?c] Cl[CLS=?c] N[CLS=?c] Num[CLS=?c] -> '一' | '两' Cl[CLS='本'] -> '本' Cl[CLS='只'] -> '只' N[CLS='本'] -> '书' N[CLS='只'] -> '猫' """ cn_grammar = nltk.grammar.FeatureGrammar.fromstring(cn_grammar_text) cn_parser = nltk.parse.FeatureEarleyChartParser(cn_grammar) for words in ["一 本 书", "两 只 猫", "一 本 猫", "两 只 书"]: trees = list(cn_parser.parse(words.split())) print(f"{words}: {len(trees)} 棵分析树")输出:
一 本 书: 1 棵分析树 两 只 猫: 1 棵分析树 一 本 猫: 0 棵分析树 两 只 书: 0 棵分析树这里的关键是同一个?c在Num、Cl、N三者之间传递。本约束CLS=本,书也声明CLS=本,两边统一成功;“本”配上“猫”时,Cl[CLS=本]和N[CLS=只]冲突,统一直接失败。
做项目时我并不指望这套小规则覆盖全部真实语料,但它证明了一个重要思路:量词、介词、格标记这类封闭词类,非常适合作特征约束。与其在规则库里堆砌几千条语料驱动的搭配统计,不如先用这种可解释的约束兜底。
3.3 解析器选择:Earley 为什么更顺手
NLTK 里有好几个能配特征语法的解析器,比如FeatureChartParser、FeatureEarleyChartParser。我自己最常用FeatureEarleyChartParser。
Earley 算法本身对上下文无关语法有很好的兼容性,加上特征后也能保持相对稳定的解析表现。更重要的是,它对规则顺序和语法格式的敏感度低一些,逻辑比较直观。写复杂规则时,我不会因为某条规则的调用顺序而大量调整代码,这在调试阶段非常舒服。
代码调用的写法也比较直接:
parser = nltk.parse.FeatureEarleyChartParser(feature_grammar, trace=0)trace参数调大可以看到内部解析过程,我在排查“某一句为什么没分析出来”时会临时把trace=2,观察特征统一在哪个非终结符上失败,比盲猜快得多。
4. 特征语法实战中的坑与排查技巧
4.1 变量名、路径、值三件小事
特征文法代码经常出问题,但绝大多数都不是算法问题,而是写法细节。我把最常踩的几个点列成了一张速查表。
| 典型错误 | 表现 | 排查方向 |
|---|---|---|
?n和?N混用 | 跨规则约束突然失效 | 检查变量名是否严格一致,大小写敏感 |
| 值没加引号 | 字符串被识别成特征路径 | 常量和变量分清:NUM=sg通常应该写成NUM='sg' |
unify结果没接住 | 打印原对象看似没变化 | 用新变量接收返回值 |
| 特征路径写错层级 | 取不到想要的属性 | 先单独打印FeatStruct看结构层级 |
| 词法规则遗漏特征 | 语法树数量异常偏多 | 检查每个词条是不是漏标了[NUM=...]这类约束 |
FeatStruct本质是字典和列表的嵌套结构,打印它永远是我排查路径问题的第一动作。别急着改规则,先确认自己“走进”的是不是预期房间。
4.2 None 不等于“没有结果”,也可能是“统一失败”
初用统一运算时,我对None的理解太简单,以为只要输出None就是“代码写错了”。其实在解析过程里,None是一个非常正常的中间状态:某两个特征不兼容,这条分支剪掉。
这就要求写工具时把业务流程分开:先跑语法解析,拿到空结果时再决定是“提示用户输入不合法”还是“换个宽松语法重新解析”。不要把None直接当成异常抛出,否则系统会非常脆弱。
trees = list(parser.parse(tokens)) if not trees: print("无法解析:可能存在不一致的特征约束") else: for tree in trees: print(tree)这样做的好处是,让语法规则成为产品里的“软约束”还是“硬约束”变得可配置。想严格的时候直接过滤,想宽松的时候可以把失败句子交给统计模型兜底。
4.3 训出“巨复杂”语法后性能反而变差
特征文法的表达能力一强,有人会忍不住把所有信息都塞进特征里,比如把语义角色、时态、语气、情感标签全堆在同一个节点上。结果语法看起来挺完整,解析速度慢得让人崩溃。
我的习惯是:分词和词性标注仍交给统计模型;特征语法只负责需要强一致性和可解释性的那部分,比如主谓一致、量词搭配、介词短语的约束、以及领域语义槽位。简单说,特征语法做“裁判”,不做“生产工人”。大语言模型出现后,很多人误解这类符号方法过时了,但在垂直领域里,符号约束依然是保证输出可靠的那道保险。
另外,如果性能确实捉急,优先检查是否有大量无意义的递归规则,比如NP -> NP PP和PP -> P NP成对出现。这类规则在特征不充分时会产生很多棵几乎一样的冗余分析树。适当限制每类短语的嵌套深度,或者给递归规则加上特征条件,能在不损失准确率的情况下大幅提速。
4.4 调试时的三个实战技巧
第一个技巧是用“正反例测试集”。我给语法 Demo 建了一个很小的测试文件,里面既有合法句子也有非法句子。每次调整语法都跑一遍,一旦出现“合法句被拒”或“非法句被接受”,立刻能定位到具体是哪条规则、哪个特征出的问题。
第二个技巧是善用trace。解析器加上trace=1或trace=2之后,控制台会打印出分析树和统一的过程,能清楚看到?c或?n在哪个位置被绑定成什么值。这个信息比在脑子里推演规则高效得多。
第三个技巧是拆语法。不要一开始就写一个非常庞大的FeatureGrammar。先把核心结构做出来,比如S -> NP VP,跑通后再逐步加入PP、AdjP、复杂特征路径。每加一层就跑一次测试,避免改动范围太大导致“哪里错了都不知道”。
写在最后的一点体会
自己动手实现特征语法的那段时间,最大的感受是:这一类方法不追求“让机器学会所有句子”,而是追求“让机器在关键约束面前不犯低级错误”。很多工程团队迷信模型规模,却忽略了在数据稀疏或领域敏感的地方,手工规则的可控性依然是不可替代的。特征结构像是语法世界里的单元测试,统一运算则是断言,每次句法分析都自动跑一遍,跑不过就是输入真的有问题。
如果你也想练手,可以从今天这个加量词的语法开始,把它扩展成一个简单的问答解析器:把名词短语的特征标记上语义类别,再让动词短语通过变量去绑定论元角色,很快就能体会到特征文法在语义槽位抽取上的潜力。这一段用 Python 和 NLTK 写起来并不复杂,但背后的设计思想,足够你在真实项目里反复使用。