acdh-spacytei:打通TEI XML与spaCy的NLP对齐方案
做数字人文尤其是处理TEI编码的历史文献时我长期被一个问题折腾一边是结构严谨的XML树一边是重视语法依赖的spaCy流水线两者怎么安全地对接以前我通常会先写一堆正则或者手动用ElementTree抽取正文喂给spaCy等结果出来后再想方设法把模型预测的实体、词性映射回原文中的TEI标签一来二去不仅代码丑还容易出现错位。后来接触到了acdh-spacytei这个Python包它能直接在TEI XML与spaCy的Doc对象之间搭一座桥让我在保留注释结构的情况下完成分词、词性标注、命名实体识别等常见NLP任务。这篇文章我会把它的语法、关键参数和实际落地案例完整梳理一遍供同样在做文本挖掘或数字人文项目的朋友参考。1. 为什么需要这个包TEI文本与NLP之间的错位1.1 TEI文本处理的老大难问题TEIText Encoding Initiative是人文文本数字化最常用的标准它把稿本、版式、人名、地名、日期、页边注等全部编码成标签。但NLP模型根本不认识这些东西spaCy期望的是纯文本字符串它把句子拆成token再跑管道组件。很多项目的做法是先把TEI中的正文抽出来丢失了全部结构。等识别完实体想再知道某个实体属于哪一页、哪一封信、哪一段落就得重新通过偏移量回找非常痛苦。我见过有的项目干脆让开发人员手写正则提取p和persName一旦遇到嵌套跨标签就会出错。标签不对齐的问题几乎无法避免比如人名中间加了个注释正文文本和标签的offset就会全部错位。如果你在真实语料上跑过一定知道我在说什么每一条lb/换行、每一个边缘注释、每一个在句子中间插入的note都有可能让粗放的字符串截断面目全非。数字人文项目要求可重复、可验证你不可能拿着那种临时拼凑的解析脚本去发论文。这也是为什么我一直觉得TEI和NLP之间值得有一个专门做“对齐”的中间层。1.2 acdh-spacytei的解决思路这个包的核心思路是把spaCy处理后的结果仍然当作一棵带着注释的树来管理而不是扁平化。它先按照TEI结构定位正文文本节点然后生成可以送给spaCy的纯文本同时记录纯文本中每个token原本对应TEI中的哪个标签和哪个属性。这样NLP是在纯文本上做但输出可以带着TEI上下文一起返回。换句话说spaCy管的是“句子里的词”acdh-spacytei管的是“这些词在TEI里是哪些区域”。这正好满足数字人文研究里“既要标引语义也要保留版本和结构信息”的需求。有人会问这不就是先抽文本再记录偏移量么区别在于手工操作常常只记录一个粗略的位置比如“这个实体在第3段”。而这个包会沿着TEI树路径做映射能告诉你这个实体出现在哪个div、哪个ab、甚至哪个persName内部。当你需要把模型预测结果重新写回XML或者按结构切片分析时这个能力非常关键。1.3 谁适合用它如果你在做的项目有下面几个特征这个包会很值得尝试数据量不大但文本结构复杂语料是TEI XML编码的人物信件、历史报刊、典籍点校你要用spaCy做分析但最终成果需要保留原始版面信息或者你要构建从XML到数据库的自动标引流程。反之如果你手里的文本只是普通txt或者简单的HTML段落那完全没有必要引入这个包直接用spaCy就够了。这个包是“术业有专攻”的典型代表它在TEI这个特定上下文里才发挥最大价值。2. 核心语法速览三步完成TEI到NLP2.1 安装与环境准备我用的是虚拟环境Python版本3.9到3.11都可以跑。安装命令很简单pip install acdh-spacytei安装会同时拉取lxml和spacy等依赖所以如果网络比较慢建议先装好spaCy和对应模型再装这个包。装完以后记得先下载一个spaCy模型。比如要处理德文历史文献用德文的小模型就够了python -m spacy download de_core_news_sm没有模型时初始化通常会直接抛OSError告诉你找不到模型。这一点在旧版本里特别常见新版本虽然会把报错藏在日志里但排查起来更麻烦还是提前装好模型最省事。2.2 创建处理对象的核心语法核心对象我习惯叫Spacytei。常见用法如下from acdh_spacytei import Spacytei from pathlib import Path tei_path Path(letters/letter_001.xml) st Spacytei(tei_path) doc st.process()这段代码做了什么首先生成一个解析器对象它会读取XML、提取正文、调用指定的spaCy模型。process方法返回的是一个spaCy的Doc对象。这里需要说明一下不同小版本对process的命名可能不一样。最早我用的一个版本方法名叫parse后来又改成了process也有版本要求直接在构造时传入model参数st Spacytei(tei_path, modelde_core_news_sm) doc st.process()建议安装后先用help(Spacytei)确认当前版本的签名避免照着网上旧示例写结果跑不通。这类小细节在开源包里很常见不改文档不代表没改接口。2.3 从Doc对象拿回TEI注释处理完拿到的Doc本身就是标准的spaCy Doc可以直接访问token也可以做doc.ents、doc.sents等操作。同时它往往还附带一个类似doc._.tei_ids的扩展属性用来记录每个token在TEI中的元素路径。有的版本也会把TEI标签放进token._.tei_tag。例如for token in doc[:20]: print(token.text, token._.tei_tag)这样可以随时检查某个词是从哪个标签里出来的。若没有扩展属性可以在初始化时打开keep_attributes之类的参数让它保留标签属性。这里我想多说一句这些扩展属性的名称在不同版本里确实变过早期版本甚至不是下划线版本而是普通的token.tag_导致很容易和spaCy自己的词性标签搞混。所以跑批量任务之前一定先在一个文件上检查一下扩展属性名是否存在。3. 关键参数详解从初始化到输出3.1 初始化阶段的关键参数常见的构造参数有几个file_pathTEI文件路径或文件对象。modelspaCy模型名字或已加载的Language对象默认为英文小模型处理中文或德文需要显式指定。tag_map一个字典告诉包哪些TEI标签需要被当作“有用注释”保留。例如{persName: PERSON}。strip_ns布尔值是否去掉XML命名空间前缀。remove_facsimile布尔值是否剔除facsimile相关节点。为什么需要tag_map因为不是所有TEI标签都应该进入后续处理。有些标签是排版层面的比如lb/、pb/有些语义标签才和自然语言处理相关比如persName、placeName、date。通过映射可以减少不必要的干扰并让实体类别和TEI标签建立对应。我通常会这样设计tag_maptag_map { persName: PERSON, placeName: LOC, date: DATE, orgName: ORG, rs: MISC, }注意这里键是TEI标签名值是我们希望spaCy实体类型显示出来的标签。它不会改变模型的预测结果更多是让输出结果更易于按TEI语义归类。3.2 文本提取与控制参数TEI文件的结构多种多样有的正文直接放在bodydivp里有的被包裹在teiHeader后面。包一般会提供text_tag或xpath参数用来指定正文根节点。比如st Spacytei( file_pathletters.xml, modelde_core_news_sm, xpath//tei:body, namespaces{tei: http://www.tei-c.org/ns/1.0}, )如果你不指定包会尝试自动找到body但如果文档用了很特殊的结构自动判断可能失效。这一点我在真实语料上踩过坑我处理过一份包含多个body的复合文档结果包把所有正文拼一起了后来明确指定xpath才解决。还要特别注意命名空间。很多TEI文档根节点自带namespace前缀直接用//body会匹配不到需要带namespace。早期版本用默认配置无法正确匹配必须传递一个命名空间字典或者开启strip_nsTrue。这两个方案各有利弊strip_ns会把标签名里的前缀去掉处理起来方便但如果你后面要把结果写回原始文档反而需要额外维护映射关系。3.3 输出结构与序列化参数因为NLP之后往往还要写回XML或者入库所以输出参数也很重要。常见的输出相关参数有return_annotated_xml设为True时在Doc处理完成后返回一个重新组装好的TEI XML树里面可能额外嵌入NLP标注。merge_entities是否把命名实体对应的token合并为span。如果你需要doc.ents正确显示实体建议开启。keep_inline_tags保留原文中的行内标签例如hi renditalic用于分析斜体片段的语义。tokenizer_options传递给spaCy tokenizer的选项。custom_extensions允许你自定义要在Doc上挂载的扩展属性名。有一个容易忽略的点merge_entities如果不打开即使模型识别出了实体doc.ents也可能返回空因为包默认采用“保守合并”策略怕合并后破坏与原始TEI标签的对应关系。所以如果你只关心实体列表不关心实体内部细节就打开它如果你还需要精细分析实体内部词的依存关系就不要开。4. 实际应用案例从历史书信集到人物关系网络4.1 案例数据准备我用一份模拟项目X来演示一批18世纪书信的TEI文本每封信包含persName、placeName、date、rs等标签。正文除段落外还有页边发行日期。我们希望通过spaCy的实体识别和依存分析提取“谁在何时何地给谁写信”的关系。因为要做网络分析目标是把每封信中出现的寄信人、收信人、提到的其他人物全部抽出来再统计共现关系。数据目录结构大致是letters/ letter_001.xml letter_002.xml ...每个文件的TEI结构简化后如下TEI xmlnshttp://www.tei-c.org/ns/1.0 teiHeader.../teiHeader text body div typeletter lb/ opener date1770-08-14/date placeName某城/placeName /opener p尊敬的rs typeperson某先生/rs/p p昨日抵达placeName另一个地方/placeName.../p closer persName寄信人甲/persName /closer /div /body /text /TEI这里我没有用真实地名和人名大家替换成自己的语料即可。4.2 处理流程拆解第一步初始化并指定模型第二步处理第三步遍历实体与自定义扩展把实体对应的TEI祖先标签取出来第四步写一个函数判断实体属于persName还是placeName并记录在哪个div、哪一页。核心代码from acdh_spacytei import Spacytei st Spacytei( letter_042.xml, modelde_core_news_sm, tag_map{ persName: PERSON, placeName: LOC, date: DATE, }, keep_attributesTrue, ) doc st.process() for ent in doc.ents: if ent.label_ PERSON: print(ent.text, -, ent._.tei_tag)如果ent._.tei_tag返回了persName我们就知道这个实体是被TEI明确标记过的人物如果返回的是其他标签说明它可能是模型新发现的实体。研究者经常会把“TEI里原本就有的标签”和“模型自动识别出来的实体”分开看前者是文献本身给出的权威信息后者是算法推断两者可以互相校验。更进一步我写了一个提取函数把每一封信的寄件人和收件人抓出来def extract_roles(doc): roles {sender: [], recipient: []} for ent in doc.ents: if ent.label_ ! PERSON: continue if ent._.tei_tag persName: roles[sender].append(ent.text) elif ent._.tei_tag rs: roles[recipient].append(ent.text) return roles因为TEI的rs通常用来指代“被提及的人”这个例子只是为了演示具体语义还得看你的TEI规范。4.3 结果数据处理与导出把所有文件循环处理后我建议把结果存入本地数据库或CSV。重点不是直接输出doc.ents而是建立实体与TEI节点的映射。我当时的导出格式是四列文件名、实体文本、实体类别、TEI路径。TEI路径可以直接写成类似div[typeletter]/opener/placeName的样子方便后续按结构筛选。之后做了一个简单统计约70%的人物实体都能和persName对应上剩余的大多被模型标成了MISC说明标签映射并不能完全替代高质量NLP模型而是一个互补关系。如果你希望实体识别效果更好可以考虑在该TEI语料上做实体类型的正则规则或微调spaCy模型但这已经超出这个包本身的范畴了。5. 高频问题与避坑笔记5.1 解析失败的常见原因我整理了平时遇到过的问题做成速查表现象可能原因解决办法初始化报找不到模型未安装对应spaCy模型执行python -m spacy download 模型名处理结果没有正文xpath未指定或namespace不匹配设置strip_nsTrue或传入正确namespacestoken偏移错位文本中间有大量note或嵌套注释开启remove_notesTrue或调整文本提取规则doc.ents为空merge_entities默认关闭初始化时设置merge_entitiesTrue扩展属性不存在版本升级改了属性名用doc._.keys()查看可用扩展属性某些实体对应不上标签tag_map未覆盖对应标签检查语料扩充tag_map这里我要特别强调一个问题TEI文档中note如果被当作正文文本的一部分就会产生“文本中插入了注释”的错误。很多TEI来源都有注释节点默认情况下应该排除它们而不是让它们混入NLP输入。不同版本的包对note的默认处理方式可能不一样建议显式设置相关开关不要依赖默认值。5.2 性能优化和批量处理建议TEI文档如果很多不建议用一个Spacytei实例循环处理所有文件因为每次初始化都会重新解析配置且如果传入的是模型名spaCy会重复加载模型。推荐先加载一次模型对象再传入构造器import spacy from pathlib import Path from acdh_spacytei import Spacytei nlp spacy.load(de_core_news_sm) for tei_file in Path(letters).glob(*.xml): st Spacytei(tei_file, modelnlp) doc st.process() ...这样可以明显减少加载时间。注意如果传入了Language对象请不要在循环中修改它否则会影响后续所有文档的处理结果。还有一个建议批量处理之前先用一个文件做冒烟测试确认输出结构符合预期。不要上来就循环处理几百个文件然后才发现某个参数不对全部白跑。最后说点实际体会如果让我用一句话概括这个包的价值那就是它把“TEI的结构”和“NLP的分析”变成了同一套逻辑下的产物而不是两条平行的流水线。我在实际使用中会先把TEI样本里的标签全部统计一遍再决定tag_map而不是随便填。还有一点自定义扩展名在不同版本中可能改过批量跑之前一定先跑一个文件确认属性名。最后再分享一个小技巧如果你在处理特别大或特别杂乱的TEI语料建议把转换后生成的纯文本也缓存一份方便随时用原生spaCy重新做实验而不必反复经过XML解析。这个工作流配合这个包能让你在数字人文项目中少踩很多坑。