6  LLM-gestützte Textanalyse

Am Beispiel der Tagesschau-Daten

Autor:in

Prof. Dr. Nicolas Meseth

Die regelbasierte Textanalyse hat eine entscheidende Schwäche: Ihr müsst im Voraus wissen, wonach ihr sucht. Wörterbücher müssen manuell erstellt werden, Verneinungen bleiben unsichtbar, und kontextuelles Verstehen ist kaum möglich. Große Sprachmodelle (LLMs) versprechen, genau diese Grenzen zu überwinden: Statt Muster zu suchen, lesen und interpretieren sie Text. In diesem Experiment lernt ihr, wie ihr LLMs systematisch für Textanalyseaufgaben einsetzen könnt – und wo ihre eigenen Grenzen liegen. Das Experiment baut direkt auf dem vorherigen Experiment zur regelbasierten Textanalyse auf und stellt denselben Tagesschau-Datensatz in einen neuen methodischen Kontext.

Schritt 1: LLMs einrichten und testen

Bevor ihr mit der eigentlichen Analyse beginnt, richtet ihr zwei Zugangswege ein: ein lokal laufendes Modell für datenschutzsensitive und kostenfreie Experimente sowie einen Cloud-Zugang für leistungsstärkere Modelle.

1. Ladet LM Studio herunter und installiert es. Öffnet LM Studio und ladet über die Suchfunktion ein deutschsprachig-kompetentes Modell, zum Beispiel das kleine Gemma 4 E2B. Öffnet nach dem Laden die Chat-Oberfläche und stellt dem Modell eine einfache Frage auf Deutsch: „Was ist die Tagesschau?”

Beschreibt in zwei Sätzen: Wie kompetent wirkt das Modell? Gibt es Fehler oder Auffälligkeiten in der Antwort?

Gemma 3 4B liefert auf diese Frage eine gut strukturierte, weitgehend korrekte Antwort zur Tagesschau. Die Aufzählung der Ressorts ist jedoch oft unvollständig oder enthält veraltete Informationen, was auf die Wissenslücke nach dem Trainings-Cutoff des Modells hindeutet. Bei sehr aktuellen Ereignissen oder spezifischem Redaktionswissen versagen kleine lokale Modelle erwartungsgemäß.

2. Startet den lokalen Server in LM Studio über Local Server → Start Server. Installiert anschließend in eurem Python-Projekt die notwendigen Pakete:

pip install openai pandas matplotlib seaborn tqdm scikit-learn

Importiert alle Bibliotheken und definiert zwei Variablen: MODEL_LOCAL (Modellname aus LM Studio, z.B. "gemma-3-4b-it" – den genauen Namen findet ihr unter My Models) und MODEL_OPENAI (z.B. "gpt-4o-mini").

from openai import OpenAI
import pandas as pd
import json
import matplotlib.pyplot as plt
import seaborn as sns
from tqdm import tqdm
import os

MODEL_LOCAL  = "gemma-3-4b-it"
MODEL_OPENAI = "gpt-4o-mini"

3. Erstellt zwei Clients: einen für LM Studio und einen für die OpenAI API. LM Studio stellt eine OpenAI-kompatible API unter http://localhost:1234/v1 bereit – ihr könnt das openai-Paket verwenden und lediglich base_url anpassen. Testet beide Verbindungen mit einer einfachen Anfrage: „Nenne mir in einem Satz, worum es bei der Tagesschau geht.” Gebt die Antworten beider Modelle aus.

Hinweis: Speichert den OpenAI-API-Schlüssel als Umgebungsvariable OPENAI_API_KEY, anstatt ihn direkt im Code einzubetten.

client_local = OpenAI(
    base_url="http://localhost:1234/v1",
    api_key="lm-studio"
)
client_openai = OpenAI(api_key=os.environ["OPENAI_API_KEY"])

test_question = "Nenne mir in einem Satz, worum es bei der Tagesschau geht."

for name, client, model in [
    ("Local",  client_local,  MODEL_LOCAL),
    ("OpenAI", client_openai, MODEL_OPENAI),
]:
    response = client.chat.completions.create(
        model=model,
        messages=[{"role": "user", "content": test_question}],
        temperature=0
    )
    print(f"[{name}] {response.choices[0].message.content.strip()}")

4. Vergleicht lokale und Cloud-basierte LLMs anhand von vier Dimensionen. Füllt die folgende Tabelle aus und begründet eure Einschätzungen kurz:

Dimension Lokal (LM Studio) Cloud (ChatGPT)
Kosten
Datenschutz
Leistungsfähigkeit
Geschwindigkeit

