ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

句法分析驱动的关系抽取:从依存树路径到关系触发词

句法分析驱动的关系抽取:从依存树路径到关系触发词 简介基于句法分析的命名实体关系抽取程序是一份面向自然语言处理学习者和关系抽取任务开发者的轻量级Python实现。程序围绕“句法树分析—实体识别—关系模板匹配—三元组输出”的主线设计包含数据预处理、依存句法解析、命名实体识别、基于规则的关系抽取等模块可用于从非结构化文本中提取实体及语义关系适用于课程设计、知识图谱构建、信息抽取原型验证等场景。资源包共3个文件全部为.py脚本压缩包仅7KB文件虽少但逻辑清晰核心脚本实现了关系三元组的规则抽取并额外提供从XML格式句法分析结果中提取关系的版本便于对照学习不同输入下的处理思路。目前已有271人学习下载适合用于快速上手NLP关系抽取流程并在此框架上扩展自己的规则或模型。1. 句法分析驱动的命名实体关系抽取先看准边界再看关系做文本挖掘时最常遇到的问题是“张三在苏州工业园区担任算法工程师”这类句子实体有“张三”“苏州工业园区”“算法工程师”关系是“任职于”。只靠正则和词表识别实体能认出人名和地名却很难判断它们之间有没有关系只靠词共现统计又会把同句里的实体全部连在一起得到一堆假关系。一个更可靠的切入点是先对句子做句法分析得到一棵依存树再沿着树上的支配和被支配关系找实体对之间的连接路径。路径越短越说明两个实体被同一个核心谓词直接管辖关系越可信路径上的动词或介词往往就是关系触发词。本文按这条思路写一个可以独立运行的关系抽取程序从预处理、特征提取到命令行打包都给出可复现的步骤适合做自然语言处理落地的工程师参考。2. 从分词到依存句法关系抽取的特征从哪来2.1 句法树比词共现多给了一层“主谓宾”约束词共现的假设是同一句话里同时出现的词可能相关。这个假设在长句里会失效“张三在苏州工业园区担任算法工程师他负责智慧交通项目。”如果只用窗口共现“苏州工业园区”和“智慧交通项目”会被当成强关联实际上它们分别挂在“担任”和“负责”两个不同的事件下。依存句法分析把句子转化为一棵树每个词只有一个支配词head关系由支配方向决定。这样一来“苏州工业园区”是“在”的介词宾语“智慧交通项目”是“负责”的动词宾语它们在树上的共同祖先要回溯到“担任”和“负责”路径明显变长。把路径长度作为过滤条件可以大幅降低假阳性。常见的句法分析工具中spaCy的中文模型在单一环境里最容易跑通它同时输出词性、依存标签和实体边界。LTP和HanLP在中文场景也有很好的表现但对部署环境依赖更重。本文示例代码以spaCy为主因为你拿到一个.zip项目时通常需要自己重新装模型spaCy的命令行下载机制最省事。pip install spacy python -m spacy download zh_core_web_sm模型文件下载后会安装到当前 Python 环境的 site-packages 中。.zip包内不一定自带模型正式工程里一般会把模型版本写进requirements.txt避免上线环境重新下载时版本漂移。2.2 依存关系标签里藏着关系触发词的线索下面这段代码用zh_core_web_sm分析一个中文句子并把每个词的依存关系和支配词打印出来import spacy nlp spacy.load(zh_core_web_sm) text 张三在苏州工业园区担任算法工程师 doc nlp(text) for token in doc: print(f{token.text}\t{token.dep_}\t{token.head.text})典型输出如下张三 nsubj 担任 在 case 工业园区 苏州 compound 工业园区 工业 compound 园区 园区 obl 担任 担任 ROOT 担任 算法 compound 工程师 工程师 obj 担任nsubj表示“张三”是“担任”的主语obj表示“算法工程师”是“担任”的宾语obl表示“苏州工业园区”是“担任”的间接旁语。这意味着“担任”这个谓语直接连接了句子里三个关键实体。关系抽取程序的核心思路就是找到实体对之后去依存句法树上找它们的最近公共祖先如果公共祖先是一个动词或介词那么这个祖先就承担了关系触发词的职责。不同的依存标签对关系候选的重要性不一样通常按下面这张表来建立权重依存标签含义在关系抽取中的价值nsubj名词主语实体往往是关系的起点obj直接宾语实体往往是关系的终点obl非核心旁语可能是位置的实体compound复合词修饰用于判断实体边界case格标记帮助识别介词触发词amod形容词修饰对实体类型有约束这张表不是固定规则而是给你一个“哪些边优先看”的排序依据。compound不会单独成为关系路径但它决定了“苏州”“工业”“园区”是不是要合并成同一个实体这一步要交给命名实体识别模块处理。2.3 命名实体识别先收紧候选集句法分析负责给出结构约束命名实体识别负责缩小候选对的范围。完整的处理顺序是先对句子做分词和实体识别得到实体边界再让同一个句子里的实体两两组对最后去依存树里计算路径。不用把全句的词都拿来组对因为笛卡尔积会让长句候选对爆炸。比如一句 30 个词的句子识别出 4 个实体组对数是 6而不是 435 个全词对。在实际项目里我一般会这样控制候选集实体类型限制只保留PERSON、ORG、GPE、LOC、DATE去掉无意义的数量词。同一实体类型组合人名-人名、地址-地址这类组合默认不抽除非关系规则明确需要。最大实体间隔两个实体之间在句子里的字符距离超过某个阈值时跳过默认 30 个字符。这些限制写成配置项放在程序入口处而不是写死在代码里。.zip项目交付给别人后对方大概率要调整抽取范围集中管理参数能少改很多代码。3. 用 Python 搭最小可跑的抽取管线命令行参数与输出结构3.1 程序入口和配置项设计一个可交付的抽取程序应该做三件事读入原始文本、输出结构化关系、把中间结果留档。下面这个文件结构是常见做法relation_extractor/ main.py extractor.py config.ini requirements.txt README.mdmain.py负责接收命令行参数extractor.py实现实体识别和关系抽取config.ini存放模型路径、窗口大小、路径长度阈值。main.py入口参数需要支持基本输入输出parser.add_argument(--input, requiredTrue, helpinput txt file) parser.add_argument(--output, requiredTrue, helpoutput json file) parser.add_argument(--lang, defaultzh, helplanguage: zh/en) parser.add_argument(--min-score, typefloat, default0.3, helpminimum relation score)min-score是关系置信度的门槛。关系抽取的规则方法不会给出概率但我们可以在路径计算时给一个启发式分数后面会说明。3.2 抽取核心代码实体对与依存路径以下是extractor.py里最关键的函数输入是 spaCy 的Doc对象输出是候选关系列表from collections import defaultdict import spacy def extract_relations(text, nlp, max_path_len4): doc nlp(text) entities [ent for ent in doc.ents if ent.label_ in {PERSON, ORG, GPE, LOC}] relations [] for i in range(len(entities)): for j in range(i 1, len(entities)): e1, e2 entities[i], entities[j] path dependency_path(doc, e1, e2) if not path: continue if len(path) max_path_len: continue trigger find_trigger(path) score path_score(path, trigger) if score 0.3: relations.append({ relation: trigger or unlabeled, subject: e1.text, object: e2.text, subject_type: e1.label_, object_type: e2.label_, path: - .join(path), score: round(score, 4) }) return relations这里的关键不在循环而在dependency_path和find_trigger两个函数。dependency_path要查找两个实体包含的 token 集合中所有 token 分别到根节点的路径然后找到两条路径的交汇点拼接成完整路径。这个过程可以这样实现def token_to_root_path(token): path [] seen set() while token is not None and token not in seen: path.append(token) seen.add(token) token token.head return path def dependency_path(doc, ent1, ent2): left_tokens list(ent1) right_tokens list(ent2) paths1 [token_to_root_path(t) for t in left_tokens] paths2 [token_to_root_path(t) for t in right_tokens] best None for p1 in paths1: for p2 in paths2: set2 set(p2) for node in p1: if node in set2: idx1 p1.index(node) idx2 p2.index(node) path [t.text for t in p1[:idx1 1]] path [t.text for t in p2[idx2 - 1::-1]] if best is None or len(path) len(best): best path break return best这段代码里需要注意token_to_root_path会把根词重复放到末尾所以拼接时要去重。路径里包含的词就是两个实体之间的全部句法连接find_trigger则在路径中查找词性为动词或介词的词def find_trigger(path_tokens_text, pos_tags): for text, pos in zip(path_tokens_text, pos_tags): if pos.startswith(V) or pos in {Prep, ADP}: return text return Nonepos_tags需要由 spaCy 的token.pos_结构传入上面函数里做了简化处理。实际工程中这里会遇到一个常见坑spaCy对中文词性的pos_标的是VERB、ADP这类西文标签而不是中文的v、p所以判断条件建议统一写成token.pos_ VERB or token.pos_ ADP。3.3 命令行调用和 JSON 输出程序完成后命令行执行方式如下python main.py --input news.txt --output relations.json --min-score 0.3输出结构用 JSON 而不是 CSV因为一个实体可能参与多条关系JSON 能保留嵌套结构[ { relation: 担任, subject: 张三, object: 算法工程师, subject_type: PERSON, object_type: TITLE, path: 张三 - 担任 - 算法工程师, score: 1.0 } ]这里的score是一个简易启发式分数路径长度为 1 时分数为 1.0路径每增加一个节点分数乘以 0.7。它不能和神经网络模型输出的概率混为一谈但在规则方法里足以用来排序。4. 关系抽取的触发词与模板规则句法路径怎么用4.1 用路径模板定义关系类型拿到依存路径后不能直接把动词当作关系标签就结束了。实际文本里“张三在苏州工业园区担任算法工程师”和“张三被公司派到苏州工业园区”路径结构完全不同但都包含地点实体。要区分“任职于”“工作地点”和“派遣至”需要使用路径模式匹配。一个可用的做法是准备一张关系模板表每个模板由“依存标签序列 词性序列 触发词词根”构成。这里给出三个常见模板关系类型触发词路径模式示例路径任职于担任nsubj - obj张三 - 担任 - 算法工程师位于位于nsubj - obl公司 - 位于 - 北京毕业于毕业nsubj - obl李四 - 毕业 - 北京大学模板表写成外部文件程序启动时加载不要硬编码在代码里。这样做的好处是新增关系类型时不用重新打包.zip只要改配置文件。模板匹配代码如下def match_relation_template(path, dep_labels, templates): path_key - .join(dep_labels) for template in templates: if path_key template[dep_path]: return template[relation] return None这里的dep_path直接用依存标签列表拼接例如[nsubj, obj]。问题在于不同句子里的中间节点数量可能不同所以更稳妥的做法是只匹配头尾两个标签中间允许任意修饰节点。这要引入通配符但也别把匹配规则做得太复杂否则调试会很难受。4.2 规则方法的边界和过滤策略规则模板方法适合关系类型明确、触发词集中的场景比如招聘文本、公司公告、履历信息。它的劣势是召回率低文本里可能写“张三目前是苏州工业园区的高级算法工程师”路径里没有“担任”这个动词“是”只起到连接作用模板匹配就会落空。这时候需要做一层动词归一化verb_normalization { 是: [担任, 任职], 就职于: [任职], 领导: [负责], }归一化不解决全部问题但能提升少量召回。真正面对开放领域关系时规则方法做不动需要换成序列标注或基于 BERT 的关系分类模型。但在产业项目里规则方法最大的优点是可解释、可快速上线。模型方法需要大量标注数据而且.zip程序里如果没有训练脚本后续迭代会非常被动。路径长度阈值是一个需要反复调的参数。经验值是名词性关系取 2 到 3动词性关系取 3 到 5。超过 5 的路径通常已经跨越了从句边界两个实体虽然在同一句里但实际属于不同事件。你可以把路径长度分布输出到日志里观察正负样本的分布再决定阈值。4.3 句法分析报错时的降级策略中文依存分析对长句、无标点文本容易出错。比如把“苏州工业园区”的“工业”错判为“园区”的修饰语路径会多出一个compound节点。这种情况下前文里的max_path_len4可能会把一条本来可靠的关系误杀。应对策略是允许compound、case这两类标签跳过不参与路径长度计数。修改匹配逻辑时把这两类节点过滤掉FILTERED_DEP {compound, case, punct, aux} def clean_path(tokens, deps): cleaned [] for token, dep in zip(tokens, deps): if dep not in FILTERED_DEP: cleaned.append(token) return cleaned这样过滤之后“苏州工业园区”的内部结构就不会干扰路径长度。降级策略应当在代码注释里写清楚因为后续维护者看到路径里少了节点容易误以为是 bug。5. 评估召回目标、分包与句法缓存的三个实战技巧关系抽取程序交付前必须做一次小规模标注评估否则你根本不知道路径长度阈值设得合不合理。通用的方法是准备一个 200 句的验证集手工标注出实体对和关系类型再运行程序对比。评估指标用精确率、召回率、F1 值。python evaluate.py --gold gold.json --pred relations.json评估结果通常会发现精确率高、召回率低。这时不要急着放宽路径阈值而是先看漏检句子是不是集中在某类句法结构上。如果漏检句子大多是疑问句或被动词态可以直接在预处理阶段把这些句子过滤因为业务上并不需要抽取疑问句中的关系。这个操作比调阈值更明确也更符合信息抽取的工程习惯。程序最终打包.zip时有两点容易踩坑。第一点是模型文件不要打包进压缩包除非你确认接收方无法访问外网。spaCy的模型可以单独下载把模型的版本号写进requirements.txt比携带一两百兆的二进制文件更稳妥。第二点是打包前删掉__pycache__和本地 log 文件很多工程师解压后第一件事就是搜源码里有没有埋点。句法分析是性能瓶颈。一篇文章几千字逐句调用nlp(text)会非常慢。常见做法是把实体识别和依存分析的结果缓存成中间格式增量文本只在新增部分跑分析。实现时可以用一个简单的 pickle 缓存import pickle def analyze_cached(text, cache_pathdep_cache.pkl): try: with open(cache_path, rb) as f: cache pickle.load(f) except FileNotFoundError: cache {} if text in cache: return cache[text] doc nlp(text) cache[text] { tokens: [token.text for token in doc], deps: [token.dep_ for token in doc], heads: [token.head.i for token in doc], } with open(cache_path, wb) as f: pickle.dump(cache, f) return cache[text]缓存键用原始文本缺点是文本稍加改动就无法命中。更合理的方式是取句子指纹比如hashlib.md5(text.encode()).hexdigest()这样即使增加标点也能快速跳过未变化的句子。注意 pickle 只适合单机小规模场景分布式批处理时要换成数据库缓存。句法分析不是零错误的技术spaCy的中文模型对口语化表达、错误标点都容易给出错误的依存标签。所以程序里一定要保留原始文本、分词结果、依赖树结构三份留档一旦关系抽取结果异常就能回溯是语法分析错还是规则匹配错。这一步看起来不影响功能但在实际项目里能节省大量排查时间。本文还有配套的精品资源点击获取
返回列表