5.2. 🚀 Analyse 1: Diachrone Frequenzdiagramme des semantischen Felds “Luft”#

Hinweise zur Ausführung des Notebooks

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

  1. Book-Only Mode: Sie lesen das Notebook hier im “Jupyter Book”, ohne den Code selbst auszuführen.

  2. Cloud Mode: Klicken Sie oben rechts in der Menüleiste auf das Raketen-Symbol 🚀 und wählen Sie “Colab”, um das Notebook auszuführen.

  3. Local Mode: Klicken Sie oben rechts in der Menüleiste auf das Download-Symbol und wählen Sie “.ipynb”, um das Notebook lokal auszuführen.

5.2.1. Übersicht#

Im Folgenden werden die annotierten Dateien (CSV-Format) analysiert. Unser Ziel ist es, die Wort-/Lemma-Häufigkeiten des semantischen Felds “Luft” im Verlauf der Zeit zu analysieren und zu visualisieren, um festzustellen, ob es, parallel zur industriellen Revolution, einen Anstieg im Auftreten des Felds gibt.

Wir führen diese Analyse auf unseren beiden Korpora durch – Korpus I und Korpus II, den beiden Zufallsstichproben, die wir im Abschnitt Sampling und Filterung des Korpus gezogen haben. Beide Korpora werden parallel ausgewertet und die Ergebnisse anschließend miteinander verglichen, um die Robustheit der Befunde einzuschätzen.

Dafür werden folgendene Schritte durchgeführt:

  1. Einlesen des Korpus, der Metadaten-Dateien für Korpus I und II und des semantischen Felds “Luft”

  2. Extraktion der Worthäufigkeiten und Visualisieren der Worthäufigkeiten für Korpus I

  3. Extraktion der Worthäufigkeiten und Visualisieren der Worthäufigkeiten für Korpus II

  4. Diskussion der Ergebnisse

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

Voraussetzungen zur Ausführung des Jupyter Notebooks

  1. Installieren der Bibliotheken
  2. Pfad zu den Daten setzen
  3. Laden der Daten (s.u.)
Zum Testen: Ausführen der Zelle "load libraries" und der Sektion "Einlesen der Daten".
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 requests pandas spacy scipy tqdm plotly numpy itables ipywidgets

Hide code cell content

import sys
import re
import requests
from pathlib import Path
from typing import Dict, List, Union, Tuple

import pandas as pd
from time import time
from tqdm.auto import tqdm
from itables import show
from scipy.signal import savgol_filter
import numpy as np

import plotly.express as px
import plotly.graph_objects as go
import plotly.io as pio

if 'google.colab' in sys.modules:
  pio.renderers.default = "colab"
else:
  pio.renderers.default = "notebook"

Bevor wir Daten herunterladen, definieren wir eine kleine Hilfsfunktion download_file. Sie lädt eine Datei plattformunabhängig – also z.B. unter Windows, macOS oder Linux – aus dem Internet in einen Zielordner herunter und ersetzt damit das Kommando wget, das nicht auf allen Systemen verfügbar ist.

# helper: download a single file (cross-platform replacement for `! wget -P`)

def download_file(url, target_dir):
    """Download the file at `url` into `target_dir`, keeping its original name."""
    target_dir = Path(target_dir)
    target_dir.mkdir(parents=True, exist_ok=True)
    target_path = target_dir / url.split("/")[-1]
    response = requests.get(url)
    response.raise_for_status()
    target_path.write_bytes(response.content)
    return target_path

5.2.2. Einlesen der Daten, Metadaten und der Luft-Wortliste#

Um eine/mehrere Dateien mit Python bearbeiten zu können, müssen die Dateien zuerst ausgewählt werden, d.h der Pfad zu den Dateien wird gesetzt, und dann eingelesen werden.

Einlesen des Korpus (CSV-Dateien)#