Wann würdet ihr welche Variante bevorzugen?

Dimension Lokal (LM Studio) Cloud (ChatGPT)
Kosten Einmalige Hardware-Kosten, danach kostenlos Pro Token abgerechnet, kann bei großen Korpora teuer werden
Datenschutz Daten verlassen den Rechner nicht Daten werden an externe Server übertragen
Leistungsfähigkeit Begrenzt durch verfügbare Hardware (RAM/GPU) State-of-the-Art-Modelle, deutlich leistungsstärker
Geschwindigkeit Langsam (CPU/RAM-limitiert ohne dedizierte GPU) Schnell und gut parallelisierbar

Faustregel: Für Experimente, Prototypen und datenschutzsensitive Projekte → lokal. Für Produktionsanalysen mit hohen Qualitätsanforderungen und großem Maßstab → Cloud.

Schritt 2: Analysecorpus aufbauen

LLM-Analysen sind pro API-Aufruf kostenpflichtig (Cloud) oder zeitintensiv (lokal). Statt den gesamten Tagesschau-Datensatz mit über 60.000 Artikeln zu analysieren, erstellt ihr zunächst einen repräsentativen Teilcorpus.

5. Ladet den Tagesschau-Datensatz in R und bereitet den Analysecorpus vor:

  • Lest tagesschau.zip ein und ergänzt eine Spalte year (Erscheinungsjahr aus date_time).
  • Filtert auf die sechs häufigsten Ressorts: ausland, wirtschaft, inland, wissen, investigativ, faktenfinder.
  • Entfernt Zeilen, in denen title oder text fehlen.

Wie viele Artikel bleiben nach dem Filtern übrig?

library(tidyverse)

news <- read_csv("data/tagesschau.zip") |>
  mutate(year = lubridate::year(date_time)) |>
  filter(
    ressort %in% c("ausland", "wirtschaft", "inland", "wissen", "investigativ", "faktenfinder"),
    !is.na(title),
    !is.na(text)
  )

nrow(news)

6. Zieht aus dem gefilterten Datensatz eine stratifizierte Zufallsstichprobe: 50 Artikel pro Jahr (Stratifizierung nach year). Verwendet set.seed(42) für Reproduzierbarkeit. Behaltet die Spalten date_time, year, title, shorttext, text, ressort und url. Exportiert den Korpus als data/tagesschau_sample.csv, damit ihr ihn anschließend in Python laden könnt.

Wie viele Zeilen hat der exportierte Datensatz insgesamt? Welche Jahre sind enthalten?

set.seed(42)

corpus <- news |>
  select(date_time, year, title, shorttext, text, ressort, url) |>
  group_by(year) |>
  slice_sample(n = 50) |>
  ungroup()

write_csv(corpus, "data/tagesschau_sample.csv")

cat("Zeilen:", nrow(corpus), "\n")
cat("Jahre:", paste(sort(unique(corpus$year)), collapse = ", "), "\n")

Der Datensatz enthält 50 × Anzahl der vollständigen Jahre, typischerweise etwa 1.000–1.100 Artikel. In frühen Jahren des Datensatzes können weniger als 50 Artikel pro Jahr vorhanden sein – slice_sample() gibt in diesem Fall alle verfügbaren zurück.

Schritt 3: Einfache NLP-Aufgaben mit LLMs

Mit dem Korpus könnt ihr jetzt konkrete Analyseaufgaben durchführen. Ihr beginnt mit einfacheren Aufgaben, die ihr aus der regelbasierten Analyse kennt – um zu sehen, was LLMs besser, schlechter oder anders machen.

7. Ladet den Korpus in Python und schreibt zwei Hilfsfunktionen:

  • llm_text(prompt, client, model): Schickt einen Prompt, gibt die Textantwort zurück.
  • llm_json(prompt, client, model): Wie llm_text, aber mit response_format={"type": "json_object"} – gibt ein Python-Dictionary zurück.

Beide Funktionen sollen temperature=0 verwenden, damit die Ergebnisse möglichst reproduzierbar sind. Testet beide Funktionen mit einem kurzen Beispielaufruf.

df = pd.read_csv("data/tagesschau_sample.csv")

def llm_text(prompt, client, model):
    response = client.chat.completions.create(
        model=model,
        messages=[{"role": "user", "content": prompt}],
        temperature=0
    )
    return response.choices[0].message.content.strip()

def llm_json(prompt, client, model):
    response = client.chat.completions.create(
        model=model,
        messages=[
            {"role": "system", "content": "Antworte ausschließlich mit gültigem JSON."},
            {"role": "user",   "content": prompt}
        ],
        response_format={"type": "json_object"},
        temperature=0
    )
    return json.loads(response.choices[0].message.content)

