☰
acdh-spacytei:打通TEI XML与spaCy的NLP对齐方案
2026/10/15 12:02:44 网站建设 项目流程

做数字人文,尤其是处理TEI编码的历史文献时,我长期被一个问题折腾:一边是结构严谨的XML树,一边是重视语法依赖的spaCy流水线,两者怎么安全地对接?以前我通常会先写一堆正则或者手动用ElementTree抽取正文,喂给spaCy,等结果出来后,再想方设法把模型预测的实体、词性映射回原文中的TEI标签,一来二去不仅代码丑,还容易出现错位。后来接触到了acdh-spacytei这个Python包,它能直接在TEI XML与spaCy的Doc对象之间搭一座桥,让我在保留注释结构的情况下完成分词、词性标注、命名实体识别等常见NLP任务。这篇文章,我会把它的语法、关键参数和实际落地案例完整梳理一遍,供同样在做文本挖掘或数字人文项目的朋友参考。

1. 为什么需要这个包:TEI文本与NLP之间的错位

1.1 TEI文本处理的老大难问题

TEI(Text 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到NLP

2.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, model="de_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_path:TEI文件路径或文件对象。
  • model:spaCy模型名字或已加载的Language对象,默认为英文小模型,处理中文或德文需要显式指定。
  • tag_map:一个字典,告诉包哪些TEI标签需要被当作“有用注释”保留。例如{"persName": "PERSON"}。
  • strip_ns:布尔值,是否去掉XML命名空间前缀。
  • remove_facsimile:布尔值,是否剔除facsimile相关节点。

为什么需要tag_map?因为不是所有TEI标签都应该进入后续处理。有些标签是排版层面的,比如<lb/>、<pb/>;有些语义标签才和自然语言处理相关,比如<persName>、<placeName>、<date>。通过映射可以减少不必要的干扰,并让实体类别和TEI标签建立对应。

我通常会这样设计tag_map:

tag_map = { "persName": "PERSON", "placeName": "LOC", "date": "DATE", "orgName": "ORG", "rs": "MISC", }

注意,这里键是TEI标签名,值是我们希望spaCy实体类型显示出来的标签。它不会改变模型的预测结果,更多是让输出结果更易于按TEI语义归类。

3.2 文本提取与控制参数

TEI文件的结构多种多样,有的正文直接放在<body><div><p>里,有的被包裹在<teiHeader>后面。包一般会提供text_tag或xpath参数,用来指定正文根节点。比如:

st = Spacytei( file_path="letters.xml", model="de_core_news_sm", xpath="//tei:body", namespaces={"tei": "http://www.tei-c.org/ns/1.0"}, )

如果你不指定,包会尝试自动找到<body>,但如果文档用了很特殊的结构,自动判断可能失效。这一点我在真实语料上踩过坑:我处理过一份包含多个<body>的复合文档,结果包把所有正文拼一起了,后来明确指定xpath才解决。

还要特别注意命名空间。很多TEI文档根节点自带namespace前缀,直接用//body会匹配不到,需要带namespace。早期版本用默认配置无法正确匹配,必须传递一个命名空间字典,或者开启strip_ns=True。这两个方案各有利弊:strip_ns会把标签名里的前缀去掉,处理起来方便,但如果你后面要把结果写回原始文档,反而需要额外维护映射关系。

3.3 输出结构与序列化参数

因为NLP之后往往还要写回XML或者入库,所以输出参数也很重要。常见的输出相关参数有:

  • return_annotated_xml:设为True时,在Doc处理完成后,返回一个重新组装好的TEI XML树,里面可能额外嵌入NLP标注。
  • merge_entities:是否把命名实体对应的token合并为span。如果你需要doc.ents正确显示实体,建议开启。
  • keep_inline_tags:保留原文中的行内标签,例如<hi rend="italic">,用于分析斜体片段的语义。
  • 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 xmlns="http://www.tei-c.org/ns/1.0"> <teiHeader>...</teiHeader> <text> <body> <div type="letter"> <lb/> <opener> <date>1770-08-14</date> <placeName>某城</placeName> </opener> <p>尊敬的<rs type="person">某先生</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", model="de_core_news_sm", tag_map={ "persName": "PERSON", "placeName": "LOC", "date": "DATE", }, keep_attributes=True, ) 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[type=letter]/opener/placeName的样子,方便后续按结构筛选。

之后做了一个简单统计:约70%的人物实体都能和<persName>对应上,剩余的大多被模型标成了MISC,说明标签映射并不能完全替代高质量NLP模型,而是一个互补关系。如果你希望实体识别效果更好,可以考虑在该TEI语料上做实体类型的正则规则或微调spaCy模型,但这已经超出这个包本身的范畴了。

5. 高频问题与避坑笔记

5.1 解析失败的常见原因

我整理了平时遇到过的问题,做成速查表:

现象可能原因解决办法
初始化报找不到模型未安装对应spaCy模型执行python -m spacy download 模型名
处理结果没有正文xpath未指定或namespace不匹配设置strip_ns=True或传入正确namespaces
token偏移错位文本中间有大量<note>或嵌套注释开启remove_notes=True或调整文本提取规则
doc.ents为空merge_entities默认关闭初始化时设置merge_entities=True
扩展属性不存在版本升级改了属性名用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, model=nlp) doc = st.process() ...

这样可以明显减少加载时间。注意,如果传入了Language对象,请不要在循环中修改它,否则会影响后续所有文档的处理结果。

还有一个建议:批量处理之前,先用一个文件做冒烟测试,确认输出结构符合预期。不要上来就循环处理几百个文件,然后才发现某个参数不对,全部白跑。

最后说点实际体会

如果让我用一句话概括这个包的价值,那就是:它把“TEI的结构”和“NLP的分析”变成了同一套逻辑下的产物,而不是两条平行的流水线。我在实际使用中会先把TEI样本里的标签全部统计一遍,再决定tag_map,而不是随便填。还有一点,自定义扩展名在不同版本中可能改过,批量跑之前一定先跑一个文件确认属性名。

最后再分享一个小技巧:如果你在处理特别大或特别杂乱的TEI语料,建议把转换后生成的纯文本也缓存一份,方便随时用原生spaCy重新做实验,而不必反复经过XML解析。这个工作流配合这个包,能让你在数字人文项目中少踩很多坑。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询