Informationen zum Ausführen des Notebooks – Zum Ausklappen klicken ⬇️ Zuerst wird der Ordner angelegt, in dem die CSV-Dateien gespeichert werden. Der Einfachheit halber wird die gleich Datenablagestruktur wie in dem GitHub Repository, in dem die Daten gespeichert sind, vorausgesetzt.
Danach werden alle CSV-Dateien im Korpus heruntergeladen und gespeichert. Dafür sind folgende Schritte nötig:
  1. Es wird eine Liste erstellt, die die URLs zu den einzelnen CSV-Dateien beinhaltet.
  2. Alle Dateien aus der Liste werden heruntergeladen und in dem Ordner ../data/csv gespeichert.
Sollten die Dateien schon an einem anderen Ort vorhanden sein, können die Dateipfade zu den Ordnern angepasst werden.

Hide code cell content

# 🚀 Create data directory path
corpus_dir = Path("../data/csv")
corpus_dir.mkdir(parents=True, exist_ok=True)

Hide code cell content

# 🚀 Create download list 
github_api_txt_dir_path = "https://api.github.com/repos/quadriga-dk/Text-Fallstudie-3/contents/data/csv"
txt_dir_info = requests.get(github_api_txt_dir_path).json()
url_list = [entry["download_url"] for entry in txt_dir_info]

⚠️ Achtung – dieser Schritt dauert einige Minuten: Die folgende Code-Zelle lädt alle CSV-Dateien des Korpus aus GitHub herunter. Das dauert eine Weile (ca. 7 Minuten). Führen Sie die Zelle nur aus, wenn Sie die Dateien noch nicht heruntergeladen haben.

Hide code cell content

# ⚠️ Only execute, if you haven't downloaded the files yet!
# 🚀 Download all csv files – this step will take a while (ca. 7 minutes)
for url in tqdm(url_list, desc="Downloading CSV files"):
    download_file(url, "../data/csv")

Setzen des Pfads zu den CSV-Dateien:

Informationen zum Aufbau des Pfades – Zum Ausklappen klicken ⬇️ Die Variable corpus_dir enthält den Pfad zu dem Ordner, in dem die CSV-Dateien des Korpus liegen. Der Pfad ../data/csv ist relativ zum Ablageort dieses Notebooks angegeben: .. steht für den übergeordneten Ordner (also eine Ebene über corpus_analysis/), von dort wird in den Unterordner data/csv gewechselt. Das setzt die gleiche Datenablagestruktur wie in dem GitHub Repository voraus.
Sollten die Dateien bei Ihnen an einem anderen Ort liegen, passen Sie den Pfad in der Variable corpus_dir entsprechend an.
# set the path to csv files to be processed
corpus_dir = Path(r"../data/csv")

Einlesen der CSV-Dateien

annotated_docs = {}
start = time()
for fp in tqdm(corpus_dir.iterdir(), desc="Reading annotated data"):
    # check if the entry is a file, not a directory
    if fp.is_file():
        # check if the file has the correct suffix csv
        if fp.suffix == '.csv':
            df = pd.read_csv(fp)
            annotated_docs[fp.stem] = df
took = time() - start
print(f"Loading the data took: {round(took, 4)} seconds") 
Loading the data took: 14.6312 seconds

Wie viele Dateien wurden eingelesen?

len(annotated_docs)
724

Da wir die Dateien für beide Korpora zusammen einlesen und sie erst später anhand der Metadaten in die zwei Korpora aufteilen, liegt die Anzahl der Korpusdateien bei 724. Jedes Korpus besteht aus 450 Texten, davon überschneiden sich 176 Texte (wie bereits in Vergleich der Überlappung zwischen den Stichproben gezeigt), so kommen wir zu einer Gesamtanzahl von 724 Texten.

Warum lesen wir die Dateien überhaupt gemeinsam ein, statt jedes Korpus für sich? Der Grund liegt genau in dieser Überschneidung: Würden wir die beiden Korpora getrennt verarbeiten, müssten wir die 176 gemeinsamen Texte doppelt speichern und einlesen. Stattdessen halten wir alle Texte nur einmal in einem gemeinsamen Verzeichnis vor und lesen sie in ein einziges Dictionary (annotated_docs) ein. Welche Datei zu Korpus I bzw. Korpus II gehört, entscheidet sich erst bei der Analyse: Über die jeweilige Metadaten-Tabelle greifen wir gezielt auf die zugehörigen Texte aus diesem gemeinsamen Bestand zu (anhand der Spalte DC.identifier). Das spart Speicherplatz und Rechenzeit und hält den Einlese-Code einfach.

