Korpusverarbeitung – Annotation mit spaCy#

Übersicht#

Im Folgenden wird exemplarisch ein Text (txt-Datei) mit der Bibliothek spaCy annotiert. Dafür 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

    • Worthäufigkeiten anzeigen

  4. Annotation speichern

  5. Prozess für das gesamte Korpus 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 

#  🚀 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 tqdm import tqdm
import pandas as pd
import numpy as np
import spacy

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

1. Einlesen des Texts#

Um eine Datei mit Python bearbeiten zu können, muss die Datei zuerst ausgewählt werden, 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.
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/dh-network/quadriga/refs/heads/main/data/txt/SNP2719372X-19181015-0-0-0-0.txt

# move the file to the data directory
! mv SNP2719372X-19181015-0-0-0-0.txt ../data/txt
# set the path to file to be processed
text_path = Path("../data/txt/SNP2719372X-19181015-0-0-0-0.txt")
# read text and print some parts of the text
if text_path.is_file():
    text = text_path.read_text()
    print(f"Textauszug:\n {text[10280:10400]}")
else:
    print("The file path does not exist. Set the variable text_path to an existing path.")
Textauszug:
 irgendeine ins einzelne gehende Erklärung über die Lage abgeben werde. Der nächste Schritt für den Präsidenten Wilson wi

2. Worthäufigkeiten ohne echte Tokenisierung#

2.1 Text in Wörter aufteilen#

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

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

Prüfen: Wie sieht die Wortliste aus?

# print the 100th up the 120th words
words[100:120]
['respektooller',
 'Entfernung,',
 'Die',
 'beschränkten',
 'sich',
 'in',
 'der',
 'Hauptsache',
 'de',
 'Ortschaften',
 'im',
 'deutschen',
 'Hinter-',
 '<->',
 'mit',
 'Bombengeschwadern',
 'anzugreisen.',
 'der',
 'Zwischenzeit',
 'wurde']

Wie viele Wörter gibt es insgesamt?

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

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.

2.2 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 "Grippe" in the word frequencies 
word_frequencies["Grippe"]

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();
    console.log('Input value:', word);

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

    var frequency = word in word_freq ? word_freq[word] : "not found";
    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 ...

3. 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. 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.

3.1 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')

3.2 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 nicht benötigt werden. Der Auschluss der Komponentnen erhöht die Annotationsgeschwindikgeit.

disable_components = ['ner', 'morphologizer', 'attribute_ruler', 'sentencizer']

3.3 Annotieren der Texte: Token, Lemma#

Der ausgewählte Text wird mit spaCy annotiert und die Token sowie die dazugehörigen Lemmata werden extrahiert und in einer Tabelle gespeichert. Das Tabellenformat wurde gewählt, da sich darin gut relationale Daten speichern lassen.

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

# annotate with spacy
doc = nlp(text)

# extract tokens and lemmata, save them to a dictionary
text_annotated = {}
text_annotated['Token'] = [tok.text for tok in doc]
text_annotated['Lemma'] = [tok.lemma_ for tok in doc]

# convert the dictionary to a dataframe 
text_annotated_df = pd.DataFrame(text_annotated)

# 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 1.4 Sekunden gedauert.

Auszug aus der Tabelle, in der der annotierte Text gespeichert ist:

# print first five lines of the annotation
text_annotated_df.head()
Token Lemma
0 BERLINER BERLINER
1
2 R R
3 M M
4 AHIRE AHIRE

3.4 Worthäufigkeit mit echter Tokenization#

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 = text_annotated_df.Lemma

# print the length
len(text_tokenized)
15062

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:
Hide code cell content
# 🚀 get the number of the word "Grippe" in the word frequencies 
token_frequencies["Grippe"]
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();
    console.log('Input value:', tok);

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

    var frequency = tok in word_freq ? word_freq[tok] : "0";
    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 ...

4. Annotation speichern#

Um den annotierten Text zu speichern, wird zuerst der Dateiname festgelegt. Dafür wird die Dateiendung ersetzt von .txt zu .csv.

CSV (comma-separated value) ist das Standardformat um tabellarische Daten im Klartext zu speichern.

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_path = Path.cwd() / text_path.with_suffix(".csv").name
# set output path, change file extension
output_path = Path(r"../data/csv") / text_path.with_suffix(".csv").name

Der Text wird dann unter dem festgelegten Dateinamen gespeichert.

# save the annotation as csv
text_annotated_df.to_csv(output_path, index=False)

5. Prozess für das gesamte Korpus ausführen#

def stream_texts_from_directory(corpus_filepaths: list[Path]) -> typing.Generator[str, None, None]:
    """A generator that yields texts from files in the file list one by one."""
    for filepath in corpus_filepaths:
            yield filepath.read_text()

def process_corpus(corpus_dir: Path, output_dir: Path) -> None:
    """
    Reads files from corpus_dir, annotates the files with spacy and writes the result
    to the output_dir
    :param Path corpus_dir: The directory in which the txt files are saved
    :param Path corpus_dir: The directory in which the annotations are written to as csv
    """
    corpus_filepaths = [f for f in corpus_dir.iterdir() if f.is_file() and f.suffix == ".txt"]

    start = time()
    for filepath, doc in zip(corpus_filepaths, nlp.pipe(stream_texts_from_directory(corpus_filepaths))):
        print(filepath)
        # Save the token and lemma information to a dictionary
        text_annotated = {}
        text_annotated['Token'] = [tok.text for tok in doc]
        text_annotated['Lemma'] = [tok.lemma_ for tok in doc]

        output_path = output_dir / filepath.with_suffix(".csv").name
        text_annotated.to_csv(output_path, index=False)
    end = time.time()
    
    print(f"""Processed {len(corpus_filepaths)} texts with spacy.
    Took {round((end - start)  / 60, 4)} minutes in total.""")
Informationen zum Ausführen des Notebooks – Zum Ausklappen klicken ⬇️ Im folgenden werden alle Textdateien im Korpus heruntergeladen und gespeichert. Dafür sind folgende Schritte nötig:
  1. Es wird eine List erstellt, die die URLs zu den einzelnen Textdateien beinhaltet.
  2. Die Liste wird als txt-Datei gespeichert.
  3. Alle Dateien aus der Liste werden heruntergeladen und in dem Ordner ../data/txt gespeichert.
Sollten die Dateien schon an einem anderen Ort vorhanden sein, können die Dateipfade zu den Ordnern angepasst werden.
Des Weiteren wird der Ordner für die annotierten Dateien angelegt: ../data/csv
Hide code cell content
# 🚀 Create download list 
github_api_txt_dir_path = "https://api.github.com/repos/dh-network/quadriga/contents/data/txt"
txt_dir_info = requests.get(github_api_txt_dir_path).json()
url_list = [entry["download_url"] for entry in txt_dir_info]

# 🚀 Write download list to the
url_list_path = Path("github_txt_file_urls.txt")
with url_list_path.open('w') as output_txt:
    output_txt.write("\n".join(url_list))
Hide code cell content
# ⚠️ Only execute, if you haven't downloaded the files yet!
# 🚀 Download all txt files – this step will take a while
! wget -i github_txt_file_urls.txt -P ../data/txt
Hide code cell content
# 🚀 Create output folder 
output_dir = Path(r"../data/csv")
if not output_dir.exists():
    output_dir.mkdir()
# Set path to corpus and output dir
corpus_dir = Path(r"../data/txt/")
output_dir = Path(r"../data/csv")

# Read, annotate, write 
process_corpus(corpus_dir, output_dir)