# Test
print(llm_text("In einem Satz: Was ist ein LLM?", client_local, MODEL_LOCAL))
print(llm_json('Gib {"status": "ok"} zurück.', client_local, MODEL_LOCAL))

8. Wählt fünf zufällige Artikel aus dem Korpus und lasst das lokale Modell und ChatGPT jeweils eine Zusammenfassung in zwei deutschen Sätzen erstellen. Nutzt den text-Wert als Eingabe, begrenzt ihn aber auf maximal 600 Zeichen (text[:600]), um die Promptlänge zu kontrollieren.

Gebt Titel, Zusammenfassung des lokalen Modells und Zusammenfassung von ChatGPT nebeneinander aus. Wo gibt es inhaltliche Unterschiede oder Fehler?

sample = df.sample(5, random_state=1).reset_index(drop=True)

for _, row in sample.iterrows():
    prompt = f"""Fasse den folgenden Nachrichtenartikel in genau zwei deutschen Sätzen zusammen.

Text: {str(row['text'])[:600]}"""

    summary_local  = llm_text(prompt, client_local,  MODEL_LOCAL)
    summary_openai = llm_text(prompt, client_openai, MODEL_OPENAI)

    print(f"\n=== {row['title'][:70]} ===")
    print(f"[Lokal]  {summary_local}")
    print(f"[OpenAI] {summary_openai}")

9. Wie reißerisch ist ein Nachrichtentitel? Schreibt eine Funktion rate_sensational(title, client, model), die einen Artikel-Titel in eine von vier ordinalen Kategorien einordnet und den Kategorienamen als String zurückgibt:

  • "neutral" – sachliche Meldung, keine besondere Wertung
  • "pointed" – zugespitzte oder deutlich pointierte Formulierung
  • "alarming" – alarmierender oder aufwühlender Ton
  • "sensational" – reißerisch, provozierend, Clickbait-Charakter

Wendet die Funktion auf 20 zufällige Artikel an. Gebt alle Titel der Kategorien "alarming" und "sensational" aus. Sind die Einstufungen plausibel?

Außerdem: Warum könnten ordinale Kategorien für LLMs zuverlässiger sein als eine numerische Skala von 1 bis 5?

SENSATIONAL_CATEGORIES = ["neutral", "pointed", "alarming", "sensational"]

def rate_sensational(title, client, model):
    prompt = f"""Wie reißerisch ist die folgende Nachrichtenüberschrift?
Wähle genau eine der folgenden Kategorien:
- neutral: sachliche Meldung, keine besondere Wertung
- pointed: zugespitzte oder deutlich pointierte Formulierung
- alarming: alarmierender oder aufwühlender Ton
- sensational: reißerisch, provozierend, Clickbait-Charakter

Überschrift: {title}

Antworte ausschließlich mit einem der vier Kategoriename (neutral / pointed / alarming / sensational)."""

    result = llm_text(prompt, client, model).strip().lower()
    return result if result in SENSATIONAL_CATEGORIES else None

sample20 = df.sample(20, random_state=42).reset_index(drop=True)
sample20["sensational"] = sample20["title"].apply(
    lambda t: rate_sensational(t, client_local, MODEL_LOCAL)
)

print(sample20[sample20["sensational"].isin(["alarming", "sensational"])][["title", "sensational"]])

Ordinale Kategorien sind für LLMs oft verlässlicher als Zahlen, weil Zahlen auf einer Skala keine eindeutige semantische Bedeutung tragen: Was ist der Unterschied zwischen einer „3” und einer „4”? Ein Modell muss diese Grenze selbst setzen und tut das inkonsistent. Kategorienamen hingegen beschreiben einen Zustand direkt – das Modell wählt das Wort, das am besten passt, statt eine Zahl zu kalibrieren.

10. Schreibt eine Funktion rate_sentiment(title, text, client, model), die bewertet, ob ein Artikel eine gute oder schlechte Nachricht enthält. Verwendet eine viergliedrige Skala ohne neutrale Mittelkategorie, um das Modell zu einer Entscheidung zu zwingen:

  • -2 – sehr schlechte Nachricht (Katastrophe, Tod, schwere Krise)
  • -1 – eher schlechte Nachricht (Konflikt, Problem, Rückschlag)
  • +1 – eher gute Nachricht (positive Entwicklung, Fortschritt)
  • +2 – sehr gute Nachricht (Durchbruch, gelöstes Problem, Erfolg)