Wie sieht der Anfang einer eingelesenen Datei aus?

Mit der folgenden Zeile lassen wir uns die ersten 15 Zeilen einer der annotierten Tabellen anzeigen. Den Ausdruck liest man am besten von innen nach außen:

  • annotated_docs.keys() liefert die Schlüssel des Dictionaries, also die Dateinamen aller eingelesenen Texte.

  • list(...) wandelt diese Schlüssel in eine Liste um, sodass wir über ihre Position darauf zugreifen können.

  • [1] wählt aus dieser Liste den Schlüssel an Position 1 aus. Da Python ab 0 zählt, ist das der zweite Text (Position [0] wäre der erste).

  • annotated_docs[...] holt zu diesem Schlüssel die zugehörige Annotationstabelle – ein DataFrame, in dem jede Zeile einem Token entspricht.

  • [:15] schneidet davon die ersten 15 Zeilen heraus, also die ersten 15 Token.

Möchten Sie sich einen anderen Text ansehen, ändern Sie einfach die Zahl in den eckigen Klammern (z.B. [0] für den ersten oder [2] für den dritten Text). Mit der Zahl hinter dem Doppelpunkt ([:15]) steuern Sie, wie viele Token angezeigt werden.

annotated_docs[list(annotated_docs.keys())[1]][:15]
Token Lemma POS
0 Georg Georg PROPN
1 Ebers Eber PROPN
2 Homo -- X
3 sum sum X
4 Homo -- X
5 sum sum VERB
6 : -- PUNCT
7 humani Humani PROPN
8 nil nil PROPN
9 a a PROPN
10 me me X
11 alienum Alienum NOUN
12 puto Puto VERB
13 . -- PUNCT
14 Terenz Terenz PROPN

Einlesen der Metadaten#

Wo kommen diese Metadaten-Dateien her? Es handelt sich nicht um Rohdaten aus einer externen Quelle, sondern um Zwischenergebnisse, die wir in dieser Fallstudie selbst erzeugt haben. Im Abschnitt „Sampling und Filterung des Korpus“ sind wir von der vollständigen Metadatentabelle des Corpus of German-Language Fiction ausgegangen, haben sie auf das 19. Jahrhundert eingegrenzt und daraus pro Jahrzehnt jeweils bis zu 50 Texte zufällig gezogen – einmal für Korpus I und mit einem anderen Zufallszustand ein zweites Mal für Korpus II. Das Ergebnis dieser beiden Ziehungen – zwei zeitlich balancierte Stichproben – wurde dort mit to_csv als Tabelle gespeichert (die Dateien …50-per-decade.csv und …50-per-decade_ALT.csv). Genau diese beiden Dateien laden wir jetzt wieder ein, damit die Analyse auf demselben, reproduzierbar zusammengestellten Korpus aufsetzt.

Informationen zum Ausführen des Notebooks – Zum Ausklappen klicken ⬇️ Zuerst wird der Ordner angelegt, in dem die Metadaten-Datei gespeichert wird. Wieder wird die gleich Datenablagestruktur wie in dem GitHub Repository vorausgesetzt.
Der Text wird aus GitHub heruntergeladen und in dem Ordner ../metadata/ abgespeichert.
Der Pfad kann in der Variable metadata_path angepasst werden. Die einzulesende Datei muss die Endung `.csv` haben.

Hide code cell content

# 🚀 Create metadata directory path
metadata_dir = Path("../metadata")
if not metadata_dir.exists():
    metadata_dir.mkdir()

Hide code cell content

