spaCy Entity Ruler, Markdown#

Erkenne Terminologie in deutschsprachigem Markdown Prosatext

Version 2021-11-24, 2022-04-19

Gegeben:

  • Eine Liste von Begriffen plus Labels (controlled terms, CT), z.B. aus einem SKOS-Thesaurus

  • Ein Zeitungstext in DE

  • Die CT kommen im Zeitungstext grammatikalisch in unterschiedlicher Form vor: Wir brauchen also NLP.

Gesucht:

  • Erkenne im Zeitungstext die Labels aus dem Thesaurus,

  • und verlinke sie als Markdown-Links zu den jeweiligen Begriffen

Technisches Detail:

  • Der Zeitungstext liegt als Markdown-Text vor und enthält bereits Links, z.B. aus früheren Läufen oder als Ergebnis manueller Annotation: Diese Links sollen natürlich nicht Gegenstand des Verfahrens sein.

spaCy installieren#

FALSCH wäre einfach ein conda install spacy.

RICHTIG: Konfigureiere den Installer mit https://spacy.io/usage. Ergebnis für uns:

#!conda install -c conda-forge spacy
#!python -m spacy download en_core_web_sm
#!python -m spacy download de_core_news_sm

Rule based matching#

Wir verwenden den Entity Ruler; die Entities können mit der Syntax des Phrasematcher beschrieben werden. Einlesen in die Konzepte:

Das sind die Patterns, die wir suchen:

patterns = [
    {"label": "CT", 
     "pattern": "fettfreie Milch", 
     "id": "ex:fettfreie_Milch"},
    {"label": "CT", 
     "pattern": "skimmed milk", 
     "id": "ex:fettfreie_Milch"},
    {"label": "CT", 
     "pattern": "Milch", 
     "id": "ex:Milch"},
    {"label": "CT", 
     "pattern": "fettfrei", 
     "id": "ex:fettfrei"}]
#text = "If [happy milk (CT)]() tomorrow skims milk they'll get skimmed milk, i.e. milk which is now skim!"
#text2 = "If [happy milk (CT)]() tomorrow [skims milk (CT)](ex:skimmed_milk) they'll get [skimmed milk (CT)](ex:skimmed_milk), i.e. [milk (CT)](ex:milk) which is now [skim (CT)](ex:skimmed)!"

text =  ("""Falls die [glückliche Milch AG](https://www.weideglueck.de/cms?source=(33,17,23)) """
"""mehr Milch fettfreier macht, """
"""hat sie mehr skimmed milk!""" )
text
'Falls die [glückliche Milch AG](https://www.weideglueck.de/cms?source=(33,17,23)) mehr Milch fettfreier macht, hat sie mehr skimmed milk!'
import spacy

# conda install -c conda-forge spacy-model-en_core_web_sm
# EN: nlp = spacy.load("en_core_web_sm")
nlp = spacy.load("de_core_news_sm")

Entity Ruler: Adding IDs to patterns#

https://spacy.io/usage/rule-based-matching#entityruler-ent-ids

Den Entity Ruler in die Pipeline einfügen:

# Ohne Lemmatisierung, ohne Tokenizer-Korrektur

ruler = nlp.add_pipe( "entity_ruler", 
    config={"phrase_matcher_attr": "LOWER"})  # also try LEMMA !

ruler.add_patterns(patterns)  # patterns siehe oben
def tag_text_with_ents(doc):
    """create a copy of doc.text, with entities added in markdown style"""
    last_end_char=0
    result = []
    for ent in doc.ents:
        result.append(doc.text[last_end_char:ent.start_char])
        result.append(f"[{doc.text[ent.start_char:ent.end_char]} ({ent.label_})]({ent.ent_id_})")
        last_end_char = ent.end_char
    result.append(doc.text[last_end_char:])
    return "".join(result)
doc = nlp(text)
type(doc)
spacy.tokens.doc.Doc
[token.text for token in doc]
['Falls',
 'die',
 '[',
 'glückliche',
 'Milch',
 'AG](https://www.weideglueck.de',
 '/',
 'cms',
 '?',
 'source=(33,17,23',
 ')',
 ')',
 'mehr',
 'Milch',
 'fettfreier',
 'macht',
 ',',
 'hat',
 'sie',
 'mehr',
 'skimmed',
 'milk',
 '!']

https://spacy.io/usage/visualizers#jupyter