Wendet die Funktion auf 30 zufällige Artikel an. Berechnet den Mittelwert pro Ressort. Welches Ressort berichtet am meisten über schlechte Nachrichten?

def rate_sentiment(title, text, client, model):
    prompt = f"""Ist der folgende Nachrichtenartikel eine gute oder schlechte Nachricht?
Skala (kein Mittelwert, du musst dich entscheiden):
-2 = sehr schlechte Nachricht (Katastrophe, Tod, schwere Krise)
-1 = eher schlechte Nachricht (Konflikt, Problem, Rückschlag)
+1 = eher gute Nachricht (positive Entwicklung, Fortschritt, Lösung)
+2 = sehr gute Nachricht (Durchbruch, großer Erfolg, Entwarnung)

Titel: {title}
Text (Auszug): {str(text)[:300]}

Antworte ausschließlich mit einer der vier Zahlen: -2, -1, +1 oder +2."""

    result = llm_text(prompt, client, model).strip()
    try:
        value = int(result.replace("+", ""))
        return value if value in (-2, -1, 1, 2) else None
    except ValueError:
        return None

sample30 = df.sample(30, random_state=7).reset_index(drop=True)
sample30["sentiment"] = sample30.apply(
    lambda r: rate_sentiment(r["title"], r["text"], client_local, MODEL_LOCAL),
    axis=1
)

print(sample30.groupby("ressort")["sentiment"].mean().sort_values())

Nachrichtenartikel berichten strukturell häufiger über schlechte Ereignisse als über gute – das entspricht dem klassischen Nachrichtenwert der Negativität. Die fehlende Nullkategorie zwingt das Modell zu einer inhaltlichen Entscheidung und verhindert, dass es bei ambivalenten Artikeln einfach die Mitte wählt.

Schritt 4: Ressort-Klassifikation und Evaluation

In der regelbasierten Textanalyse habt ihr Artikel über Wörterbücher klassifiziert. Jetzt fragt ihr ein LLM: Kann es das besser – und wie viel besser?

11. Schreibt eine Funktion predict_section(title, shorttext, client, model), die das Ressort eines Artikels vorhersagt. Das Modell soll genau eines der sechs Ressorts zurückgeben: ausland, wirtschaft, inland, wissen, investigativ, faktenfinder. Nennt im Prompt die vollständige Liste und fordert eine exakte Antwort ohne weitere Erklärung.

Testet die Funktion an drei Beispielen aus dem Korpus und vergleicht Vorhersage und wahres Label.

SECTIONS = ["ausland", "wirtschaft", "inland", "wissen", "investigativ", "faktenfinder"]

def predict_section(title, shorttext, client, model):
    sections_list = ", ".join(SECTIONS)
    prompt = f"""Klassifiziere den folgenden Tagesschau-Artikel in genau ein Ressort.
Mögliche Ressorts: {sections_list}

Titel: {title}
Kurztext: {str(shorttext)[:200]}

Antworte ausschließlich mit dem Ressort-Namen, ohne weitere Erklärung."""

    result = llm_text(prompt, client, model)
    result_clean = result.strip().lower()
    return result_clean if result_clean in SECTIONS else "unbekannt"

for _, row in df.sample(3, random_state=5).iterrows():
    pred = predict_section(row["title"], row["shorttext"], client_local, MODEL_LOCAL)
    print(f"Wahr: {row['ressort']:15} | Vorhergesagt: {pred}")
    print(f"  Titel: {row['title'][:70]}\n")

12. Wendet die Klassifikation auf einen stratifizierten Test-Datensatz von 60 Artikeln an (10 pro Ressort). Führt die Klassifikation mit beiden Modellen durch (lokal und ChatGPT) und speichert die Ergebnisse in den Spalten pred_local und pred_openai. Berechnet anschließend die Genauigkeit (Anteil korrekt klassifizierter Artikel) für beide Modelle.

Welches Modell schneidet besser ab? Ist der Unterschied größer als erwartet?

test_df = (
    df.groupby("ressort", group_keys=False)
    .apply(lambda g: g.sample(min(len(g), 10), random_state=42))
    .reset_index(drop=True)
)

tqdm.pandas()

test_df["pred_local"] = test_df.progress_apply(
    lambda r: predict_section(r["title"], r["shorttext"], client_local, MODEL_LOCAL),
    axis=1
)
test_df["pred_openai"] = test_df.progress_apply(
    lambda r: predict_section(r["title"], r["shorttext"], client_openai, MODEL_OPENAI),
    axis=1
)

acc_local  = (test_df["ressort"] == test_df["pred_local"]).mean()
acc_openai = (test_df["ressort"] == test_df["pred_openai"]).mean()

