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

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

5.2.2. Übersicht#

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

Dafür werden folgendene Schritte durchgeführt:

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

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

  3. Extraktion der Worthäufigkeiten und Plotten 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 (z.B. über den Command `wget` (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 pandas spacy tqdm plotly numpy

Hide code cell content

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
pio.renderers.default = "notebook"

5.2.3. Einlesen der Daten, Metadaten und der Grippe-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. Die Liste wird als txt-Datei gespeichert.
  3. 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")
if not corpus_dir.exists():
    corpus_dir.mkdir()

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]

# 🚀 Write download list as txt file
url_list_path = Path("github_csv_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 csv files – this step will take a while (ca. 7 minutes)
! wget -i github_csv_file_urls.txt -P ../data/spacy

Setzen des Pfads:

# 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 spacy
        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: 15.4598 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, so kommen wir zu einer Gesamtanzahl von 724 Texten.

Wie sieht der Anfang der ersten Datei aus?

annotated_docs[list(annotated_docs.keys())[1]][:15]
Token Lemma POS
0 Edmund Edmund PROPN
1 Hoefer Hoefer PROPN
2 Das der DET
3 Wyler Wyler ADJ
4 Schlößchen Schlößchen NOUN
5 Die der DET
6 rothen roth ADJ
7 Nelken Nelk NOUN
8 . -- PUNCT
9 Ein ein DET
10 Schrei Schrei NOUN
11 Erzählungen Erzählung NOUN
12 Stuttgart Stuttgart PROPN
13 . -- PUNCT
14 Verlag Verlag NOUN

Einlesen der Metadaten#

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 ../data/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 file from GitHub 
! wget https://raw.githubusercontent.com/quadriga-dk/Text-Fallstudie-1/refs/heads/main/data/metadata/QUADRIGA_FS-Text-01_Data01_Corpus-Table.csv -P ../data/metadata
# set path to metadata file
metadata_path_1 = '../metadata/metadata_corpus-german_language_fiction_1810-1900_50-per-decade.csv'
metadata_path_2 = '../metadata/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['year'] = pd.to_datetime(corpus_metadata['year'], format='%Y')
    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.

⚠️ Da für die Texte nur das Publikationsjahr vorliegt, für die Visualisierung aber ein Datum verwendet wird, wird das Datum in der Spalte year für jeden Text auf den 01.01. gesetzt.

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 
! wget https://raw.githubusercontent.com/quadriga-dk/Text-Fallstudie-3/refs/heads/main/wordlist/luft_semantisches_feld.txt -P ../wordlist
path_to_wordlist = Path("../wordlist/luft_semantisches_feld.txt")
semantic_field_nouns = list(set([word for word in path_to_wordlist.read_text().split("\n") if len(word) > 0]))

Wie sieht die Wortliste aus?

semantic_field_nouns[:20]
['Stickluft',
 'Luftzug',
 'Himmelsluft',
 'Luftdurchzug',
 'Ozon',
 'Zugluft',
 'Lufterschütterung',
 'Luftabzug',
 'Nebel',
 'Lufthauche',
 'Abendluft',
 'Qualmwolke',
 'Lufthieb',
 'Luftart',
 'Pestluft',
 'Faulluft',
 'Luftbad',
 'Luftfeuchtigkeit',
 'Qualm',
 'Luftton']

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

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:
    -----------
    spacy_docs : dict
        Dictionary with file_ids as keys and spaCy Doc objects as values
    metadata_df : pd.DataFrame
        DataFrame with columns: 'lastname', 'firstname', 'title', 'year', 'ID', '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 angucken#

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

Ergebnisse für Korpus II angucken#

show(freq_2_df[['lastname', 'firstname', 'DC.title', 'year', '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#

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)
    """
    # 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)
# Create scatter plot
plot_noun_list_scatter(freq_2_df, semantic_field_nouns)

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 angucken, die im Schnitt häufiger einmal oder mehr 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 Wort angezeigt. Dieses Wort ist maßgeblich für die Präsenz des semantischen Felds in diesem Text verantwortlich.

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):
    # 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 über Zeit',
        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.5. Schreiben der Ergebnisse#

Die Ergebnisse werden als Tabellen (.csv-Dateien) gespeichert, so kann die Erstellung der Abbildungen unbahängig von diesem Notebook nachvollzogen werden und die werden Ergebnisse so nachnutzbar.

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.6. 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 Subkorpora 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. eine Untersuchung der Naturdarstellung unternehmen könnte.

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 Importanz der Thematik ist.