5.4. 🚀 Analyse syntaktischer N-Gramme#

5.4.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.4.2. Übersicht#

Im Folgenden wird das Korpus mithilfe syntaktischer n-Gramme analysiert. Ziel dieser Analyse ist es, wiederkehrende grammatische Muster (insbesondere adjektivische Modifikationen von Substantiven) über Zeit zu untersuchen und damit über rein lexikalische Häufigkeiten hinauszugehen. Im Unterschied zur Analyse semantischer Felder stehen hier strukturierte, syntaktisch motivierte Wortkombinationen im Fokus, die auf Abhängigkeitsbeziehungen basieren.

Konkret wird untersucht, wie häufig bestimmte syntaktische Konstruktionen (z. B. Adjektiv → Substantiv-Relationen) im Korpus auftreten und wie sich deren Vorkommen zeitlich entwickelt. Dies erlaubt es, stilistische und diskursive Entwicklungen sichtbar zu machen, etwa Veränderungen in der Beschreibung bestimmter Konzepte über längere Zeiträume hinweg.

Dazu werden die folgenden Schritte durchgeführt:

  1. Einlesen des Korpus, der Metadaten sowie der bereits erzeugten spaCy-Annotationen

  2. Auswahl relevanter Dependency-Relationen zur Bildung syntaktischer n-Gramme

  3. Extraktion syntaktischer n-Gramme auf Basis der vorhandenen Abhängigkeitsinformationen

  4. Identifikation von Ausreißertexten, die auffällige Peaks in den Zeitreihen (mit-)verursachen

  5. KWIC-/Kontextansicht ausgewählter Belegstellen aus diesen Texten zur qualitativen Einordnung der syntaktischen Muster

  6. Diskussion der Ergebnisse

5.4.3. Import der Bibliotheken#

Hide code cell content

from pathlib import Path
import re
from typing import Dict, List, Union, Tuple
from collections import OrderedDict, Counter
from time import time
import numpy as np
import pandas as pd
import plotly.express as px
import plotly.io as pio
pio.renderers.default = "notebook"
import plotly.graph_objects as go
from itables import show
from tqdm import tqdm
import spacy
from spacy.tokens import DocBin, Doc

Laden des spaCy-Modells

Hide code cell content

! python -m spacy download de_core_news_sm
Collecting de-core-news-sm==3.8.0
  Using cached https://github.com/explosion/spacy-models/releases/download/de_core_news_sm-3.8.0/de_core_news_sm-3.8.0-py3-none-any.whl (14.6 MB)
[notice] A new release of pip is available: 25.3 -> 26.0.1
[notice] To update, run: pip install --upgrade pip
✔ Download and installation successful
You can now load the package via spacy.load('de_core_news_sm')

Hide code cell content

nlp = spacy.load("de_core_news_sm")

5.4.4. Laden der Annotationen#

In diesem Schritt werden die zuvor erzeugten spaCy-Annotationen aus dem Dateisystem eingelesen. Die gespeicherten DocBin-Dateien werden zu vollständigen spaCy.Doc-Objekten rekonstruiert und in einer Datenstruktur abgelegt, die eine weitere Analyse erlaubt.

# Bei Verwendung eines anderen Korpus hier den Verzeichnisnamen anpassen
annotation_dir = Path("../data/spacy")

if not annotation_dir.exists():
    print("The directory does not exist, please check the path again.")

Hide code cell content

# Create dictionary to save the corpus data (filenames and tables)
annotated_docs = {}