# 🚀 Load the metadata files from GitHub 
download_file("https://raw.githubusercontent.com/quadriga-dk/Text-Fallstudie-3/refs/heads/main/metadata/metadata_corpus-german_language_fiction_1810-1900_50-per-decade.csv", "../metadata")
download_file("https://raw.githubusercontent.com/quadriga-dk/Text-Fallstudie-3/refs/heads/main/metadata/metadata_corpus-german_language_fiction_1810-1900_50-per-decade_ALT.csv", "../metadata")
# set path to metadata file
metadata_path_1 = metadata_dir / "metadata_corpus-german_language_fiction_1810-1900_50-per-decade.csv"
metadata_path_2 = metadata_dir / "metadata_corpus-german_language_fiction_1810-1900_50-per-decade_ALT.csv"

def read_replace_metadata(fp):
    corpus_metadata = pd.read_csv(fp)
    corpus_metadata = corpus_metadata.fillna("-")
    return corpus_metadata

# read metadata file to pandas dataframe
corpus_metadata_1 = read_replace_metadata(metadata_path_1)
corpus_metadata_2 = read_replace_metadata(metadata_path_2)

Wie sieht die Metadaten-Datei aus? Hier können Sie nach Texten suchen und die Tabelle nach den Spalten sortieren, z.B. um zu erfahren, welches der frühste Text in Korpus I ist.

⚠️ Für die Texte liegt nur das Publikationsjahr vor (kein genaues Datum). Es steht als ganze Zahl in der Spalte DC.date (dem Dublin-Core-Feld für das Publikationsjahr).

show(corpus_metadata_1)
Loading ITables v2.6.1 from the internet... (need help?)

Einlesen der Wortliste (Semantisches Feld “Luft”)#

Informationen zum Ausführen des Notebooks – Zum Ausklappen klicken ⬇️ Parallel zur Metadaten-Datei wird ein Ordner für die Wortlisten-Datein angelegt, die Datei wird aus GitHub geladen und in dem erstellten Ordner abgelegt.

Hide code cell content

# 🚀 Create word list directory path
wordlist_dir = Path("../wordlist")
if not wordlist_dir.exists():
    wordlist_dir.mkdir()

Hide code cell content

# 🚀 Load the wordlist file from GitHub 
download_file("https://raw.githubusercontent.com/quadriga-dk/Text-Fallstudie-3/refs/heads/main/wordlist/luft_semantisches_feld.txt", "../wordlist")
path_to_wordlist = Path("../wordlist/luft_semantisches_feld.txt")
semantic_field_nouns = list(set([word for word in path_to_wordlist.read_text(encoding="utf-8").split("\n") if len(word) > 0]))

Wie sieht die Wortliste aus?

semantic_field_nouns[:20]
['Moderluft',
 'Sauerstoff',
 'Atmosphäre',
 'Luftkur',
 'Zugluft',
 'Luftverdichtung',
 'Luftverdünnung',
 'Fieberluft',
 'Höhenluft',
 'Bergluft',
 'Luftbeschaffenheit',
 'Lüftung',
 'Luftwechsel',
 'Luftbewegung',
 'Landluft',
 'Grubenluft',
 'Luftwärme',
 'Kellerluft',
 'Nachtluft',
 'Lufterschütterung']

5.2.3. Häufigkeiten der Wörter im semantischen Feld berechnen#

Um die Präsenz des semantischen Felds “Luft” über die Zeit zu messen, definieren wir zwei Hilfsfunktionen. extract_noun_list_counts durchläuft alle Texte eines Korpus und zählt für jeden Text, wie oft die Wörter aus unserer Wortliste (semantic_field_nouns) vorkommen; das Ergebnis wird mit den bibliografischen Metadaten zu einer Tabelle zusammengeführt. get_relative_frequencies berechnet daraus für jeden Text die relative Häufigkeit – die Treffer aus dem semantischen Feld geteilt durch die Gesamtzahl der Tokens im Text, mal 100, also Treffer pro 100 Tokens (relative_frequency). So lassen sich Texte unterschiedlicher Länge miteinander vergleichen: Ein hoher Wert bedeutet, dass das semantische Feld in diesem Text besonders präsent ist.

