首页 > 解决方案 > 使用 SpaCy 从德语句子中提取主从句

问题描述

在德语中,如何从带有 SpaCy 的句子中提取主从句(又名“从属从句”、“从属从句”)?

我知道如何使用 SpaCy 的标记器、词性标记和依赖解析器,但我无法弄清楚如何使用 SpaCy 可以提取的信息来表示德语的语法规则。

标签: pythonnlpspacy

解决方案


该问题可以分为两个任务:1. 将句子拆分为构成从句,以及 2. 识别哪些从句是主要从句,哪些是从句。由于从句和主句的结构差异有非常严格的语法规则,我会采用基于规则的方法。

将句子分成从句

一个从句包含一个限定动词。在德语中,子句用逗号 (",") 与它们所依赖的“支配”子句(主子句或另一个子句)分隔。主句用逗号或连词“und”、“oder”、“aber”和“sondern”之一与其他主句分开(如果两个主句由“und”或“oder”连接,则逗号被省略)。

这就是为什么我们可能会想到这个想法,用逗号和“und”/“oder”/“aber”/“sondern”将句子分成块。但这给我们留下了一个问题,例如逗号分隔的不是子句的部分存在(想想枚举或并列)以及“und”和“oder”并不总是表示开头一个新的子句(想想枚举)。此外,我们可能会遇到子条款开头的逗号被省略的情况。即使这违反了德语的(规范)语法规则,我们仍然希望正确识别这些子句。

这就是为什么最好从句子中的有限动词开始并使用 spacy 的依赖解析器。我们可以假设,每个有限动词都是它自己的子句的一部分。所以我们可以从一个有限动词开始,遍历它的“后代”(它的孩子和他们的孩子,等等)。这个行走需要在遇到另一个有限动词时立即停止——因为这将是另一个子句的根。

然后,我们只需要将这次步行的路径组合成一个短语。这需要考虑到一个子句可以由多个跨度组成——因为一个子句可以被一个子句分割(考虑与主子句中的对象相关的相对子句)。

确定一个子句是主子句还是子句

从语法上讲,在德语中,从句可以通过限定动词位于最后位置这一事实来识别,这在主要从句中是不可能的。

所以我们可以利用 spacy 的词性标签来解决这个问题。我们可以区分动词的不同标签,动词形式是有限的还是无限的,我们可以很容易地检查从句中的最后一个标记(标点符号之前)是有限动词形式还是无限动词形式。

代码

import itertools as it
import typing as tp

import spacy


VERB_POS = {"VERB", "AUX"}
FINITE_VERB_TAGS = {"VVFIN", "VMFIN", "VAFIN"}


class Clause:
    def __init__(self, spans: tp.Iterable["spacy.tokens.Span"]):
        """Clause is a sequence of potentially divided spans.

        This class basically identifies a clause as subclause and
        provides a string representation of the clause without the
        commas stemming from interjecting subclauses.

        A clause can consist of multiple unconnected spans, because
        subclauses can divide the clause they are depending on. That's
        why a clause cannot just be constituted by a single span, but
        must be based on an iterable of spans.
        """

        self.spans = spans

    @property
    def __chain(self) -> tp.Iterable["spacy.tokens.Token"]:
        return [token for token in it.chain(*self.spans)]

    # We make this class an iterator over the tokens in order to
    #  mimic span behavior. This is what we need the following
    #  dunder methods for.
    def __getitem__(self, index: int) -> "spacy.tokens.Token":
        return self.__chain[index]

    def __iter__(self) -> tp.Iterator:
        self.n = 0
        return self

    def __next__(self) -> "spacy.tokens.Token":
        self.n += 1
        try:
            return self[self.n - 1]
        except IndexError:
            raise StopIteration

    def __repr__(self) -> str:
        return " ".join([span.text for span in self.inner_spans])

    @property
    def is_subclause(self) -> bool:
        """Clause is a subclause iff the finite verb is in last position."""
        return (
            self[-2].tag_ in FINITE_VERB_TAGS
            if self[-1].pos_ == "PUNCT"
            else self[-1].tag_ in FINITE_VERB_TAGS
        )

    @property
    def clause_type(self) -> str:
        return "SUB" if self.is_subclause else "MAIN"

    @property
    def inner_spans(self) -> tp.List["spacy.tokens.Span"]:
        """"Spans with punctuation tokens removed from span boundaries."""
        inner_spans = []
        for span in self.spans:
            span = span[1:] if span[0].pos_ == "PUNCT" else span
            span = span[:-1] if span[-1].pos_ == "PUNCT" else span
            inner_spans.append(span)

        return inner_spans