print(f"Genauigkeit lokal:  {acc_local:.1%}")
print(f"Genauigkeit OpenAI: {acc_openai:.1%}")

13. Visualisiert die Klassifikationsergebnisse als Konfusionsmatrix – je eine Heatmap für das lokale Modell und für ChatGPT. Auf der x-Achse stehen die vorhergesagten Ressorts, auf der y-Achse die wahren Ressorts.

Welche Ressorts werden am häufigsten verwechselt? Lässt sich ein inhaltliches Muster erkennen?

from sklearn.metrics import confusion_matrix

fig, axes = plt.subplots(1, 2, figsize=(14, 5))

for ax, col, title in [
    (axes[0], "pred_local",  f"Local ({MODEL_LOCAL})"),
    (axes[1], "pred_openai", f"OpenAI ({MODEL_OPENAI})"),
]:
    valid = test_df[test_df[col] != "unbekannt"]
    cm = confusion_matrix(valid["ressort"], valid[col], labels=SECTIONS)
    sns.heatmap(cm, annot=True, fmt="d", ax=ax,
                xticklabels=SECTIONS, yticklabels=SECTIONS, cmap="Blues")
    ax.set_xlabel("Vorhergesagt")
    ax.set_ylabel("Wahr")
    ax.set_title(title)
    ax.tick_params(axis="x", rotation=45)

plt.tight_layout()
plt.show()

Typische Verwechslungen: ausland und inland (beide berichten über politische Ereignisse auf unterschiedlichen geografischen Ebenen), wirtschaft und inland (Wirtschaftspolitik liegt im Grenzbereich beider Ressorts). wissen und faktenfinder werden seltener verwechselt, da sie inhaltlich distinktive Begriffe und Formulierungen enthalten.

14. Berechnet für jedes Ressort Precision, Recall und F1-Score mithilfe von sklearn.metrics.classification_report. Gebt den Report für beide Modelle aus.

Welches Ressort ist am schwersten zu klassifizieren? Diskutiert, warum das so ist.

from sklearn.metrics import classification_report

for col, title in [("pred_local", "Local"), ("pred_openai", "OpenAI")]:
    valid = test_df[test_df[col] != "unbekannt"]
    print(f"\n=== {title} ===")
    print(classification_report(
        valid["ressort"], valid[col], labels=SECTIONS, zero_division=0
    ))

investigativ und faktenfinder weisen häufig niedrigen Recall auf: Ihr Stil ähnelt dem von inland- oder ausland-Artikeln, wenn der Faktencheck-Charakter sich nicht explizit im Titel manifestiert. Das LLM sieht keinen strukturellen Unterschied, wenn der redaktionelle Rahmen fehlt.

Schritt 5: Sentiment-Analyse im Zeitverlauf

Mit den Grundlagen aus Schritt 3 könnt ihr jetzt eine umfangreichere Analyse aufsetzen: Wie verändert sich die Grundstimmung der Tagesschau-Berichterstattung über Ressorts und Jahre hinweg?

15. Wendet die Funktion rate_sentiment() aus Aufgabe 10 auf den gesamten Stichprobencorpus an. Nutzt tqdm.pandas() und progress_apply(), um den Fortschritt zu verfolgen. Speichert die Ergebnisse als neue Spalte sentiment im DataFrame.

Wie viele Werte konnten nicht geparst werden (d.h. ergaben None)? Was könnte der Grund dafür sein?

tqdm.pandas()

df["sentiment"] = df.progress_apply(
    lambda r: rate_sentiment(r["title"], r["text"], client_local, MODEL_LOCAL),
    axis=1
)

missing = df["sentiment"].isna().sum()
print(f"Fehlende Werte: {missing} von {len(df)} ({missing/len(df):.1%})")

Fehlende Werte entstehen, wenn das Modell eine unerwartete Ausgabe liefert, zum Beispiel eine Erklärung statt einer Zahl, oder die Zahl mit einem Vorzeichen wie „+2” formatiert. temperature=0 reduziert solche Abweichungen, eliminiert sie aber nicht vollständig – besonders bei kleinen lokalen Modellen.

16. Berechnet den Durchschnittssentiment pro Ressort und Jahr für Artikel ab 2010. Speichert das Ergebnis als Pivot-Tabelle (Zeilen = Ressorts, Spalten = Jahre). Gebt die Tabelle aus.

pivot_sentiment = (
    df[df["year"] >= 2010]
    .dropna(subset=["sentiment"])
    .groupby(["ressort", "year"])["sentiment"]
    .mean()
    .round(2)
    .unstack()
)

print(pivot_sentiment)