def extract_noun_list_counts(annotated_docs: Dict, metadata_df: pd.DataFrame, 
                                  noun_list: List[str]) -> pd.DataFrame:
    """
    Calculate the combined relative frequency of a list of nouns for each text.
    
    Parameters:
    -----------
    annotated_docs : dict
        Dictionary with file_ids as keys and tables (DataFrames) with annotations as values
    metadata_df : pd.DataFrame
        DataFrame with columns: 'lastname', 'firstname', 'DC.title', 'DC.date', 'volume',
       'DC.identifier', 'decade'
    noun_list : list of str
        List of noun lemmata to count together
    
    Returns:
    --------
    pd.DataFrame
        DataFrame with columns: filename, title, year, total_freq, total_count, total_tokens
    """
    results = []
    
    for idx, meta_row in metadata_df.iterrows(): #changed from metadata_df.itertuples() to metadata_df.iterrows() to accommodate for our conventional DC names like 'DC.title' (this breaks the .itertuples() method)
        file_id = meta_row['DC.identifier']
        
        if file_id in annotated_docs:
            doc = annotated_docs[file_id]
        else:
            print(f"File {file_id} not in the corpus. Skipping...")
            continue
        
        # Count total tokens
        total_tokens = len(doc)

        # Skip empty texts
        if total_tokens == 0:
            continue
            
        # Count occurrences in the list
        lemma_counts = doc.Lemma.value_counts()
        counts = lemma_counts.reindex(noun_list, fill_value=0)
        specific_nouns = pd.DataFrame([counts.values], columns=counts.index)
        
        specific_nouns['DC.identifier'] = file_id
        specific_nouns['total_count_tokens'] = total_tokens
        results.append(specific_nouns)
    combined_result = pd.concat(results, ignore_index=True)
    metadata_result = pd.merge(metadata_df, combined_result, on="DC.identifier")
    
    return metadata_result

def get_relative_frequencies(df, semantic_field_nouns):
    df['total_count_semantic_field'] = df[semantic_field_nouns].sum(axis=1)
    df['relative_frequency'] = (df['total_count_semantic_field'] / df['total_count_tokens'])*100
    return df
# Extract frequencies for Corpus I and II
count_1_df = extract_noun_list_counts(annotated_docs, corpus_metadata_1, semantic_field_nouns)
count_2_df = extract_noun_list_counts(annotated_docs, corpus_metadata_2, semantic_field_nouns)
# Calculate relative frequencies for Corpus I and II
freq_1_df = get_relative_frequencies(count_1_df, semantic_field_nouns)
freq_2_df = get_relative_frequencies(count_2_df, semantic_field_nouns)
show(count_1_df)
Loading ITables v2.6.1 from the internet... (need help?)

Ergebnisse für Korpus I sichten#

Die folgende Tabelle zeigt für jeden Text aus Korpus I die bibliografischen Angaben (Autor:in, Titel, Erscheinungsjahr) sowie die berechneten Kennzahlen total_count_tokens, total_count_semantic_field und relative_frequency. Sie können die Tabelle nach einzelnen Spalten sortieren, etwa um die Texte mit dem stärksten Bezug zum semantischen Feld zu finden.

show(freq_1_df[['lastname', 'firstname', 'DC.title', 'DC.date', 'total_count_tokens', 'total_count_semantic_field', 'relative_frequency']])
Loading ITables v2.6.1 from the internet... (need help?)

Ergebnisse für Korpus II sichten#

Dieselben Kennzahlen zeigt die folgende Tabelle für Korpus II; sie lässt sich auf die gleiche Weise lesen und sortieren.

show(freq_2_df[['lastname', 'firstname', 'DC.title', 'DC.date', 'total_count_tokens', 'total_count_semantic_field', 'relative_frequency']])
Loading ITables v2.6.1 from the internet... (need help?)

Häufigkeiten als Streudiagramm mit Trend-Linie darstellen#

Um die Entwicklung über die Zeit sichtbar zu machen, definieren wir die Funktion plot_noun_list_scatter. Sie stellt für jeden Text einen Punkt in einem Streudiagramm dar: auf der x-Achse das Erscheinungsjahr, auf der y-Achse die relative Häufigkeit des semantischen Felds. Zusätzlich wird eine lineare Trendlinie durch alle Punkte gelegt, die die grobe Richtung der Entwicklung über das Jahrhundert anzeigt.

