4.2. 🚀 Korpusverarbeitung – Annotation mit spaCy#

4.2.1. Hinweise zur Ausführung des Notebooks#

Dieses Notebook kann auf unterschiedlichen Levels erarbeitet werden (siehe Abschnitt “Technische Voraussetzungen”):

  1. Book-Only Mode

  2. Cloud Mode: Dafür auf 🚀 klicken und z.B. in Colab ausführen.

  3. Local Mode: Dafür auf Herunterladen ↓ klicken und “.ipynb” wählen.

4.2.2. Übersicht#

Im Folgenden wird exemplarisch der Roman “Feldblumen” von Adalbert Stifter (txt-Datei) mit der Bibliothek spaCy annotiert.

Es werden folgendene Schritte durchgeführt:

  1. Einlesen des Texts

  2. Worthäufigkeiten ohne echte Tokenisierung

    • Aufteilen des Texts in Wörter auf Grundlage von Leerzeichen

    • Abfrage von Häufigkeiten

  3. Annotation mit spaCy

    • Laden des Sprachmodells

    • Analysekomponenten auswählen

    • Text annotieren: Lemmatisierung, POS-Tagging, Dependency Parsing

    • Worthäufigkeiten anzeigen

  4. Vorläufige Experimente zur Adjektiv-Extraktion

  5. Annotation speichern

  6. Prozess für die gesamten Korpora ausführen

Informationen zum Ausführen des Notebooks – Zum Ausklappen klicken ⬇️

Voraussetzungen zur Ausführung des Jupyter Notebooks

  1. Installieren der Bibliotheken
  2. 2. Laden der Daten (z.B. über den Command `wget` (s.u.))
  3. 3. Pfad zu den Daten setzen
Zum Testen: Ausführen der Zelle "load libraries" und der Sektion "Einlesen des Texts".
Alle Zellen, die mit 🚀 gekennzeichnet sind, werden nur bei der Ausführung des Noteboos in Colab / JupyterHub bzw. lokal ausgeführt.

Hide code cell content

#  🚀 Install libraries 
! pip install tqdm pandas numpy spacy bokeh ipython==7.23.1

#  🚀 Load german language model for annotation
! python -m spacy download de_core_news_sm

Hide code cell content

# load libraries 
import json
import typing
import requests
from pathlib import Path
from time import time
from collections import OrderedDict, Counter
from datetime import datetime

from tqdm import tqdm
import pandas as pd
import numpy as np
import spacy
from spacy import displacy

from bokeh.io import output_notebook, show
from bokeh.layouts import column
from bokeh.models import CustomJS, TextInput, Div

4.2.3. Einlesen des Texts#

Um eine Datei mit Python bearbeiten zu können, muss die Datei zuerst ausgewählt, d.h der Pfad zur Datei wird gesetzt, und dann eingelesen werden.

Informationen zum Ausführen des Notebooks – Zum Ausklappen klicken ⬇️ Zuerst wird der Ordner angelegt, in dem die Textdateien gespeichert werden. Der Einfachheit halber wird die gleich Datenablagestruktur wie in dem GitHub Repository, in dem die Daten gespeichert sind, vorausgesetzt.
Der Text wird aus GitHub heruntergeladen und in dem Ordner ../data/txt/ abgespeichert.
Der Pfad kann in der Variable text_path angepasst werden. Die einzulesenden Daten müssen die Endung `.txt` haben.

Pfad setzen#

Hide code cell content

# 🚀 Create data directory path
corpus_dir = Path("../data/txt")
if not corpus_dir.exists():
    corpus_dir.mkdir()

Hide code cell content

# 🚀 Load the txt file from GitHub 
! wget https://raw.githubusercontent.com/quadriga-dk/Text-Fallstudie-3/refs/heads/main/data/txt/Adalbert_Stifter_-_Feldblumen_(1841).txt -P ../data/txt
# set the path to file to be processed
text_path = Path("../data/txt/Adalbert_Stifter_-_Feldblumen_(1841).txt")

