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”):
Book-Only Mode
Cloud Mode: Dafür auf 🚀 klicken und z.B. in Colab ausführen.
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:
Einlesen des Texts
Worthäufigkeiten ohne echte Tokenisierung
Aufteilen des Texts in Wörter auf Grundlage von Leerzeichen
Abfrage von Häufigkeiten
Annotation mit spaCy
Laden des Sprachmodells
Analysekomponenten auswählen
Text annotieren: Lemmatisierung, POS-Tagging, Dependency Parsing
Worthäufigkeiten anzeigen
Vorläufige Experimente zur Adjektiv-Extraktion
Annotation speichern
Prozess für die gesamten Korpora ausführen
Informationen zum Ausführen des Notebooks – Zum Ausklappen klicken ⬇️
Voraussetzungen zur Ausführung des Jupyter Notebooks
- Installieren der Bibliotheken
- 2. Laden der Daten (z.B. über den Command `wget` (s.u.))
- 3. Pfad zu den Daten setzen
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#
# 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:Dann kann die Häufigkeit abgefragt werden:
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:
Das sprachspezifische Modell wird geladen. Wir arbeiten mit dem weniger akkuraten aber schnellsten spaCy Modell
de_core_news_sm.Für eine erhöhte Annotationsgeschwindigkeit werden nur bestimmte Analysekomponenten geladen. Dies ist vor allem für größere Textmengen sinnvoll.
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
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.