def plot_noun_list_scatter(freq_df: pd.DataFrame, noun_list: List[str], 
                          show_trendline: bool = True, verbose: bool = False):
    """
    Create a scatter plot showing the combined frequency of a noun list over time.
    
    Parameters:
    -----------
    freq_df : pd.DataFrame
        DataFrame returned by extract_noun_list_frequencies()
    noun_list : list of str
        The list of nouns being analyzed (for the text)
    show_trendline : bool
        If True, add a linear regression trendline (default: True)
    verbose : bool
        If True, print diagnostic information about the trendline (default: False)
    
    Returns:
    --------
    plotly.graph_objects.Figure
        The figure object (will display automatically in Jupyter)
    """
    # DC.date is a plain integer year; rebuild a datetime column locally so the
    # x-axis renders as a proper time axis (identical to before). Tables keep integer DC.date.
    freq_df = freq_df.copy()
    freq_df['year'] = pd.to_datetime(freq_df['DC.date'], format='%Y')

    # Create scatter plot
    fig = go.Figure()
    
    fig.add_trace(go.Scatter(
        x=freq_df['year'],
        y=freq_df['relative_frequency'],
        mode='markers',
        name='Texts',
        text=freq_df['DC.title'],
        customdata=np.column_stack((freq_df['total_count_semantic_field'], freq_df['lastname'])),
        hovertemplate='<b>%{text}</b> (%{customdata[1]})<br>' +
                     'Year: %{x|%Y}<br>' +
                     'Frequency: %{y:.2f} per 100 tokens<br>' +
                     'Total count: %{customdata[0]}<br>' +
                     '<extra></extra>',
        marker=dict(
            size=8,
            color='steelblue',
            opacity=0.7,
            line=dict(width=1, color='white')
        )
    ))
    
    # Add trendline if requested
    if show_trendline:
        # Calculate linear regression
        
        
        # Convert year to numeric, handling datetime objects
        if pd.api.types.is_datetime64_any_dtype(freq_df['year']):
            # If datetime, extract the year
            x = freq_df['year'].dt.year.values.astype(float)
        else:
            # Otherwise convert to numeric
            x = pd.to_numeric(freq_df['year'], errors='coerce').values
        
        y = freq_df['relative_frequency'].values
        
        # Remove any NaN values
        valid_idx = ~(np.isnan(x) | np.isnan(y))
        x = x[valid_idx]
        y = y[valid_idx]
        
        if verbose:
            print(f"Valid data points for trendline: {len(x)}")
            print(f"Year range: {x.min():.0f} to {x.max():.0f}")
            print(f"Frequency range: {y.min():.2f} to {y.max():.2f}")
        
        if len(x) > 1:  # Need at least 2 points for a line
            # Fit line: y = mx + b
            m, b = np.polyfit(x, y, 1)
            
            if verbose:
                print(f"Trendline equation: y = {m:.6f}x + {b:.4f}")
                print(f"Slope interpretation: {'increasing' if m > 0 else 'decreasing' if m < 0 else 'flat'} trend")
            
            # Create trendline
            x_trend = np.array([x.min(), x.max()])
            y_trend = m * x_trend + b
            
            fig.add_trace(go.Scatter(
                x=x_trend,
                y=y_trend,
                mode='lines',
                name='Trend',
                line=dict(color='red', width=2, dash='dash'),
                hovertemplate='Trendline<br>Year: %{x}<br>%{y:.2f}<extra></extra>'
            ))
        else:
            if verbose:
                print("Warning: Not enough valid data points to draw trendline (need at least 2)")
    
    # Create a readable noun list for the title
    noun_list = list(noun_list)
    noun_list_str = ', '.join(noun_list[:5])
    if len(noun_list) > 5:
        noun_list_str += f', ... ({len(noun_list)} total)'
    
    # Update layout
    fig.update_layout(
        title=f'Relative Frequenz des semantischen Felds "Luft"',
        xaxis_title='Jahr',
        yaxis_title='Relative Frequenz (pro 100 Tokens)',
        hovermode='closest',
        height=600,
        showlegend=show_trendline
    )
    
    return fig