Text einlesen#

# read text and print some parts of the text
if text_path.is_file():
    text = text_path.read_text()
    print(f"Textauszug:\n {text[120:230]}")
else:
    print("The file path does not exist. Set the variable text_path to an existing path.")
Textauszug:
 Primel

24. April 1834.

Man legt oft etwas dem Menschen zur Last, woran eigentlich die Chemie alle
Schuld hat

Im Textauszug ist erkennbar, dass der Text die Absätze aus dem Text einer Print-Ausgabe entsprechen. Das ist für die automatische Prozessierung mit spaCy irrelevant, da die Absätze (kodiert durch \n) nicht als semantische Einheit gesehen werden.

4.2.4. Worthäufigkeiten ohne echte Tokenisierung#

Text in Wörter aufteilen#

Der einfachste Weg einen Text automatisch in Wörter aufzuteilen, ist anzunehmen, dass Wörter durch Leerzeichen getrennt sind.

# split the text into words by space
words = text.split()

Wie lang ist der Text in Worten?

len(words)
38015

Prüfen: Wie sieht die Wortliste aus?

# print the 7th up the 79th words
words[7:79]
['24.',
 'April',
 '1834.',
 'Man',
 'legt',
 'oft',
 'etwas',
 'dem',
 'Menschen',
 'zur',
 'Last,',
 'woran',
 'eigentlich',
 'die',
 'Chemie',
 'alle',
 'Schuld',
 'hat.',
 'Es',
 'ist',
 'offenbar,',
 'daß',
 'wenn',
 'ein',
 'Mensch',
 'zu',
 'wenig',
 'Metalle,',
 'z.',
 'B.',
 'Eisen,',
 'in',
 'sein',
 'Blut',
 'bekommen',
 'hat,',
 'die',
 'andern',
 'Atome',
 'gleichsam',
 'darnach',
 'lechzen',
 'müssen,',
 'um,',
 'damit',
 'verbunden,',
 'das',
 'chemisch',
 'heilsame',
 'Gleichgewicht',
 'herstellen',
 'zu',
 'können.',
 'Nur',
 'mißversteht',
 'aber',
 'der',
 'so',
 'schlimm',
 'Begabte',
 'meistens',
 'seinen',
 'Drang,',
 'und',
 'statt',
 "in's",
 'Blut,',
 'schleppt',
 'er',
 'unbeholfen',
 'die',
 'Metalle']

Wie viele Wörter gibt es insgesamt?

# print the length of the word list
len(words)
38015

Wie zu sehen ist, hat diese Art der “falschen” Tokenisierung den Nachteil, dass Satzzeichen nicht von Wörtern abgetrennt werden.
Die Wortanzahl ist dementsprechend auch nicht akkurat.

Anzeigen von Worthäufigkeiten#

Auf Grundlage dieser Wortliste kann trotzdem schon eine erste basale Häufigkeitenabfrage erfolgen. Dafür werden die Wörter zuerst gezählt.

# Count the words with Counter and save the result to a variable
word_frequencies = Counter(words)
Informationen zum Ausführen des Notebooks – Zum Ausklappen klicken ⬇️ Um die Häufigkeit nur mit Python abzufragen, kann folgende Zeile ausgeführt werden:

Hide code cell content

# 🚀 get the number of the word "Luft" in the word frequencies 
word_frequencies["Luft"]

Dann kann die Häufigkeit abgefragt werden:

Hide code cell source

# Ensure Bokeh output is displayed in the notebook
output_notebook()

# Convert the dictionary to a JSON string to be passed to javascript
word_freq_json = json.dumps(word_frequencies)

# Create the text input widget
text_input = TextInput(value='', title="Geben Sie ein Wort ein:")

# Create a Div to display the frequency
frequency_display = Div(text="Häufigkeit: ")

