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

Hide code cell content
# Laden von Bibliotheken 
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

import json
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.

text_path = Path("../data/txt/SNP2719372X-19181015-0-0-0-0.txt")
text = text_path.read_text()
print(f"Textauszug:\n {text[10280:10400]}")
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.

words = text.split()

Prüfen: Wie sieht die Wortliste aus?

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?

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:

Hide code cell source
word_frequencies = dict(Counter(words))

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

# Convert the dictionary to a JSON string
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
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.

current = time()
doc = nlp(text)
text_annotated = {}
text_annotated['Token'] = [tok.text for tok in doc]
text_annotated['Lemma'] = [tok.lemma_ for tok in doc]
text_annotated_df = pd.DataFrame(text_annotated)
took = time() - current
print(f"Die Annotation hat {round(took, 2)} Sekunden gedauert.") 
Die Annotation hat 1.49 Sekunden gedauert.

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

Hide code cell source
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.

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

Hide code cell source
token_frequencies = Counter(text_tokenized)

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

output_path = Path(r"../data/csv") / text_path.with_suffix(".csv").name
text_annotated_df.to_csv(output_path, index=False)

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

def read_corpus_linewise(corpus_dir: Path) -> OrderedDict[str,str]:
    """
    Reads txt files from a given directory. Returns a dictionary with the filename
    as key and the txt file content as value.
    :param Path corpus_dir: The directory in which the txt files are saved
    :return OrderedDict[str, str]: The file names as keys, the file content as value
    """
    corpus = OrderedDict()
    for filepath in corpus_dir.iterdir():
        if filepath.suffix == ".txt":
            text = filepath.read_text()
            corpus[filepath.name] = text
    return corpus

def annotate_corpus(corpus: OrderedDict[str, str], disable_components: list[str]) -> dict[str, pd.DataFrame]:
    """
    Annotate a corpus (filename: text) with spacy. Collect the Token and Lemma information. 
    Save the annotation information as a pandas DataFrame. 
    :param OrderedDict[str, str] corpus: The file names as keys, the file content as value
    :param list[str] disable_components: spacy components to be diasbled in the annotation process
    :return dict[str, pd.DataFrame]: The file name as keys, the annotated text as value
    """
    # list to collect how long the annotation runs take in seconds
    took_per_text = []

    # define result dict
    corpus_annotated = {}
    
    filename_list = list(corpus.keys())
    current = time()
    
    # iterate over the corpus values, annotate them with spacy
    for i, doc in tqdm(enumerate(nlp.pipe(list(corpus.values()), disable=disable_components))):
        before = current
        current = time()
        took_per_text.append(current - before)

        # 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]

        # Save the annotation as pandas DataFrame to the result dict
        # Key is the current filename
        corpus_annotated[filename_list[i]] = pd.DataFrame(text_annotated)

    # print corpus size and performance
    print(f"""Processed {len(corpus_annotated)} texts with spacy.
    Took {round(np.mean(took_per_text), 4)} seconds per text on average.
    Took {round(np.sum(took_per_text) / 60, 4)} minutes in total.""")

    return corpus_annotated
# Korpus einlesen
corpus_dir = Path(r"../data/txt/")
corpus = read_corpus_linewise(corpus_dir)
print(f"Es wurden {len(corpus)} Dateien eingelesen.")

# Korpus annotieren
corpus_annotated = annotate_corpus(corpus, disable_components)

# Annotiertes Korpus speichern
output_dir = Path(r"../data/csv")
for filepath, text_annotated in corpus_annotated.items():
    filepath = Path(filepath)
    output_path = output_dir / filepath.with_suffix(".csv")
    text_annotated.to_csv(output_path, index=False)