class ClausedSentence(spacy.tokens.Span):
    """Span with extracted clause structure.

    This class is used to identify the positions of the finite verbs, to
    identify all the tokens that belong to the clause around each finite
    verb and to make a Clause object of each clause.
    """

    @property
    def __finite_verb_indices(self) -> tp.List[int]:
        return [token.i for token in self if token.tag_ in FINITE_VERB_TAGS]

    def progeny(
        self,
        index: int,
        stop_indices: tp.Optional[tp.List[int]] = None,
    ) -> tp.List["spacy.tokens.Token"]:
        """Walk trough progeny tree until a stop index is met."""
        if stop_indices is None:
            stop_indices = []

        progeny = [index]  # consider a token its own child

        for child in self[index].children:
            if child.i in stop_indices:
                continue

            progeny += [child.i] + self.progeny(child.i, stop_indices)

        return sorted(list(set(progeny)))

    @property
    def clauses(self) -> tp.Generator["Clause", None, None]:
        for verb_index in self.__finite_verb_indices:
            clause_tokens = [
                self[index]
                for index in self.progeny(
                    index=verb_index, stop_indices=self.__finite_verb_indices
                )
            ]

            spans = []

            # Create spans from range extraction of token indices
            for _, group in it.groupby(
                enumerate(clause_tokens),
                lambda index_token: index_token[0] - index_token[1].i,
            ):
                tokens = [item[1] for item in group]
                spans.append(self[tokens[0].i : tokens[-1].i + 1])

            yield Clause(spans)

示例如何运行

以下代码片段演示了如何使用上述类将句子拆分为子句:

import spacy


text = "Zu Hause ist dort, wo sich das W-LAN verbindet."  # Could also be a text with multiple sentences

language_model = "de_core_news_lg"
nlp = spacy.load(language_model)  # The spacy language model must be installed, see https://spacy.io/usage/models
document = nlp(text)
sentences = document.sents

for sentence in sentences:
    claused_sentence = ClausedSentence(sentence.doc, sentence.start, sentence.end)
    clauses = list(claused_sentence.clauses)
    for clause in clauses:
        print(f"{clause.clause_type}: {clause.inner_spans}")

测试用例

我还没有对更大的不同类型文本的语料库进行彻底的测试,但是我创建了一些测试用例来调查算法的主要能力和潜在的缺陷:

将主从句与从句分开

在 meinem Bett, das ich gestern gekauft habe, fühle ich mich wohl。

SUB: das ich gestern gekauft habe
MAIN: In meinem Bett fühle ich mich wohl

正确的。

主从句

Ich brauche nichts, außer dass mir ab und zu jemand Trost zuspricht。

MAIN: Ich brauche nichts 
SUB: außer dass mir ab und zu jemand Trost zuspricht

正确的。

主要条款和子条款的顺序

Er sieht in den Spiegel und muss erkennen, dass er alt geworden ist。

MAIN: Er sieht in den Spiegel und 
MAIN: muss erkennen
SUB: dass er alt geworden ist

子句类型的分配是正确的。不过,“und”可以分配给第二个主要子句。这将需要另外考虑从句的最后一个标记是否是连词,如果是,则将其分配给下一个子句。

子条款和主要条款的顺序

Als er die Türklingel hört, rennt er die Treppe hinunter, geht zur Tür, schaut durch den Spion, und öffnet die Tür。

SUB: Als er die Türklingel hört
MAIN: rennt er die Treppe hinunter  und 
MAIN: geht zur Tür
MAIN: schaut durch den Spion
MAIN: öffnet die Tür

正确的。与上面的连词“und”相同的问题。

带有实体化动词的主句

Essen und Trinken hält Leib und Seele zusammen。

MAIN: Essen und Trinken hält Leib und Seele zusammen

正确的。

主要条款和子条款

Zu Hause ist dort,wo sich das W-LAN verbindet。

MAIN: Zu Hause ist dort 
SUB: wo sich das W-LAN verbindet

正确的。

主子句的复杂序列

安吉拉·默克尔(Angela Merkel),deutsche Bundeskanzlerin, hat nicht erneut für den Vorsitz ihrer Partei kandidiert, obwohl sie stets der Auffassung war, Kanzlerschaft und Parteivorsitz würden in eine Hand gehören。

SUB: Angela Merkel, die deutsche Bundeskanzlerin, hat 
SUB: nicht erneut für den Vorsitz ihrer Partei kandidiert
SUB: obwohl sie stets der Auffassung war
SUB: Kanzlerschaft und Parteivorsitz würden
SUB: in eine Hand gehören

这是错误的。正确的是:

MAIN: Angela Merkel, die deutsche Bundeskanzlerin, hat nicht erneut für den Vorsitz ihrer Partei kandidiert, 
SUB: obwohl sie stets der Auffassung war, 
MAIN: Kanzlerschaft und Parteivorsitz würden in eine Hand gehören.

该错误是由于 SpaCy 错误地将“kandidiert”识别为有限动词,而它是分词,并且还将“gehören”错误识别为有限动词形式,而它是无限动词。由于此错误基于 SpaCy 提供的底层语言模型,因此似乎很难独立于语言模型来纠正此输出。然而,也许有一种基于规则的方式来覆盖 SpaCy 将这些动词形式标记为无限动词的决定。我还没有找到解决方案。


推荐阅读