17. Visualisiert die Pivot-Tabelle als Heatmap (x-Achse: Jahr, y-Achse: Ressort, Farbe: Durchschnittssentiment). Verwendet eine divergierende Farbskala, die negative Werte (rot) von positiven (grün) unterscheidet, mit 0 als Mittelpunkt.

Was fällt auf? In welchen Jahren und Ressorts überwiegt negatives Sentiment? Lassen sich gesellschaftliche Ereignisse (z.B. Wirtschaftskrisen, Pandemie) in der Heatmap ablesen?

plt.figure(figsize=(14, 4))
sns.heatmap(
    pivot_sentiment,
    cmap="RdYlGn", center=0, vmin=-2, vmax=2,
    annot=True, fmt=".1f", linewidths=0.4
)
plt.title("Durchschnittliches Sentiment je Ressort und Jahr")
plt.xlabel("Jahr")
plt.ylabel("Ressort")
plt.tight_layout()
plt.show()

Erwartbare Muster: wirtschaft zeigt in Krisenjahren (2009, 2020, 2022) negativere Werte; wissen liegt strukturell neutraler, da Wissenschaftsjournalismus weniger ereignisbezogen ist; die Covid-Jahre 2020–2021 erzeugen ressortübergreifend ein Negativsignal.

Schritt 6: Wer spricht über wen?

Named-Entity-Recognition (NER) identifiziert Personen, Orte und Organisationen in Texten. Im Unterschied zur regelbasierten Erkennung mit Namenslisten versteht ein LLM den Kontext, erkennt verschiedene Schreibweisen und unterscheidet Personen mit gleichem Nachnamen.

18. Schreibt eine Funktion extract_persons(text, client, model), die alle im Text genannten Personen als Python-Liste zurückgibt. Der Prompt soll das Modell anweisen, ausschließlich Personennamen (Vor- und Nachname) zurückzugeben – keine Orte oder Organisationen. Das Ergebnis soll ein JSON-Dictionary der Form {"personen": ["Name1", "Name2"]} sein.

Testet die Funktion an fünf Artikeln und beurteilt die Qualität der Extraktion.

def extract_persons(text, client, model):
    prompt = f"""Extrahiere alle im folgenden Text genannten Personennamen (Vor- und Nachname).
Gib ausschließlich Personen zurück, keine Orte oder Organisationen.
Format: {{"personen": ["Vorname Nachname", ...]}}
Falls keine Personen genannt werden: {{"personen": []}}

Text: {str(text)[:800]}"""

    result = llm_json(prompt, client, model)
    return result.get("personen", [])

for _, row in df.sample(5, random_state=3).iterrows():
    personen = extract_persons(row["text"], client_local, MODEL_LOCAL)
    print(f"{row['title'][:65]}")
    print(f"  → {personen}\n")

19. Wendet extract_persons() auf alle Artikel im Korpus an und bringt das Ergebnis mit explode() in Langform: eine Zeile pro genannter Person. Behaltet year, ressort und url als Kontextspalten.

Erstellt eine Rangliste der 20 meistgenannten Personen im gesamten Datensatz. Wer steht an der Spitze, und entspricht das euren Erwartungen?

df["persons_list"] = df.progress_apply(
    lambda r: extract_persons(r["text"], client_local, MODEL_LOCAL),
    axis=1
)

persons_df = (
    df[["url", "year", "ressort", "persons_list"]]
    .explode("persons_list")
    .dropna(subset=["persons_list"])
    .rename(columns={"persons_list": "person"})
    .query("person != ''")
    .reset_index(drop=True)
)

print(persons_df["person"].value_counts().head(20))

20. Wählt fünf bekannte Politiker aus dem Datensatz – zum Beispiel Merkel, Scholz, Merz, Baerbock und Habeck. Schreibt eine Funktion person_sentiment(text, person, client, model), die bewertet, wie die genannte Person in einem Artikel dargestellt wird (Skala -2 bis +2). Gibt None zurück, wenn die Person nicht erwähnt wird.

Filtert den persons_df auf Artikel, in denen mindestens einer der fünf Politiker vorkommt, und wendet die Funktion an.

POLITICIANS = ["Merkel", "Scholz", "Merz", "Baerbock", "Habeck"]

def person_sentiment(text, person, client, model):
    prompt = f"""Wird {person} in diesem Text erwähnt?
Falls ja: Wie wird {person} in diesem Artikel dargestellt?
Skala: -2 (sehr kritisch/negativ) bis +2 (sehr positiv/zustimmend), 0 = neutral/sachlich
Falls {person} nicht erwähnt wird, antworte mit: null

Antworte ausschließlich mit einer ganzen Zahl (-2, -1, 0, 1, 2) oder dem Wort null.

Text: {str(text)[:600]}"""

    result = llm_text(prompt, client, model).strip().lower()
    if result == "null":
        return None
    try:
        return int(result)
    except ValueError:
        return None