# JavaScript callback to update the frequency display
# Only needed for graphical interface 
callback = CustomJS(args=dict(frequency_display=frequency_display, text_input=text_input), code=f"""
    var word = text_input.value.trim();

    // Parse the word frequency dictionary from Python
    var word_freq = {word_freq_json};

    var frequency = word in word_freq ? word_freq[word] : "Nicht gefunden";
    frequency_display.text = "Häufigkeit: " + frequency;
""")

text_input.js_on_change('value', callback)

# Layout and display
layout = column(text_input, frequency_display)
show(layout)
Loading BokehJS ...

4.2.5. Annotation mit spaCy#

Um eine präzisere Einteilung in Wörter zu erhalten (Tokenisierung) und um flektierte Wörter aufeinander abbildbar zu machen (Lemmatisierung), wird der Text im folgenden durch die Bibliothek spaCy annotiert. In der darauffolgenden Analyse sollen außerdem Adjektiv-Nomen Paare extrahiert werden,

Dafür werden folgende Schritte ausgeführt:

  1. Das sprachspezifische Modell wird geladen. Wir arbeiten mit dem weniger akkuraten aber schnellsten spaCy Modell de_core_news_sm.

  2. Für eine erhöhte Annotationsgeschwindigkeit werden nur bestimmte Analysekomponenten geladen. Dies ist vor allem für größere Textmengen sinnvoll.

  3. Der Text wird annotiert und die Token sowie die dazugehörigen Lemmata werden extrahiert.

Sprachmodell laden#

Das sprachspezifische Modell wird geladen. Es handelt sich dabei um das am wenigsten akkurate aber schnellste Modell.

nlp = spacy.load('de_core_news_sm')

Analysekomponenten auswählen#

Es werden einige Analysekomponent wie z. B. das Aufteilen des Texts in Sätze (sentencizer) oder die Named Entity Recognition (ner) ausgeschlossen, da diese für die Tokenisierung und die Lemmatisierung sowie für das POS-Tagging und Dependency Parsing nicht benötigt werden. Der Auschluss der Komponenten erhöht die Annotationsgeschwindikgeit.

disable_components = ['ner', 'attribute_ruler', 'sentencizer']
nlp.max_length = 5200000 

Annotieren der Texte: Token, Lemma, POS, Dependenzen#

Der ausgewählte Text wird mit spaCy annotiert und liegt dann in einem spaCy-eigenen Datenformat, dem sogenannten Doc vor. Das Doc ist eine praktische Datenstruktur, in der sich die Annotation leicht navigieren lassen. So kann zu jedem Token das dazugehörige Lemma, POS-Tag und die Dependenzannotation abfragen.

# get the current time to display how long the annotation took
current = time()

# annotate with spacy
doc = nlp(text)

# calculate how long the annotation and extraction took and print result
took = time() - current
print(f"Die Annotation hat {round(took, 2)} Sekunden gedauert.") 
Die Annotation hat 6.29 Sekunden gedauert.

Wie lang ist der Text jetzt (in Worten)?

len(doc)
47363

Die Annotationen lassen sich dann wie folgt anzeigen:

# print extract of the annotation
print(f"Token\tLemma\tPOS\tDependency Head\tDependency Tag")
for token in doc[89:110]:
    print(f"{token.text}\t{token.lemma_}\t{token.pos_}\t{token.head}\t{token.dep_}")
Token	Lemma	POS	Dependency Head	Dependency Tag


	

	SPACE	Primel	dep
24.	24.	ADJ	April	nk
April	April	NOUN	April	ROOT
1834.	1834.	NUM	1834.	ROOT


	

	SPACE	1834.	dep
Man	man	PRON	legt	sb
legt	legen	VERB	legt	ROOT
oft	oft	ADV	legt	mo
etwas	etwas	PRON	legt	oa
dem	der	DET	Menschen	nk
Menschen	Mensch	NOUN	legt	da
zur	zu	ADP	legt	cvc
Last	Last	NOUN	zur	nk
,	--	PUNCT	legt	punct
woran	woran	SCONJ	hat	mo
eigentlich	eigentlich	ADV	hat	mo
die	der	DET	Chemie	nk
Chemie	Chemie	NOUN	hat	sb
alle	aller	DET	Schuld	nk

	
	SPACE	alle	dep