from spacy import displacy
html = displacy.render(doc, style="ent", jupyter=True)
# html is None: a bug?
Falls die [glückliche Milch CT AG](https://www.weideglueck.de/cms?source=(33,17,23)) mehr Milch CT fettfreier macht, hat sie mehr skimmed milk CT !
tag_text_with_ents(doc)
'Falls die [glückliche [Milch (CT)](ex:Milch) AG](https://www.weideglueck.de/cms?source=(33,17,23)) mehr [Milch (CT)](ex:Milch) fettfreier macht, hat sie mehr [skimmed milk (CT)](ex:fettfreie_Milch)!'

Probleme:

  • Das Wort “fettreier” wird nicht erkannt. Lösung: Suche nicht nach exakten, sondern nach lemmatisierten Strings .

  • Der bereits vorhandene Link [glückliche Milch AG](https://www.weideglueck.de/) wurde vom Tokenizer in einzelne Tokens zerteilt. Deswegen wurde auch in dem bereits vorhandenen Link das Wort “Milch” erkannt. Lösung: Sofort in Anschluss an das Tokenizing suchen wir nach solchen Links und fügen sie wieder zu einem einzelnen span zusammen. Damit nehmen wir solche vorhandenen Links aus dem nachfolgenden NLP faktisch heraus.

Mit Re-Tokenize und Lemmatisierung#

Q: “Is there a way to add an regexp-keyed exception, to, say, match phone number?”

A: “No, there’s no way to have regular expressions as tokenizer exceptions. The tokenizer only looks for exceptions as exact string matches, mainly for reasons of speed. The other difficulty for this kind of example is that tokenizer exceptions currently can’t contain spaces. (Support for spaces is planned for a future version of spacy, but not regexes, which would still be too slow.) … I think the best way to do this would be to add a custom pipeline component at the beginning of the pipeline that retokenizes the document with the retokenizer: https://spacy.io/api/doc#retokenize.” source

Wir erweitern die Pipeline um eine frühe Komponente, die per regex die vorhandenen, in einzelne Tokens zerteilte Markdown-Links wieder zu einzelnen spans zusammenzieht.

from spacy.language import Language
import re

# https://spacy.io/usage/processing-pipelines#custom-components-simple

@Language.component("markdownLinks_2_spans")
def markdownLinks_2_spans(doc):
    # https://spacy.io/usage/rule-based-matching#regex-text > Matching regular expressions on the full text
    expression = r"\[.*?\]\(.*?\)"
    for match in re.finditer(expression, doc.text):
        start, end = match.span()
        span = doc.char_span(start, end)
        # This is a Span object or None if match doesn't map to valid token sequence
        if span is not None:
            # print("Found match:", span.text)
            # https://spacy.io/api/doc#retokenizer.merge
            with doc.retokenize() as retokenizer:
                attrs = {'LEMMA': 'markdownlink', "POS" : "PROPN"}
                retokenizer.merge(span, attrs = attrs)
    return doc
# nlp.remove_pipe("markdownLinks_2_spans")
# In der Pipeline registrieren
nlp.add_pipe("markdownLinks_2_spans", name="markdownLinks_2_spans", first=True)
<function __main__.markdownLinks_2_spans(doc)>
nlp.remove_pipe("entity_ruler")
('entity_ruler', <spacy.pipeline.entityruler.EntityRuler at 0x7fce1f0f6180>)
ruler = nlp.add_pipe( "entity_ruler", 
    config={"phrase_matcher_attr": "LEMMA"})  # oben hatten wir "LOWER"
ruler.add_patterns(patterns)  # patterns siehe oben
text2 = """Judo-Minis 5 bis 7 Jahre: Dienstag, 17 bis 18 Uhr, Donnerstag 16.30 bis 17.30 Uhr
Anfänger 8-12 Jahre: Dienstag, 18 bis 19 Uhr, Donnerstag 17.30 bis 18.30 Uhr
Kinder 8 bis 10 Jahre: Montag und Mittwoch, 17 bis 18.30 Uhr
Jugend: Montag und Mittwoch 18.30 bis 20 Uhr
Erwachsene: Montag 20 bis 21.30 Uhr und Freitag 19.30 bis 21 Uhr
Wettkampftraining Jugend: Donnerstag, 18.30 bis 20 Uhr"""

text = """Falls die [glückliche Milch AG](https://www.weideglueck.de/cms?source=(33,17,23)) mehr Milch fettfreier macht, 
hat sie mehr skimmed milk!"""
doc = nlp(text)
[ (token.text, token.pos_) for token in doc]
[('Falls', 'SCONJ'),
 ('die', 'DET'),
 ('[glückliche Milch AG](https://www.weideglueck.de/cms?source=(33,17,23)',
  'PROPN'),
 (')', 'PUNCT'),
 ('mehr', 'DET'),
 ('Milch', 'NOUN'),
 ('fettfreier', 'ADV'),
 ('macht', 'VERB'),
 (',', 'PUNCT'),
 ('\n', 'SPACE'),
 ('hat', 'AUX'),
 ('sie', 'PRON'),
 ('mehr', 'PRON'),
 ('skimmed', 'PROPN'),
 ('milk', 'VERB'),
 ('!', 'PUNCT')]
html = displacy.render(doc, style="ent", jupyter=True)
Falls die [glückliche Milch AG](https://www.weideglueck.de/cms?source=(33,17,23) MISC ) mehr Milch CT fettfreier macht,
hat sie mehr skimmed milk!
tag_text_with_ents(doc)
'Falls die [[glückliche Milch AG](https://www.weideglueck.de/cms?source=(33,17,23) (MISC)]()) mehr [Milch (CT)](ex:Milch) fettfreier macht, \nhat sie mehr skimmed milk!'