politician_articles = (
    persons_df[persons_df["person"].str.contains("|".join(POLITICIANS), na=False)]
    .merge(df[["url", "text", "year"]], on="url")
    .drop_duplicates(subset=["url", "person"])
    .copy()
)

politician_articles["politician_sentiment"] = politician_articles.progress_apply(
    lambda r: person_sentiment(r["text"], r["person"], client_local, MODEL_LOCAL),
    axis=1
)

21. Aggregiert das Sentiment pro Politiker und Jahr (ab 2010) und visualisiert es als Heatmap (x-Achse: Jahr, y-Achse: Politiker, Farbe: Durchschnittssentiment). Verwendet dieselbe divergierende Farbskala wie in Aufgabe 17.

Welche Muster sind erkennbar? Gibt es Jahre, in denen ein Politiker besonders negativ oder positiv dargestellt wurde?

politician_pivot = (
    politician_articles[politician_articles["year"] >= 2010]
    .dropna(subset=["politician_sentiment"])
    .groupby(["person", "year"])["politician_sentiment"]
    .mean()
    .round(2)
    .unstack()
)

plt.figure(figsize=(14, 4))
sns.heatmap(
    politician_pivot,
    cmap="RdYlGn", center=0, vmin=-2, vmax=2,
    annot=True, fmt=".1f", linewidths=0.4
)
plt.title("Darstellung von Politiker:innen in der Tagesschau (LLM-Sentiment)")
plt.xlabel("Jahr")
plt.ylabel("Politiker:in")
plt.tight_layout()
plt.show()

Achtung: Die Stichprobengröße pro Politiker und Jahr ist begrenzt. Einzelne Werte können stark durch wenige Artikel beeinflusst sein. Vorsicht bei der Überinterpretation – für belastbare Aussagen wäre ein größerer Korpus nötig.

Schritt 7: Framing-Analyse mit LLMs

Framing beschreibt, welchen Deutungsrahmen ein Artikel einem Thema gibt. Derselbe Migrationsbericht kann als humanitäre Krise, als Sicherheitsbedrohung oder als politischer Streit gerahmt sein. LLMs können solche impliziten Rahmungen erkennen – etwas, das regelbasierte Verfahren kaum leisten können.

22. Filtert den Korpus auf Artikel zum Thema Migration, indem ihr prüft, ob der Titel eines der Wörter „Migration”, „Flüchtling”, „Asyl”, „Migranten” oder „Einwanderer” enthält. Wie viele solche Artikel gibt es im Stichprobencorpus?

Schreibt anschließend eine Funktion analyze_framing(title, text, client, model), die das dominante Framing eines Artikels klassifiziert. Verwendet mindestens fünf Kategorien: humanitaer, sicherheitspolitisch, rechtlich, wirtschaftlich, politischer_streit.

FRAMING_CATEGORIES = [
    "humanitaer", "sicherheitspolitisch", "rechtlich",
    "wirtschaftlich", "politischer_streit"
]

def analyze_framing(title, text, client, model):
    prompt = f"""Analysiere das Framing des Themas Migration in diesem Tagesschau-Artikel.
Wähle das dominante Framing:
- humanitaer: Menschliches Leid, Fluchterfahrungen, Hilfsbereitschaft
- sicherheitspolitisch: Grenzsicherung, Kriminalität, Bedrohung
- rechtlich: Asylverfahren, Gesetze, Bürokratie, Gerichtsurteile
- wirtschaftlich: Arbeitsmarkt, Kosten, Fachkräfte, Sozialsystem
- politischer_streit: Koalitionsstreit, Parteipositionen, Wahlkampf

Antworte als JSON: {{"framing": "...", "begruendung": "Ein kurzer Satz."}}

Titel: {title}
Text: {str(text)[:600]}"""

    result = llm_json(prompt, client, model)
    framing = result.get("framing", "sonstiges").lower().replace(" ", "_")
    return framing if framing in FRAMING_CATEGORIES else "sonstiges"

migration_df = df[
    df["title"].str.contains(
        "Migration|Flüchtling|Asyl|Migranten|Einwanderer", case=False, na=False
    )
].copy()

print(f"Migrationsartikel im Stichprobencorpus: {len(migration_df)}")