Schuld	Schuld	NOUN	hat	oa

Um herauszufinden, wofür die einzelnen Tags stehen, können wir spaCy’s .explain Methode benutzen:

spacy.explain("mnr")
'postnominal modifier'

Worthäufigkeit mit echter Tokenisierung#

Durch die Tokenisierung wurden z. B. Satzzeichen von Wörtern abgetrennt. An der Textlänge lässt sich dies schon erkennen.

# get the lemmata 
text_tokenized = [token.lemma_ for token in doc]

# print the length
len(text_tokenized)
47363

Auf Grundlage des tokenisierten und lemmatisierten Texts, kann die Häufigkeitenabfrage erneut augeführt werden. Da durch die Lemmatisierung flektierte Wortformen auf die Grundformen zurückgeführt wurden, erwarten wir, dass die Häufigkeit einer Wortgrundform im Gegensatz zur vorherigen Abfrage erhöht ist.

# Count the words with Counter and save the result to a variable
token_frequencies = Counter(text_tokenized)
Informationen zum Ausführen des Notebooks – Zum Ausklappen klicken ⬇️ Um die Häufigkeit nur mit Python abzufragen, kann folgende Zeile ausgeführt werden:

Wir können die Häufigkeit des Worts “Luft” abfragen oder unten nach weiteren Wörtern suchen.

# 🚀 get the number of the word "Grippe" in the word frequencies 
token_frequencies["Luft"]
16

Hide code cell source

# Ensure Bokeh output is displayed in the notebook
output_notebook()

# Convert the dictionary to a JSON string
tok_freq_json = json.dumps(token_frequencies)

# Create the text input widget
token_input = TextInput(value='', title="Geben Sie ein Wort ein:")

# Create a Div to display the frequency
token_frequency_display = Div(text="Häufigkeit: ")

# JavaScript callback to update the frequency display
# Only needed for graphical interface 
tok_callback = CustomJS(args=dict(frequency_display=token_frequency_display, text_input=token_input), code=f"""
    var tok = text_input.value.trim();

    // Parse the word frequency dictionary from Python
    var word_freq = {tok_freq_json};

    var frequency = tok in word_freq ? word_freq[tok] : "Nicht gefunden";
    frequency_display.text = "Häufigkeit: " + frequency;
""")

token_input.js_on_change('value', tok_callback)

# Layout and display
layout = column(token_input, token_frequency_display)
show(layout)
Loading BokehJS ...

Luft-Adjektive#

In einem weiteren Schritt können wir die Adjektive extrahieren, die mit dem Nomen Luft in Verbindung stehen. Wir machen dabei Gebrauch von den Dependenzstrukturen, die sich durch das spaCy-eigene Doc einfach navigieren lassen.

adjectives = []
for token in doc:
    # Find the target noun
    if token.lemma_ == "Luft" and token.pos_ == "NOUN":
        # find attributive adjectives (direct children of the noun)
        for child in token.children:
            if child.pos_ == "ADJ":
                adjectives.append(child.lemma_)
        
        # find predicative adjectives
        # The noun should be subject (sb) of a copula verb
        if token.dep_ == "sb":  # check if noun is subject
            head = token.head # get verb
            # Check if head is a copula (sein, werden, bleiben, etc.)
            if head.pos_ in ["AUX", "VERB"] and head.lemma_ in ["sein", "werden", "bleiben"]:
                # Find predicate adjectives (children of the copula)
                for child in head.children:
                    if child.pos_ == "ADJ" and child.dep_ == "pd":  # predicate
                        adjectives.append(child.lemma_)

Wir lassen uns die Anzahl der Adjektive anzeigen:

len(adjectives)
8

Und lassen die Adjektive zählen:

adjectives_counted = Counter(adjectives)
adjectives_counted.most_common()
[('wimmelnd', 2),
 ('rein', 1),
 ('weich', 1),
 ('kühl', 1),
 ('finster', 1),
 ('wellenlos', 1),
 ('dunkel', 1)]

Aus den 16 Vorkommen von Luft (s.o.), werden 8 durch Adjektive genauer beschrieben, darunter lassen sich sowohl positive Adjektive wie “rein” und “weich” finden als auch negative Adjektive wie “finster”. In Feldblumen zeichnet sich mit dieser Minimalnanalyse noch kein klares Bild über die Konnotation von Luft ab.

4.2.6. Annotationen speichern#

Um den annotierten Text zu speichern, muss zuerst das Speicher-Format festgelegt. Für die Speicherung von relativen Daten (wie ein Wort und die unterschiedlichen Annotationen des Worts) eignet sich das Tabellenformat gut. Für die weitere Prozessierung ist es allerdings von Vorteil die spaCy-spezifischen Funktionen nutzen zu können, um die Dependenz-Annotationen zu navigieren (wie in dem Beispiel oben).

Datei-Format und Interoperabilität

Wenn die Annotationen nur im spaCy-eigenen Format gespeichert werden, sind wir von spaCy abhängig, um die Dateien wieder auslesen zu können. Das Format ist dementsprechend weniger interoperabel. Um die Reproduzierbarkeit der Annotation sicherzustellen, sollte:

  • dokumentiert werden, mit welcher spaCy-Version die Dateien erstellt wurden

  • im bestem Fall die Dateien zusätzlich in einem platform-unabhängigen, textbasierten Format wie CSV abgespeichert werden.

Deswegen speichern wir die Annotationen sowohl über die von spaCy dazu bereitgestellten Methoden, um sie dann wieder in spaCy laden zu können als auch im Tabellenformat, da Tabellen unabhängig von einer spezifischen Bibliothek / einem spezifischen Programm geöffnet werden können.

Annotationstabelle erstellen#

Zuerst erstellen wir aus den Annotationen eine Tabelle, dafür legen wir folgende Spalten an:

  • IDx: Index des Token im annotierten Dokument

  • Token: das Wort wie es im Text vorkommt

  • Lemma: Die Wortgrundform

  • PoS: Das Tag für die Wortart

  • Dependency: Das Dependenz-Label

  • Dependency_head_idx: Der Token-Index des Kopf-Token

  • Dependency_head_text: Der Token-Text des Kopf-Token

# create final annotation list
annotations = []

# iterate token
for token in doc:
    # Extract annotations
    annotation = {
        "Idx": token.i,
        "Token": token.text,
        "Lemma": token.lemma_,
        "PoS": token.pos_,
        "Dependency": token.dep_,
        "Dependency_head_idx": token.head.i,
        "Dependency_head_text": token.head.text
    }
    annotations.append(annotation)
anno_df = pd.DataFrame(annotations)

Der Anfang unserer Tabelle sind dann so aus:

anno_df.head()
Idx Token Lemma PoS Dependency Dependency_head_idx Dependency_head_text
0 0 Adalbert Adalbert VERB ROOT 0 Adalbert
1 1 Stifter Stifter PROPN nk 3 Feldblumen
2 2 \n\n \n\n SPACE dep 1 Stifter
3 3 Feldblumen Feldblumen PROPN ROOT 3 Feldblumen
4 4 \n\n \n\n SPACE dep 3 Feldblumen

Dateien schreiben#

Zum Schreiben der Dateien müssen wir zuerst einen Dateinamen festlegen. Die Annotationstabellen speichern wir als .csv-Datei, die Einträge einer Reihen werden dabei mit Kommata getrennt. Für die Speicherung der spaCy-eigenen Annotationen gibt es keine standadisierte Dateiendung. Um die Abhängigkeit von spaCy explizit zu machen, setzen wir .spacy als Dateiendung.

Informationen zum Ausführen des Notebooks Der Pfad zum Schreiben der Ergebnisse wird hier auf den selben Ordner gesetzt, in dem das Notebook liegt. So wird nicht von einer bestimmten Ordner-Struktur ausgegangen, wie in der Code-Zeile danach. Dort wird davon ausgeganen, dass auf der selben Höhe des Ordners, in dem das Notebook liegt, ein Ordner `data` existiert, in dem ein Ordner `csv` vorhanden ist. In dem Ordner `csv` wird die Annotation gespeichert.

⚠️ Die nächste Zeile, in der der Pfad noch einmal gesetzt wird, muss übersprungen werden.
# set output path to current directory
output_dir = Path(r"../data/annotations")
if not output_dir.exists():
    output_dir.mkdir()

# set file name to original name with a different file extension
output_path_spacy = output_dir / text_path.with_suffix(".spacy").name
output_path_table = output_dir / text_path.with_suffix(".csv").name

Der Text wird dann unter dem festgelegten Dateinamen gespeichert.

# save the annotation in spaCy-specific format
doc.to_disk(output_path_spacy)

# save the annotation in table format
anno_df.to_csv(output_path_table, index=False)

Zusätzlich schreiben wir eine Dokumentationsdatei, in der folgende Informationen zur Annotation gespeichert werden:

  • die spaCy-Version,

  • der Modell-Name

  • die Modell-Version

  • das Datum

Die Daten speichern wir auch in einer Tabelle.

datetime_str = datetime.today().replace(second=0, microsecond=0).isoformat()

documentation = {
    "spacy_version":spacy.__version__,
    "model_name": f"{nlp.meta['lang']}_{nlp.meta['name']}",
    "model_version": nlp.meta["version"],
    "date": datetime_str
}
docu_df = pd.DataFrame([documentation])

Die Dokumentationstabelle sieht so aus:

docu_df
spacy_version model_name model_version date
0 3.8.11 de_core_news_sm 3.8.0 2026-02-12T15:43:00

Schreiben der Dokumentationstabelle:

# set file path
output_documentation_fp = output_dir / f"{datetime}_spaCy_annotation_documentation.txt"

# save dataframe to file path
docu_df.to_csv(output_documentation_fp, index=False)

4.2.7. Prozess für die gesamten Korpora ausführen#

Um die gesamten Korpora zu annotieren, sollten wir zuerst abschätzen, wie lange die Annotation aller Texte dauern würde, um ggf. die Performanz der Annotation zu optimieren.

Dauer der Annotation für das gesamte Korpus

Die Korpora enthalten jeweils 400 Texte. Mit einer Länge von über etwa 47.000 Wörtern ist Feldblumen ein verhältnismäßig kurzer Text, weswegen wir durchschnittlich die dreifache Annotationsdauer pro Text annehmen (wir wollen lieber zu viel als zu wenig Zeit für die Annotation ansetzen). Die Annotation eines einzelnen Texts sollte somit im Schnitt etwa 15 Sekunden dauern. Die Annotation von 800 Texten dauert dementsprechend 12.000 Sekunden, also 200 Minuten ~ 3 Stunden.

Da dies eher lang erscheint, sollte versucht werden, die Performanz zu optimieren. spaCy stellt dafür z.B. einen Methode bereit, die automatisch eine Liste von Dokumenten verarbeitet (.pipe()). Da die Annotation einzelner Texte unabhängig voneinander ist, kann die Prozessierung so automatisiert werden, dass mehrere Texte zeitgleich annotiert werden. Je nach Ausstattung des Computers, der zur Annotation genutzt wird (v.a. die Anzahl von Prozessoren und die Größe des RAM-Speichers sind ausschlaggebend), können unterschiedlich viele Texte zeitgleich prozessiert werden.

Die optimierte Annotation wurde auf ein Skript ausgelagert, das sich in dem GitHub-Repositorium der Fallstudie befindet. Das Skript haben wir auf einem MacBook M4 Max mit 13 Kernen und 36GB RAM ausgeführt. Es ist für ca. 20 Minuten gelaufen.