# Create scatter plot
plot_noun_list_scatter(freq_1_df, semantic_field_nouns)

Tipp: Die Diagramme sind interaktiv

Fahren Sie mit der Maus über einen Punkt, um das zugehörige Werk (Titel, Autor:in und Werte) anzuzeigen. Über die Legende rechts lassen sich einzelne Linien bzw. Wörter per Mausklick ein- und ausblenden. Sie können außerdem in das Diagramm hineinzoomen und den sichtbaren Ausschnitt verschieben.

# Create scatter plot
plot_noun_list_scatter(freq_2_df, semantic_field_nouns)

In beiden Streudiagrammen steigt die Trendlinie über das Jahrhundert nur leicht an; ein deutlicher Aufwärts- oder Abwärtstrend ist nicht erkennbar. Auffällig sind jedoch einzelne Ausreißer, insbesondere gegen Ende des Jahrhunderts, in denen die relative Häufigkeit deutlich über dem sonst üblichen Bereich liegt.

Entwicklung der häufigsten Wörter des semantisches Felds anzeigen#

Es kommen manche Wörter des semantischen Felds sehr häufig und andere gar nicht vor. Im Folgenden wollen wir uns die Wörter im semantischen Feld genauer betrachten, die im Schnitt mindestens einmal auf 100 Wörter vorkommen. So können wir feststellen, welche Wörter das semantische Feld ausmachen.

Im folgenden Streudiagramm ist jeder Punkt wieder ein Text, allerdings wird nun die relative Häufigkeit eines Wortes angezeigt. Dieses Wort ist maßgeblich für die Präsenz des semantischen Felds in diesem Text verantwortlich.

Dafür filtert filter_df_by_mean_threshold die Wortliste auf jene Wörter, deren mittlere Häufigkeit einen Schwellenwert übersteigt (standardmäßig mindestens 1 Treffer pro 100 Tokens). plot_single_terms berechnet anschließend für jedes verbleibende Wort die relative Häufigkeit pro Text und stellt sie in einem Streudiagramm dar, mit je einer eigenen Trendlinie pro Wort.

def filter_df_by_mean_threshold(df, semantic_field_nouns, threshold=1):
    # Find which checked columns pass the threshold
    passing_cols = [col for col in semantic_field_nouns if df[col].mean() > threshold]
    
    # Keep passing columns plus all columns that weren't checked
    other_cols = [col for col in df.columns if col not in semantic_field_nouns]
    df_filtered = df[other_cols + passing_cols].copy()
    return df_filtered