23. Wendet analyze_framing() auf alle gefundenen Migrationsartikel an. Erstellt zwei Visualisierungen:

  • Ein horizontales Balkendiagramm der Gesamtverteilung aller Framing-Kategorien.
  • Ein gestapeltes Balkendiagramm der Framing-Verteilung nach Jahr (falls Artikel aus mehr als zwei Jahren vorliegen).

Welches Framing dominiert in der Tagesschau-Berichterstattung über Migration? Hat sich das im Zeitverlauf verändert?

migration_df["framing"] = migration_df.progress_apply(
    lambda r: analyze_framing(r["title"], r["text"], client_local, MODEL_LOCAL),
    axis=1
)

# Gesamtverteilung
migration_df["framing"].value_counts().plot.barh(figsize=(8, 4), color="steelblue")
plt.title("Framing von Migration in der Tagesschau (Stichprobe)")
plt.xlabel("Anzahl Artikel")
plt.tight_layout()
plt.show()

# Zeitliche Entwicklung
if migration_df["year"].nunique() > 2:
    pd.crosstab(migration_df["year"], migration_df["framing"]).plot.bar(
        stacked=True, figsize=(12, 5), colormap="tab10"
    )
    plt.title("Framing-Entwicklung: Migration im Zeitverlauf")
    plt.xlabel("Jahr")
    plt.ylabel("Anzahl Artikel")
    plt.legend(bbox_to_anchor=(1.05, 1), loc="upper left")
    plt.tight_layout()
    plt.show()

Schritt 8: Reflexion

24. Vergleicht die LLM-basierte Analyse mit der regelbasierten Analyse aus dem vorherigen Experiment entlang von vier Dimensionen. Füllt die folgende Tabelle aus und begründet eure Einschätzungen:

Dimension Regelbasiert LLM-basiert
Transparenz
Reproduzierbarkeit
Skalierbarkeit
Qualität

Für welche Aufgaben eignet sich welcher Ansatz besser?

Dimension Regelbasiert LLM-basiert
Transparenz Hoch: Regeln sind explizit und nachvollziehbar Niedrig: Entscheidungen entstehen im „Black Box”
Reproduzierbarkeit Perfekt: gleiche Eingabe → immer gleiche Ausgabe Bedingt: temperature=0 hilft, garantiert es aber nicht
Skalierbarkeit Sehr hoch: kein API-Aufruf, kein Kostenlimit Begrenzt durch Kosten und Latenz
Qualität Gut bei klaren, exakten Mustern Besser bei Ambiguität, Kontext, Nuancen

Fazit: Regelbasierte Verfahren eignen sich für schnelle, skalierbare Suche und stabile Muster (z.B. Keyword-Trends, Artikelformate). LLMs sind überlegen bei Aufgaben, die kontextuelles Verstehen erfordern: Sentiment, Framing, Zusammenfassungen, Named-Entity-Extraktion in fließendem Text.

25. Diskutiert die folgenden ethischen und methodischen Fragen der LLM-basierten Inhaltsanalyse:

  • Können LLMs politisch voreingenommen sein? Wie würdet ihr das methodisch testen?
  • Welche Risiken entstehen, wenn LLM-basierte Analysen in journalistische oder wissenschaftliche Entscheidungen einfließen?
  • Wie lässt sich die Qualität einer LLM-Analyse valide messen – und was bedeutet das für eure Ergebnisse aus diesem Experiment?

Politische Voreingenommenheit: LLMs spiegeln die Biases ihrer Trainingsdaten wider. Ein methodischer Test wäre, denselben Sachverhalt mit vertauschten politischen Akteuren zu beschreiben und zu prüfen, ob die Sentiment-Bewertungen symmetrisch bleiben – also ob das Modell dieselbe Aussage von Politikerin A gleich bewertet wie dieselbe Aussage von Politiker B.

Risiken: LLM-Bewertungen können subtil und systematisch verzerrt sein, ohne dass es offensichtlich ist. Werden solche Analysen unreflektiert als Grundlage für Entscheidungen genutzt – etwa im Monitoring von Medientendenzen –, werden die Verzerrungen institutionalisiert.

Qualitätsmessung: Goldstandard-Validierung: Eine Teilmenge der Daten wird von menschlichen Codiererinnen und Codierern manuell annotiert und mit den LLM-Ergebnissen verglichen. Cohen’s κ oder Krippendorffs α messen die Übereinstimmung zwischen Mensch und Modell. Für die Ergebnisse dieses Experiments bedeutet das: Alle Befunde sind als explorative Hypothesen zu verstehen, nicht als gesicherte Fakten.

Zusatzmaterial

Zu diesem Experiment gibt es folgendes Zusatzmaterial: