Haiku aus dem Nationalrat¶
Haiku aus den stenographischen Sitzungsprotokollen des Nationalrat extrahieren.
Der korrekte Plural von Haiku ist Haiku.
Der Verständlichkeit halber wird im Code aber Haikus verwendet.
Die direkt vom Parlament veröffentlichten stenographischen Protkolle der Nationalratssitzungen sind schlecht strukturiert. Wir bauen deshalb auf Mario Zechner's Vorarbeiten in seinem Projekt Wos wor mei Leistung? auf. Sauber als JSON strukturierte Sitzungsprotkolle - die Doku direkt auf der Projektseite.
Als erstes Protokolle aus dem gecloned und ausgeführten Repo laden.
import json
import pandas as pd
with open("woswormeileistung/data/sessions.json") as f:
sessions = json.load(f)
wortmeldungen = pd.json_normalize(
sessions,
record_path=["sections"],
meta=["period", "sessionNumber", "date"],
)
wortmeldungen = wortmeldungen[["period", "sessionNumber", "date", "speaker", "text"]]
wortmeldungen = wortmeldungen.rename(columns={"sessionNumber": "session"})
wortmeldungen.head()
Die Wortmeldungen enthalten den Namen der sprechenden Person gefolgt von einem Doppelpunkt. Die wollen wir entfernen.
wortmeldungen["text"] = wortmeldungen["text"].str.split(": ", n=1).str[1]
wortmeldungen.head()
Alle möglichen Haiku im Text finden:
- Texte mit spacy in Sätze splitten
- Wenn möglich aus jedem Satz ein Haiku extrahieren:
- Ungeeignete Sätze überspringen. Solche enthalten etwa:
- Zahlen
- Klammern
- Phrasen die auf sehr häufige Aussagen hindeuten, z.B. "zu wort gemeldet" deutet auf die sehr häufige Aufforderung des bzw. der Nationalratspräsident:in zum Beginn einer Rede hin.
- Satz in Wörter splitten (Bindestrichwörter dabei nicht trennen)
- Zeile für Zeile des Haiku auffüllen und Silben mitzählen.
Dabei weitere Heuristiken anwenden um 'schlechte' Haiku rauszufiltern:
- Satzzeichen (abgesehen von Anführungszeichen und Bindestriche) dürfen nur am Ende einer Haiku-Zeile auftreten
- Der syntaktische Kopf von Wörtern am Beginn einer Zeile darf nicht auf der vorherigen Zeile liegen.
Gleiches gilt für Wörter am Ende einer Zeile - der syntaktische Kopf darf nicht auf der nächsten liegen.
Das filtert Haiku-Kandidaten mit stark zusammenhängenden Wörtern an den Zeilenenden.
Was ist der syntaktische Kopf? Im Beispiel "Die Katze sitzt auf der Matte":- Die -> Katze
- Katze -> sitzt
- auf -> sitzt
- der -> Matte
- Matte -> auf
- Jegliche Satzzeichen abgesehen von den Bindestrichen in Bindestrich-Wörtern nicht in die finalen Haiku-Zeilen aufnehmen
- Ungeeignete Sätze überspringen. Solche enthalten etwa:
import spacy
from spacy.tokenizer import Tokenizer
from spacy.util import compile_infix_regex
import pyphen
import regex
from tqdm import tqdm
!python -m spacy download de_core_news_lg
#!python -m spacy download de_core_news_sm
nlp = spacy.load("de_core_news_lg")
#nlp = spacy.load("de_core_news_sm")
# bindestrich-wörter nicht trennen
infixes = [x for x in nlp.Defaults.infixes if "-" not in x and "–" not in x and "—" not in x]
infix_re = compile_infix_regex(infixes)
nlp.tokenizer = Tokenizer(
nlp.vocab,
rules=nlp.Defaults.tokenizer_exceptions,
prefix_search=nlp.tokenizer.prefix_search,
suffix_search=nlp.tokenizer.suffix_search,
infix_finditer=infix_re.finditer,
)
dic = pyphen.Pyphen(lang="de_DE")
def count_syllables(word: str) -> int:
parts = word.split("-")
total = 0
for part in parts:
if not part:
continue
hyphenated = dic.inserted(part, hyphen="·")
total += hyphenated.count("·") + 1
return total
def extract_haiku(sentence):
pattern = r"^[\p{L} .!?'\":;,–—―]+$"
if not bool(regex.fullmatch(pattern, sentence)):
return None
common_phrases = ["zu wort gemeldet", "nächste rednerin", "nächster redner", "damen und herren"]
if any(phrase in sentence.lower() for phrase in common_phrases):
return None
tokens = nlp(sentence)
line_limits = [5, 7, 5]
line_idx = 0
line_sum = 0
extracted_haiku = [[], [], []]
for i in range(len(tokens)):
token = tokens[i]
if token.text in [",", ":", ";", "–", "—", "―"]:
if line_sum == 0:
continue
return None
if token.is_punct:
continue
if line_sum == 0 and token.head.i < i:
return None
extracted_haiku[line_idx].append(token.text)
line_sum += count_syllables(token.text)
if line_sum > line_limits[line_idx]:
return None
if line_sum == line_limits[line_idx]:
if token.head.i > i:
return None
line_idx += 1
line_sum = 0
if line_idx == 3:
remaining_syllables = sum([count_syllables(t.text) for t in tokens[i + 1 :] if not t.is_punct])
if remaining_syllables > 0:
return None
break
return [" ".join(line) for line in extracted_haiku] if line_idx == 3 else None
haikus = []
context_indices = []
for doc in tqdm(nlp.pipe(wortmeldungen["text"].fillna("").astype(str), batch_size=50), total=len(wortmeldungen)):
haikus.append([extract_haiku(sentence.text) for sentence in doc.sents])
context_indices.append([(sentence.start_char, sentence.end_char) for sentence in doc.sents])
wortmeldungen["lines"] = haikus
wortmeldungen["context_indices"] = context_indices
wortmeldungen.head()
Das Datenset auf eine Zeile pro Haiku flatten und den Kontext vor und nach den Haiku richtig setzen.
haikus = wortmeldungen.explode(["lines", "context_indices"]).dropna(subset=["lines"])
haikus["line1"] = haikus["lines"].str[0]
haikus["line2"] = haikus["lines"].str[1]
haikus["line3"] = haikus["lines"].str[2]
haikus["context_before"] = haikus.apply(lambda row: row["text"][: row["context_indices"][0]], axis=1)
haikus["context_after"] = haikus.apply(lambda row: row["text"][row["context_indices"][1] :], axis=1)
haikus = haikus.drop(columns=["text", "context_indices", "lines"])
haikus.head()
Die Gesetzgebungsperiode zusätzlich als arabische Zahl abspeichern.
import roman
haikus["period_roman"] = haikus["period"]
haikus["period"] = haikus["period"].apply(lambda periodRoman: roman.fromRoman(periodRoman))
haikus.head()
Das Personen Datenset verknüpfen.
personen = pd.read_json("woswormeileistung/data/persons.json")
personen = personen[["id", "name", "parties", "imageUrl"]]
personen = personen.rename(columns={"imageUrl": "image_url"})
personen["id"] = personen["id"].astype(str)
haikus = haikus.merge(personen, left_on="speaker", right_on="id", how="left")
haikus = haikus.drop(columns=["id"])
haikus = haikus.rename(columns={"name": "person_name", "speaker": "person_id"})
haikus["parties"] = haikus["parties"].apply(
lambda x: x if isinstance(x, list) and len(x) > 0 else ["Ohne Klub"]
)
haikus.head()
Für jedes Haiku eine möglichst stabile ID erzeugen. Diese ist ein Hash aus:
- Gesetzgebungsperiode
- Sitzungsnummer
- Person-ID
- die drei Haiku-Zeilen
import hashlib
def hash(row):
combined = (
f"{row['period']} {row['session']} {row["person_id"]} {row["line1"]} {row["line2"]} {row["line3"]}"
)
return hashlib.sha256(combined.encode()).hexdigest()
haikus["id"] = haikus.apply(hash, axis=1)
haikus.head()
Gibt es Kollisionen bei den Haiku-IDs nur das chronologisch älteste Haiku behalten.
Da die Haiku-ID Gesetzgebungsperiode, Sitzungsnummer und Person inkludiert muss es sich bei Haiku-ID-Kollisionen um ein in einem Redebeitrag mehrmals vorkommendes handeln.
Dadurch reicht die Länge des Kontexts vor dem Haiku um das "älteste" zu identifizieren.
haikus["context_before_length"] = haikus["context_before"].str.len()
haikus = haikus.sort_values(by=["date", "context_before_length"])
haikus = haikus.drop_duplicates(subset=["id"], keep="first")
haikus = haikus.drop(columns=["context_before_length"])
haikus.head()
Alle gefundenen Haiku als JSON speichern.
haikus.to_json("web/db/haikus.json", orient="records", force_ascii=False, indent=2)