def plot_single_terms(df_filtered, semantic_field_nouns):
    # DC.date is a plain integer year; rebuild a datetime column locally for the time axis
    df_filtered = df_filtered.copy()
    df_filtered['year'] = pd.to_datetime(df_filtered['DC.date'], format='%Y')

    # Calculate relative frequencies for each term
    terms = [col_name for col_name in df_filtered.columns if col_name in semantic_field_nouns]
    for term in terms:
        df_filtered[f'{term}_relative'] = (df_filtered[term] / df_filtered['total_count_tokens']) * 100
    
    # Melt the dataframe to long format
    plot_data = df_filtered.melt(
        id_vars=['year', 'DC.title', 'lastname', 'total_count_tokens'],
        value_vars=[f'{term}_relative' for term in terms],
        var_name='Term',
        value_name='Relative_Frequency'
    )
    
    # Clean up term names
    plot_data['Term'] = plot_data['Term'].str.replace('_relative', '')
    
    fig = go.Figure()
    
    colors = ['steelblue', 'coral', 'seagreen', 'mediumpurple', 'goldenrod']
    term_colors = dict(zip(terms, colors))
    
    for term in terms:
        term_data = plot_data[plot_data['Term'] == term].sort_values('year')
        
        # Add scatter points
        fig.add_trace(go.Scatter(
            x=term_data['year'],
            y=term_data['Relative_Frequency'],
            mode='markers',
            name=term,
            text=term_data['DC.title'],
            customdata=term_data['lastname'],
            hovertemplate='<b>%{text}</b> (%{customdata})<br>' +
                         'Year: %{x|%Y}<br>' +
                         f'{term} Frequency: ' + '%{y:.4f} per 100 tokens<br>' +
                         '<extra></extra>',
            marker=dict(size=6, color=term_colors[term], opacity=0.5),
            showlegend=True
        ))
        
        # Add linear trend line (if enough data points)
        if len(term_data) > 2:
            try:
                # Convert year to numeric for regression
                x = term_data['year'].astype(int).values
                y = term_data['Relative_Frequency'].values
                
                # Calculate linear regression
                coeffs = np.polyfit(x, y, 1)
                trend_line = np.polyval(coeffs, x)
                
                # Calculate MSE
                mse = np.mean((y - trend_line) ** 2)
                
                fig.add_trace(go.Scatter(
                    x=term_data['year'],
                    y=trend_line,
                    mode='lines',
                    name=f'{term} (Trend)',
                    line=dict(color=term_colors[term], width=2, dash='dash'),
                    showlegend=True,
                    hovertemplate=f'Linearer Trend<br>MSE: {mse:.6f}<extra></extra>'
                ))
            except:
                pass
    
    fig.update_layout(
        title='Relative Frequenz des gefilterten semantischen Felds im Zeitverlauf',
        xaxis_title='Jahr',
        yaxis_title='Frequenz pro 100 Tokens',
        height=600
    )
    
    fig.show()
filtered_1 = filter_df_by_mean_threshold(freq_1_df, semantic_field_nouns)
plot_single_terms(filtered_1, semantic_field_nouns)
filtered_2 = filter_df_by_mean_threshold(freq_2_df, semantic_field_nouns)
plot_single_terms(filtered_2, semantic_field_nouns)

5.2.4. Schreiben der Ergebnisse#

Die Ergebnisse werden als Tabellen (.csv-Dateien) gespeichert, so kann die Erstellung der Abbildungen unabhängig von diesem Notebook nachvollzogen werden und die Ergebnisse werden so nachnutzbar. Der folgende Code legt dazu einen Unterordner results/ in demselben Ordner an, in dem dieses Notebook liegt (also corpus_analysis/), und speichert die beiden CSV-Tabellen dort ab.

result_dir = Path("results/")
if not result_dir.exists():
    result_dir.mkdir()

result_path_1 = result_dir / "results-corpus-german_language_fiction_1810-1900_50-per-decade-I.csv"
result_path_2 = result_dir / "results-corpus-german_language_fiction_1810-1900_50-per-decade-II.csv"

freq_1_df.to_csv(result_path_1, index=False)
freq_2_df.to_csv(result_path_2, index=False)

5.2.5. Diskussion der Häufigkeitsanalyse#

Die Analyse der relativen Häufigkeiten zeigt, dass deutschsprachige literarische Texte des 19. Jahrhunderts nicht vermehrt über Luft und Luft-verwandte Begriffe sprechen, sondern der Trend in beiden Korpora zwar leicht ansteigt, der Anstieg allerdings nicht maßgeblich ist. Es gibt Ausreißer wie z.B. Das Dorf im Gebirge (von Hofmannsthal) oder Das Schattenspiel. Eine Morgenwanderung von Flaischlen, die beide Natur zum Thema haben. Daraus ließe sich ein Ansatz für weitere Analysen ableiten, die z.B. die Naturdarstellung genauer untersuchen könnten.

Es gibt mehrere Deutungsansätze dieses Ergebnisses: Zum einen kann es sein, dass deutschsprachige Literatur die durch die industrielle Revolution herbeigeführte Veränderung der Luftqualität tatsächlich nicht reflektiert. Zum anderen ist es möglich, dass unsere Operationalisierung zu kurz gegriffen ist, z.B. da abnehmende Luftqualität subtiler angedeutet werden könnte, das semantische Feld nicht spezifisch bzw. generell genug ist oder die relative Häufigkeit keine gute Metrik für die Relevanz der Thematik ist.