start = time()
# Iterate over spacy files
for fp in tqdm(annotation_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 == '.spacy':
            print( f"Loading file: {fp.name}" )
            # load spacy DocBin objects
            doc_bin = DocBin().from_disk(fp)
            chunk_docs = list(doc_bin.get_docs(nlp.vocab))
            # merge bins into one single document
            full_doc = Doc.from_docs(chunk_docs)

            # save the data frame to the dictionary, key=filename (without suffix), value=spacy.Doc
            annotated_docs[fp.stem] = full_doc
took = time() - start
print(f"Loading the data took: {round(took, 4)} seconds")

Hide code cell content

print(f"Annotations of first 20 lines of the text: {list(annotated_docs.keys())[0]}:\n")
print("Token\tLemma\tPoS")
for token in annotated_docs[list(annotated_docs.keys())[0]][:20]:
    print(f"{token.text}\t{token.lemma_}\t{token.pos_}")

5.4.5. Metadaten einlesen#

Anschließend werden die zugehörigen Metadaten geladen und auf diejenigen Texte beschränkt, für die Annotationen vorliegen. Die zeitlichen Angaben werden in ein geeignetes Datumsformat überführt, um spätere zeitbasierte Aggregationen und Visualisierungen zu erleichtern.

metadata_df = pd.read_csv("../metadata/metadata_corpus-german_language_fiction_1820-1900_50-per-decade.csv")
# metadata_df = metadata_df[metadata_df['ID'].isin(annotated_docs.keys())]
# Datentyp der Datumsspalte für eine einfachere Weiterverarbeitung ändern
metadata_df['year'] = pd.to_datetime(metadata_df['year'], format="%Y")
show(metadata_df)
Loading ITables v2.6.1 from the internet... (need help?)
metadata_df_alt = pd.read_csv("../metadata/metadata_corpus-german_language_fiction_1820-1900_50-per-decade_ALT.csv")
# metadata_df_alt = metadata_df_alt[metadata_df_alt['ID'].isin(annotated_docs.keys())]
# Datentyp der Datumsspalte für eine einfachere Weiterverarbeitung ändern
metadata_df_alt['year'] = pd.to_datetime(metadata_df_alt['year'], format="%Y")
show(metadata_df_alt)
Loading ITables v2.6.1 from the internet... (need help?)

5.4.6. Syntaktische N-Gramme extrahieren#

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.

Hide code cell content

def extract_dependent_adjective_list(spacy_docs: Dict, metadata_df: pd.DataFrame,
                                     noun_input: Union[str, List[str]], top_n: int = 10) -> Tuple[pd.DataFrame, List[str]]:
    """
    Extract adjective modifiers (nk) for a noun or list of nouns and track their frequency over time.

    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', 'volume', 'ID', 'decade'
    noun_input : str or list of str
        Single noun lemma (e.g., 'liebe') or list of noun lemmata (e.g., ['liebe', 'leidenschaft'])
    top_n : int
        Number of most frequent adjectives to extract (default: 10)

    Returns:
    --------
    tuple : (pd.DataFrame, list)
        - DataFrame with columns: filename, title, year, adjective, count, noun_count
        - List of the top N adjectives found
    """
    # Convert single noun to list for uniform processing
    if isinstance(noun_input, str):
        noun_list = [noun_input]
    else:
        noun_list = noun_input

    # Convert to lowercase for case-insensitive matching
    noun_list_lower = [noun.lower() for noun in noun_list]

    # First pass: count all adjectives modifying any noun in the list across entire corpus
    all_adjectives = Counter()

    for file_id, doc in spacy_docs.items():
        for token in doc:
            # Check if this token is one of our target nouns
            if token.lemma_.lower() in noun_list_lower and token.pos_ == 'NOUN':
                # Find any dependent adjective
                for child in token.children:
                    if child.dep_ == 'nk' and child.pos_ == 'ADJ':
                    #if child.pos_ == 'ADJ':
                        all_adjectives[child.lemma_.lower()] += 1

    # Get top N most frequent adjectives
    top_adjectives = [adj for adj, count in all_adjectives.most_common(top_n)]

    # Second pass: calculate frequencies per document
    results = []

    for file_id, doc in spacy_docs.items():
        # Get metadata for this file
        meta_row = metadata_df[metadata_df['DC.identifier'] == file_id]

        if meta_row.empty:
            continue

        # Count adjectives modifying the target nouns
        adjective_counts = Counter()
        noun_count = 0

        for token in doc:
            # Check if this token is one of our target nouns
            if token.lemma_.lower() in noun_list_lower and token.pos_ == 'NOUN':
                noun_count += 1
                #print('found noun:', token.text)
                # Find adjective modifiers (amod dependency)
                for child in token.children:
                    #print('child dep:', child.dep_, 'pos:', child.pos_)
                    if child.dep_ == 'nk' and child.pos_ == 'ADJ':
                    #if child.pos_ == 'ADJ':
                        #print('  found dependent adjective:', child.text)
                        adjective_counts[child.lemma_.lower()] += 1

        # Create a row for each top adjective found in this document
        # (even if count is 0, we want to track that)
        for adjective in top_adjectives:
            count = adjective_counts.get(adjective, 0)
            if noun_count > 0 or count > 0:  # Include if we have nouns or this adjective
                results.append({
                    'filename': file_id,
                    'DC.title': meta_row['DC.title'].values[0],
                    'year': meta_row['year'].values[0],
                    'adjective': adjective,
                    'count': count,
                    'noun_count': noun_count
                })

    return pd.DataFrame(results), top_adjectives


def get_top_adjectives_list(adj_df: pd.DataFrame, top_n: int = 10) -> list:
    """
    Get the top N most frequent adjectives from the adjective dataframe.

    Parameters:
    -----------
    adj_df : pd.DataFrame
        DataFrame returned by extract_adjective_modifiers_list()
    top_n : int
        Number of most frequent adjectives to return

    Returns:
    --------
    list
        List of top N adjectives
    """
    total_counts = adj_df.groupby('adjective')['count'].sum().sort_values(ascending=False)
    return total_counts.head(top_n).index.tolist()


def plot_adjective_trends_list(adj_df: pd.DataFrame, top_adjectives: list,
                               noun_input: Union[str, List[str]]):
    """
    Create a plot showing adjective modifier trends over time for a noun or noun list.

    Parameters:
    -----------
    adj_df : pd.DataFrame
        DataFrame returned by extract_adjective_modifiers_list()
    top_adjectives : list
        List of adjectives to plot (e.g., from get_top_adjectives_list())
    noun_input : str or list of str
        The noun(s) being analyzed
    show_individual_texts : bool
        If True, show individual text data points with titles; if False, show yearly means only

    Returns:
    --------
    
    .graph_objects.Figure
        The figure object (will display automatically in Jupyter)
    """
    # Filter for only the top adjectives
    filtered_df = adj_df[adj_df['adjective'].isin(top_adjectives)].copy()

    # Calculate relative frequency (per 100 noun occurrences)
    # Handle division by zero
    filtered_df['rel_freq'] = filtered_df.apply(
        lambda row: (row['count'] / row['noun_count']) * 100 if row['noun_count'] > 0 else 0,
        axis=1
    )

    # Create figure
    fig = go.Figure()


    # Show individual texts as scatter points
    for adj in top_adjectives:
        adj_data = filtered_df[filtered_df['adjective'] == adj]

        fig.add_trace(go.Scatter(
            x=adj_data['year'],
            y=adj_data['rel_freq'],
            mode='markers',
            name=adj,
            text=adj_data['title'],
            hovertemplate='<b>%{fullData.name}</b><br>' +
                         '<b>%{text}</b><br>' +
                         'Year: %{x}<br>' +
                         'Frequency: %{y:.2f} per 100 occurrences<br>' +
                         '<extra></extra>',
            marker=dict(size=8, opacity=0.7)
        ))

    # Create title based on input
    if isinstance(noun_input, str):
        title_noun = f'"{noun_input}"'
    else:
        noun_str = ', '.join(noun_input[:3])
        if len(noun_input) > 3:
            noun_str += f', ... ({len(noun_input)} total)'
        title_noun = f'[{noun_str}]'

    # Update layout
    fig.update_layout(
        title=f'Adjective Syntactic Dependents of {title_noun} Over Time',
        xaxis_title='Year',
        yaxis_title=f'Relative Frequency (per 100 noun occurrences)',
        hovermode='closest',
        height=600,
        legend=dict(
            title='Adjectives',
            yanchor="top",
            y=0.99,
            xanchor="right",
            x=0.99
        )
    )

    return fig


def plot_adjective_trends_moving_avg_plotly(
    adj_df: pd.DataFrame,
    top_adjectives: list,
    noun_input,
    window_years: int = 10,
    n_plot: int = 8,
    value_col: str = "rel_freq",
    show_points: bool = True,
):
    """
    Plotly lineplot per year for adjective dependents + centered moving average window.

    Expects adj_df to have at least: year, adjective, count, noun_count
    If value_col (default: rel_freq) is missing, it will be computed as (count / noun_count) * 100.
    """

    df = adj_df.copy()

    # --- Ensure year is integer year (avoid datetime nanoseconds weirdness) ---
    if "year" not in df.columns:
        raise ValueError("adj_df must contain a 'year' column.")

    if pd.api.types.is_datetime64_any_dtype(df["year"]):
        df["year"] = df["year"].dt.year
    else:
        df["year"] = pd.to_numeric(df["year"], errors="coerce")

    df = df.dropna(subset=["year"])
    df["year"] = df["year"].astype(int)

    # --- Compute relative frequency if needed ---
    if value_col not in df.columns:
        if not {"count", "noun_count"}.issubset(df.columns):
            raise ValueError(
                f"adj_df must contain '{value_col}' or both 'count' and 'noun_count'."
            )
        df[value_col] = df.apply(
            lambda row: (row["count"] / row["noun_count"]) * 100 if row["noun_count"] else 0,
            axis=1,
        )

    # --- Filter adjectives ---
    df = df[df["adjective"].isin(top_adjectives)].copy()
    if df.empty:
        raise ValueError("After filtering by top_adjectives, no rows remain.")

    # --- Yearly aggregate (mean across texts) ---
    yearly = (
        df.groupby(["year", "adjective"])[value_col]
          .mean()
          .unstack("adjective")
          .sort_index()
    )

    # --- Moving average on yearly aggregates ---
    moving = yearly.rolling(window=window_years, center=True, min_periods=1).mean()

    # --- Limit to n_plot adjectives (keep original top_adjectives order if possible) ---
    cols_in_data = [a for a in top_adjectives if a in moving.columns]
    cols_to_plot = cols_in_data[:n_plot] if cols_in_data else list(moving.columns)[:n_plot]
    moving_plot = moving[cols_to_plot].copy()

    # --- Build title ---
    if isinstance(noun_input, str):
        title_noun = f'"{noun_input}"'
    else:
        noun_str = ", ".join(noun_input[:3])
        if len(noun_input) > 3:
            noun_str += f", ... ({len(noun_input)} total)"
        title_noun = f"[{noun_str}]"

    # --- Long format for plotly ---
    moving_long = (
        moving_plot.reset_index()
                  .melt(id_vars="year", var_name="adjective", value_name=value_col)
    )

    # --- Plotly line chart ---
    fig = px.line(
        moving_long,
        x="year",
        y=value_col,
        color="adjective",
        markers=show_points,
        title=f"Top {min(n_plot, len(cols_to_plot))} Adjectives – {window_years}-Year Moving Average – {title_noun}",
        labels={
            "year": "Year",
            value_col: f"{value_col} (moving avg, window={window_years}y)",
            "adjective": "Adjective",
        },
    )

    # Make x axis show integer years cleanly (no scientific notation)
    fig.update_xaxes(
        tickmode="linear",
        dtick=10,          # change to 5/1 if you want denser ticks
        tickformat="d",
    )

    fig.update_layout(
        width=1000,
        height=500,
        legend_title_text="",
        hovermode="x unified",
        margin=dict(l=40, r=40, t=70, b=40),
    )

    fig.show()
    return yearly, moving


def plot_top_adjective_ranking(
    adj_df: pd.DataFrame,
    top_n: int = 20,
    noun_input=None,
    metric: str = "count",   # "count" or "share"
):
    """
    Show a simple ranking of the most frequent adjective modifiers.

    metric:
      - "count": total raw modifier counts across corpus
      - "share": percentage share among all modifier counts (sums to 100)
    """
    totals = (
        adj_df.groupby("adjective")["count"]
              .sum()
              .sort_values(ascending=False)
              .head(top_n)
              .reset_index()
              .rename(columns={"count": "total_count"})
    )

    if metric == "share":
        totals["value"] = (totals["total_count"] / totals["total_count"].sum()) * 100
        xcol = "value"
        xlabel = "Share of all adjective modifiers (%)"
        hover = {"total_count": True, "value": ":.2f"}
    else:
        totals["value"] = totals["total_count"]
        xcol = "value"
        xlabel = "Total count (across corpus)"
        hover = {"total_count": True}

    # Make a readable title noun
    if isinstance(noun_input, str):
        title_noun = f'"{noun_input}"'
    elif isinstance(noun_input, list) and noun_input:
        noun_str = ", ".join(noun_input[:3]) + (f", … ({len(noun_input)})" if len(noun_input) > 3 else "")
        title_noun = f"[{noun_str}]"
    else:
        title_noun = ""

    fig = px.bar(
        totals[::-1],               # reverse for top at top in horizontal bar
        x=xcol,
        y="adjective",
        orientation="h",
        title=f"Top {top_n} adjective modifiers of {title_noun}",
        labels={xcol: xlabel, "adjective": "Adjective"},
        hover_data=hover,
    )

    fig.update_layout(
        height=450 + top_n * 12,  # scales nicely
        margin=dict(l=120, r=40, t=60, b=40),
        yaxis=dict(categoryorder="total ascending"),
    )

    fig.show()
    return totals


def show_top_adjectives_table(adj_df: pd.DataFrame, top_n: int = 20):
    totals = (
        adj_df.groupby("adjective")["count"]
              .sum()
              .sort_values(ascending=False)
              .head(top_n)
              .reset_index()
              .rename(columns={"count": "total_count"})
    )
    totals["rank"] = range(1, len(totals) + 1)
    totals = totals[["rank", "adjective", "total_count"]]

    fig = go.Figure(data=[go.Table(
        header=dict(values=list(totals.columns)),
        cells=dict(values=[totals[c] for c in totals.columns])
    )])
    fig.update_layout(title=f"Top {top_n} adjective modifiers (total counts)", height=400)
    fig.show()
    #return totals


def plot_top_adjectives_by_decade(adj_df: pd.DataFrame, metadata_df: pd.DataFrame, top_n: int = 8):
    # attach decade via filename/ID
    dec = metadata_df[["DC.identifier", "decade"]].rename(columns={"DC.identifier": "filename"})
    df = adj_df.merge(dec, on="filename", how="left").dropna(subset=["decade"]).copy()

    # totals per decade
    g = (df.groupby(["decade", "adjective"])["count"].sum().reset_index())
    # find global top_n adjectives (across all decades)
    top_adjs = (df.groupby("adjective")["count"].sum().sort_values(ascending=False).head(top_n).index.tolist())
    g = g[g["adjective"].isin(top_adjs)]

    fig = px.bar(
        g,
        x="decade",
        y="count",
        color="adjective",
        barmode="group",
        title=f"Top {top_n} adjective modifiers by decade (raw counts)",
        labels={"count": "Count", "decade": "Decade", "adjective": "Adjective"},
    )
    fig.update_layout(height=500, hovermode="x unified")
    fig.show()
    #return g
noun = "Luft"
adj_df, top_adjs = extract_dependent_adjective_list(annotated_docs, metadata_df, noun, top_n=10)
adj_df_alt, top_adjs_alt = extract_dependent_adjective_list(annotated_docs, metadata_df_alt, noun, top_n=10)

Hide code cell content

# Save adj_df to CSV
adj_df.to_csv("interm_data_synt_ngrams/adj_df_luft.csv", index=False)
print(f"Saved adj_df to interm_data_synt_ngrams/adj_df_luft.csv ({len(adj_df)} rows)")

Hide code cell content

# Save adj_df_alt to CSV
adj_df_alt.to_csv("interm_data_synt_ngrams/adj_df_alt_luft.csv", index=False)
print(f"Saved adj_df_alt to interm_data_synt_ngrams/adj_df_alt_luft.csv ({len(adj_df_alt)} rows)")

5.4.7. Laden gespeicherter Ergebnisse (Optional)#

Falls die Ergebnisse bereits extrahiert und als CSV gespeichert wurden, können sie hier geladen werden, anstatt die Extraktion erneut durchzuführen.

Hide code cell content

# Optional: Load previously saved results from CSV

# Load adj_df
adj_df = pd.read_csv("interm_data_synt_ngrams/adj_df_luft.csv")
adj_df['year'] = pd.to_datetime(adj_df['year'])

# Load adj_df_alt
adj_df_alt = pd.read_csv("interm_data_synt_ngrams/adj_df_alt_luft.csv")
adj_df_alt['year'] = pd.to_datetime(adj_df_alt['year'])

# Recreate top_adjs lists from the dataframes
top_adjs = adj_df.groupby('adjective')['count'].sum().sort_values(ascending=False).head(10).index.tolist()
top_adjs_alt = adj_df_alt.groupby('adjective')['count'].sum().sort_values(ascending=False).head(10).index.tolist()

5.4.8. Analyse und Visualisierung#

Korpusweite Häufigkeiten adjektivischer Modifikatoren#

Zunächst betrachten wir die Gesamtverteilung der adjektivischen Modifikatoren. Bevor zeitliche Entwicklungen analysiert werden, ist es sinnvoll, einen Überblick darüber zu gewinnen, welche Adjektive im gesamten Korpus am häufigsten als syntaktische Modifikatoren der untersuchten Substantive auftreten. Diese aggregierte Betrachtung erlaubt es, dominante Beschreibungs- und Bewertungsmuster zu identifizieren und dient zugleich als Ausgangspunkt für die nachfolgenden diachronen Analysen.

Sample 1:#

totals = plot_top_adjective_ranking(adj_df, top_n=20, noun_input=noun, metric="count")
show_top_adjectives_table(adj_df)

Sample 2:#

totals_alt = plot_top_adjective_ranking(adj_df_alt, top_n=20, noun_input=noun, metric="count")
show_top_adjectives_table(adj_df_alt)

Diachrone Analyse adjektivisch-substantivischer Konstruktionen#

Im nächsten Schritt wird die Analyse um eine diachrone Perspektive erweitert. Anstatt ausschließlich korpusweite Gesamthäufigkeiten zu betrachten, wird nun untersucht, wie sich die Verwendung der zuvor identifizierten adjektivischen Modifikatoren im Zeitverlauf entwickelt. Die zeitliche Aggregation erlaubt es, Verschiebungen in Beschreibungs- und Bewertungsmustern nachzuzeichnen und diese mit historischen Prozessen in Beziehung zu setzen.

Sample 1#

# yearly lineplots + moving average
yearly_1, moving_1 = plot_adjective_trends_moving_avg_plotly(
    adj_df, top_adjs, noun_input=noun, window_years=10, n_plot=8
)

Sample 2:#

# NEW: yearly lineplots + moving average
yearly_2, moving_2 = plot_adjective_trends_moving_avg_plotly(
    adj_df_alt, top_adjs_alt, noun_input=noun, window_years=10, n_plot=8
)

Ausreißertexte und Kontextanalyse#

Welche konkreten Texte sind für die Ausschläge in den Zeitreihen verantwortlich?

Um diese Frage transparent zu beantworten, identifizieren wir sogenannte „Ausreißertexte“ (aber nicht über einen komplexen statistischen Test, sondern über eine nachvollziehbare Zähl- und Ranking-Logik). Für jedes Werk zählen wir (1) noun_count, also wie oft das Lemma Luft im Text vorkommt, und (2) count, also wie oft ein bestimmtes Adjektiv (z.B. frisch) in der Dependenzannotation als attributiver Modifikator von Luft erscheint (ADJ → Luft, z.B. frische Luft). Daraus berechnen wir pro Text eine normalisierte Kennzahl rel_freq = (count / noun_count) * 100, also den Anteil der Luft-Vorkommen, die mit diesem Adjektiv modifiziert werden.

Anschließend bestimmen wir für jedes Adjektiv zunächst die Dekaden, in denen der Durchschnitt dieser relativen Häufigkeit besonders hoch ist (Peak-Dekaden), und listen innerhalb dieser Zeitfenster die Texte mit den höchsten rel_freq-Werten (Top-k) als „Ausreißertexte“ auf. Um instabile Extremwerte zu vermeiden, filtern wir Texte mit sehr wenigen Luft-Belegen (z.B. noun_count < 5) aus. Ergänzend berechnen wir pro Dekade den Anteil eines einzelnen Textes an allen Adjektiv→Luft-Treffern (share_of_adj_count_in_decade), um sichtbar zu machen, ob ein Ausschlag der Zeitreihe vor allem von wenigen Werken getragen wird. Diese identifizierten Texte werden anschließend mithilfe von KWIC- bzw. Satzkontexten qualitativ überprüft, um die semantische Funktion der jeweiligen Konstruktionen genauer einzuordnen.

Hide code cell content

# --- small detokenizer for KWIC left/right contexts ---
_punct_fix = re.compile(r"\s+([.,;:!?])")

def detok(tokens):
    """Join tokens with spaces and fix spacing before punctuation."""
    return _punct_fix.sub(r"\1", " ".join(tokens)).strip()


def enrich_adj_df_with_decade_and_relfreq(adj_df: pd.DataFrame, metadata_df: pd.DataFrame) -> pd.DataFrame:
    """
    Adds:
      - year_int (int)
      - decade (from metadata)
      - rel_freq = (count / noun_count) * 100
    """
    df = adj_df.copy()

    # year → int
    if pd.api.types.is_datetime64_any_dtype(df["year"]):
        df["year_int"] = df["year"].dt.year
    else:
        df["year_int"] = pd.to_numeric(df["year"], errors="coerce").astype("Int64")

    meta = metadata_df[["DC.identifier", "decade"]].copy()
    df = df.merge(meta, left_on="filename", right_on="DC.identifier", how="left").drop(columns=["DC.identifier"])

    # rel_freq per text
    df["rel_freq"] = df.apply(
        lambda r: (r["count"] / r["noun_count"]) * 100 if r["noun_count"] else 0,
        axis=1,
    )
    return df


def peak_decades_for_adjective(
    df_enriched: pd.DataFrame,
    adjective: str,
    metric: str = "rel_freq",
    top_n: int = 3,
    min_noun_count: int = 5,
) -> pd.DataFrame:
    """
    Returns the decades where an adjective is strongest (by decade mean of metric),
    filtering out texts with very few noun occurrences (min_noun_count).
    """
    sub = df_enriched[(df_enriched["adjective"] == adjective) & (df_enriched["noun_count"] >= min_noun_count)].copy()
    if sub.empty:
        return pd.DataFrame(columns=["decade", "decade_mean", "n_texts"])

    dec = (
        sub.groupby("decade")[metric]
           .agg(decade_mean="mean", n_texts="count")
           .reset_index()
           .sort_values("decade_mean", ascending=False)
           .head(top_n)
    )
    return dec


def top_outlier_texts(
    df_enriched: pd.DataFrame,
    adjective: str,
    decades: list,
    metric: str = "rel_freq",
    k: int = 5,
    min_noun_count: int = 5,
) -> pd.DataFrame:
    """
    For a given adjective and a list of decades:
    returns top-k texts per decade (sorted by metric), plus a few diagnostic columns.
    """
    sub = df_enriched[
        (df_enriched["adjective"] == adjective) &
        (df_enriched["decade"].isin(decades)) &
        (df_enriched["noun_count"] >= min_noun_count)
    ].copy()

    if sub.empty:
        return pd.DataFrame(columns=[
            "decade","year_int","DC.title","filename","count","noun_count","rel_freq",
            "share_of_adj_count_in_decade"
        ])

    # share of raw adjective count within that decade (helps detect "one text drives it")
    sub["share_of_adj_count_in_decade"] = sub["count"] / sub.groupby("decade")["count"].transform("sum").replace(0, np.nan)

    out = (
        sub.sort_values(["decade", metric], ascending=[True, False])
           .groupby("decade", as_index=False)
           .head(k)
           .sort_values(["decade", metric], ascending=[True, False])
    )

    cols = ["decade", "year_int", "DC.title", "filename", "count", "noun_count", "rel_freq", "share_of_adj_count_in_decade"]
    return out[cols]


def kwic_adj_noun(
    spacy_docs: Dict[str, "spacy.tokens.Doc"],
    metadata_df: pd.DataFrame,
    noun_lemma: str,
    adj_lemma: str,
    dep_label: str = "nk",
    window: int = 7,
    max_examples: int = 30,
    file_ids: list | None = None,
    random_state: int = 0,
) -> pd.DataFrame:
    """
    Extract KWIC lines for cases where adj_lemma is a dependent adjective (dep_label)
    of noun_lemma.

    Returns a dataframe with left / keyword / right + sentence + metadata.
    """
    noun_lemma = noun_lemma.lower()
    adj_lemma = adj_lemma.lower()

    meta = metadata_df.set_index("DC.identifier")[["DC.title", "year", "decade", "lastname", "firstname"]]

    rows = []
    for file_id, doc in spacy_docs.items():
        if file_ids is not None and file_id not in file_ids:
            continue
        if file_id not in meta.index:
            continue

        m = meta.loc[file_id]
        year_int = int(pd.to_datetime(m["year"]).year) if not isinstance(m["year"], (int, np.integer)) else int(m["year"])

        for token in doc:
            if token.pos_ == "NOUN" and token.lemma_.lower() == noun_lemma:
                for child in token.children:
                    if child.pos_ == "ADJ" and child.dep_ == dep_label and child.lemma_.lower() == adj_lemma:
                        i = token.i
                        left = detok([t.text for t in doc[max(0, i - window): i]])
                        right = detok([t.text for t in doc[i + 1: i + 1 + window]])

                        rows.append({
                            "left context": left,
                            "keyword": token.text,
                            "right context": right,
                            "modifier": child.text,
                            "DC.title": m["DC.title"],
                            "author": f'{m["firstname"]} {m["lastname"]}'.strip(),
                            "decade": int(m["decade"]),
                            "year": year_int,
                            "filename": file_id,
                            "sentence": token.sent.text.strip() if token.sent is not None else ""
                        })

    df = pd.DataFrame(rows)
    if df.empty:
        return df

    # sample if too many (keeps notebook readable)
    if len(df) > max_examples:
        df = df.sample(n=max_examples, random_state=random_state)

    return df.sort_values(["decade", "year", "DC.title"]).reset_index(drop=True)

Hide code cell content

# --- SAMPLE 1 ---
adj_df_enriched_1 = enrich_adj_df_with_decade_and_relfreq(adj_df, metadata_df)
# choose which adjectives to inspect (use the ones you plotted)
adjs_to_inspect_1 = top_adjs[:8]  # adjust if you want more/less

all_outliers_1 = []
all_kwic_1 = []

for adj in adjs_to_inspect_1:
    peaks = peak_decades_for_adjective(
        adj_df_enriched_1,
        adjective=adj,
        metric="rel_freq",
        top_n=2,            # take the 2 strongest decades
        min_noun_count=5,   # avoid extreme ratios from very few Luft occurrences
    )

    peak_decades = peaks["decade"].tolist()
    outliers = top_outlier_texts(
        adj_df_enriched_1,
        adjective=adj,
        decades=peak_decades,
        metric="rel_freq",
        k=5,                # top 5 texts per decade
        min_noun_count=5,
    )

    outliers["adjective"] = adj
    all_outliers_1.append(outliers)

    # KWIC only for the outlier filenames (keeps it small and interpretable)
    outlier_files = outliers["filename"].unique().tolist()
    kw = kwic_adj_noun(
        annotated_docs, metadata_df,
        noun_lemma=noun, adj_lemma=adj,
        dep_label="nk",
        window=7,
        max_examples=25,
        file_ids=outlier_files,
        random_state=0
    )
    if not kw.empty:
        kw["adjective"] = adj
        all_kwic_1.append(kw)

outliers_table_1 = pd.concat(all_outliers_1, ignore_index=True) if all_outliers_1 else pd.DataFrame()
kwic_table_1 = pd.concat(all_kwic_1, ignore_index=True) if all_kwic_1 else pd.DataFrame()

outliers_table_1.to_csv("interm_data_synt_ngrams/outliers_luft_method1.csv", index=False)
kwic_table_1.to_csv("interm_data_synt_ngrams/kwic_luft_method1.csv", index=False)

Hide code cell content

outliers_table_1 = pd.read_csv("interm_data_synt_ngrams/outliers_luft_method1.csv")
kwic_table_1 = pd.read_csv("interm_data_synt_ngrams/kwic_luft_method1.csv")

Die folgende Tabelle listet die identifizierten Ausreißertexte für die jeweiligen Adjektiv–Nomen-Konstruktionen auf, also jene Werke, die maßgeblich zu den beobachteten Ausschlägen in den Zeitreihen beitragen (in Sample 1).

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

Die KWIC-Ansicht zeigt ausgewählte Satzkontexte der Adjektiv–Nomen-Konstruktionen aus diesen Ausreißertexten und ermöglicht eine qualitative Einordnung ihrer semantischen Verwendung.

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

Hide code cell content

# --- SAMPLE 2 ---
adj_df_enriched_2 = enrich_adj_df_with_decade_and_relfreq(adj_df_alt, metadata_df_alt)

adjs_to_inspect_2 = top_adjs_alt[:8]

all_outliers_2 = []
all_kwic_2 = []

for adj in adjs_to_inspect_2:
    peaks = peak_decades_for_adjective(
        adj_df_enriched_2,
        adjective=adj,
        metric="rel_freq",
        top_n=2,
        min_noun_count=5,
    )
    peak_decades = peaks["decade"].tolist()

    outliers = top_outlier_texts(
        adj_df_enriched_2,
        adjective=adj,
        decades=peak_decades,
        metric="rel_freq",
        k=5,
        min_noun_count=5,
    )

    outliers["adjective"] = adj
    all_outliers_2.append(outliers)

    outlier_files = outliers["filename"].unique().tolist()
    kw = kwic_adj_noun(
        annotated_docs, metadata_df_alt,
        noun_lemma=noun, adj_lemma=adj,
        dep_label="nk",
        window=7,
        max_examples=25,
        file_ids=outlier_files,
        random_state=0
    )
    if not kw.empty:
        kw["adjective"] = adj
        all_kwic_2.append(kw)

outliers_table_2 = pd.concat(all_outliers_2, ignore_index=True) if all_outliers_2 else pd.DataFrame()
kwic_table_2 = pd.concat(all_kwic_2, ignore_index=True) if all_kwic_2 else pd.DataFrame()

outliers_table_2.to_csv("interm_data_synt_ngrams/outliers_luft_method2.csv", index=False)
kwic_table_2.to_csv("interm_data_synt_ngrams/kwic_luft_method2.csv", index=False)

Hide code cell content

outliers_table_2 = pd.read_csv("interm_data_synt_ngrams/outliers_luft_method2.csv")
kwic_table_2 = pd.read_csv("interm_data_synt_ngrams/kwic_luft_method2.csv")

Die folgende Tabelle listet die identifizierten Ausreißertexte für die jeweiligen Adjektiv–Nomen-Konstruktionen auf, also jene Werke, die maßgeblich zu den beobachteten Ausschlägen in den Zeitreihen beitragen (in Sample 2).

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

Die KWIC-Ansicht zeigt ausgewählte Satzkontexte der Adjektiv–Nomen-Konstruktionen aus diesen Ausreißertexten und ermöglicht eine qualitative Einordnung ihrer semantischen Verwendung.

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

5.4.9. Diskussion der syntaktischen N-Gramm-Analyse#

Die syntaktische N-Gramm-Analyse verschiebt den Blick von der bloßen Häufigkeit des Begriffs Luft hin zur Frage, wie über Luft gesprochen wird – also welche Adjektive als Modifikatoren in Konstruktionen wie frische Luft, reine Luft oder freie Luft auftreten. Damit rückt nicht nur das Thema, sondern auch die semantische Rahmung in den Fokus (z.B. Luft als Naturerlebnis vs. Luft als „Qualität“ im hygienischen/medizinischen Sinn).

Über beide Stichproben hinweg zeigt sich, dass einige Modifikatoren sehr stabil und dominant sind – insbesondere frisch (und in Teilen auch frei). Das spricht dafür, dass bestimmte Formulierungen im 19. Jahrhundert als relativ feste sprachliche Muster etabliert sind. Gleichzeitig treten zeitweise stärkere Ausschläge einzelner Adjektive (z.B. klar, rein, leer oder still) auf. Solche Peaks können als Hinweis auf zeitgebundene Konjunkturen bestimmter Redeweisen gelesen werden, sollten jedoch nicht vorschnell historisch „übererklärt“ werden.

Denn es gibt mehrere Deutungs- und Einschränkungsansätze:

  1. Einfluss einzelner Texte / Ausreißer:
    Die hier dargestellte relative Häufigkeit wird pro Text normalisiert (Anzahl der Adjektiv-Nomen-Konstruktionen im Verhältnis zu allen Vorkommen von Luft) und anschließend pro Jahr gemittelt. In Zeitfenstern mit wenigen Texten oder mit wenigen Luft-Belegen pro Text können einzelne Werke die Kurven deutlich beeinflussen. Entsprechend haben wir im Anschluss an die Zeitreihenanalyse Ausreißertexte identifiziert, also jene Werke, die innerhalb der Peak-Zeitfenster besonders hohe Werte aufweisen und die Ausschläge der Kurven (teilweise) tragen.

  2. Mehrdeutigkeit der Konstruktionen (Kontextabhängigkeit):
    Dieselbe Oberfläche kann in sehr unterschiedlichen Kontexten auftreten (z.B. Naturbeschreibung, Wetter/Temperatur, Hygiene/Atmung, Metaphern der Stimmung oder räumliche Wendungen wie „in freier Luft“). Daher wurden exemplarische KWIC- bzw. Satzkontexte aus den Ausreißertexten herangezogen, um sichtbar zu machen, welche semantischen Funktionen die jeweiligen Adjektiv–Nomen-Konstruktionen in konkreten Textstellen erfüllen (z.B. phraseologische Wendungen, naturpoetische Beschreibungen oder metaphorische Verwendungen wie „leere Luft“).

  3. Operationalisierung und Modellgrenzen:
    Die Extraktion basiert auf syntaktischen Abhängigkeiten (Adjektive als Modifikatoren von Luft). Parserfehler, Lemmatisierung und die Beschränkung auf eine Konstruktion (Adjektiv → Nomen) können dazu führen, dass relevante Stellen übersehen werden (z.B. komplexere Umschreibungen, ironische oder indirekte Thematisierung von Luftqualität). Außerdem kann die Normalisierung bei sehr wenigen Luft-Belegen pro Text zu instabilen relativen Werten führen, weshalb entsprechende Mindestschwellen (z.B. noun_count) sinnvoll sind.

Insgesamt deuten die Ergebnisse darauf hin, dass Literatur des 19. Jahrhunderts Luft häufig in Form einiger wiederkehrender Qualifizierungen rahmt (besonders frisch und frei), während andere Attribute phasenweise stärker werden und potenziell mit Gattungen, Themenfeldern oder historischen Diskursen (Natur, Hygiene, Urbanität) zusammenhängen könnten. Die ergänzende Ausreißertext- und KWIC-Auswertung hilft dabei, die Kurven zu „erden“: Sie zeigt, ob Peaks eher breit über viele Texte verteilt sind (stabile Muster) oder ob einzelne Werke/Genre-Kontexte den Ausschlag geben, und sie macht anhand konkreter Belegstellen sichtbar, ob eine Konstruktion eher wörtlich-physisch, naturpoetisch oder metaphorisch gebraucht wird. Für weitergehende, belastbarere Interpretationen wäre als Anschluss besonders sinnvoll, die KWIC-Belege systematischer zu kategorisieren (z.B. Natur/Wetter vs. Hygiene/Atmung vs. Metapher) und die Kontexte gezielt zwischen Peak- und Nicht-Peak-Phasen zu kontrastieren.