Files
swp-02-aufbereitung/akteure.py
T
irrlichtandClaude Opus 5 7e4c31912c Akteursmodul mit Wikidata-Spiegel
Modul 4 der Spezifikation, dazu das Lexikon, auf das es sich stuetzt.

Drei Schritte, streng getrennt: die NER liefert Kandidatenspannen, das
Lexikon entscheidet den Typ, die Amtstabelle loest Aemter gegen das Datum
des Items auf. Die Trennung ist gemessen begruendet und nicht uebernommen:
de_core_news_lg haelt "Merz" fuer einen Ort, "Landtagswahl" fuer einen Ort
und "Ukraine-Krieg" fuer eine Person. Der NER-Typ steht als ner_typ in der
Nutzlast, entscheidet aber nichts.

Modellwahl gemessen: sm und lg finden fast gleich viele Entitaeten (14249
gegen 14261), aber nicht dieselben - nur 10,7 Prozent der Items bekommen
von beiden dasselbe Ergebnis, der Jaccard ueber alle Spannen liegt bei
0,590. lg findet 170-mal zusaetzlich "AfD" sowie Merz, Putin, Trump,
Kushner, Witkoff. Zeitlich kostet das nichts (7,3 gegen 8,0 Sekunden fuer
den Gesamtbestand), an Plattenplatz 568 MB.

Der Spiegel ist eine Abschrift, keine Auslegung: lexikon/*.json enthaelt
die Wikidata-Antworten unveraendert, mit MANIFEST.json als Beleg. Jede
Ableitung geschieht in lexikon_laden.py. Nur lexikon.py --spiegeln fasst
das Netz an, und es laeuft von Hand - die WDQS ist ein spendenfinanzierter
Dienst, kein Endpunkt fuer eine Pipeline im Viertelstundentakt.

p2-005 legt akteure und amtsinhaber an, dazu zwei Betriebssichten. Keine
Ausschlussbeschraenkung gegen ueberlappende Amtszeiten: Doppelspitzen sind
im deutschen Parteiensystem der Normalfall, und Bremen fuehrt
"Buergermeister" als kollegiales Amt. Die Sicht
amtsinhaber_ueberschneidungen macht Ueberlappungen sichtbar, statt sie zu
verbieten. Amtszeiten sind halboffen - der Tag der Uebergabe gehoert dem
Nachfolger, sonst waere jeder Amtswechsel eine Ueberschneidung.

Messung ueber den Gesamtbestand: 3407 Items in 21,1 Sekunden, 14652
Nennungen, 2889 aufgeloest. Ein Slot-Lauf erzeugt bitgleich dieselbe
Nutzlast wie der Gesamtlauf, 0 Abweichungen. spaCy ist stapelunabhaengig
(Stapelgroesse 64, 7, 1 und rueckwaerts: identische Spannen), der
Kunstgriff mit der festen Tokenlaenge aus dem Embeddingmodul ist hier
nicht noetig.

Zwei Fallen sind in NOTES.md festgehalten: eine geratene QID haengt einen
Eintrag an ein fremdes Objekt (Q157617 ist die Commerzbank, nicht der
Bundesrat), und FILTER (lang(?name) = "de") verliert lautlos alles ohne
deutsches Label - Apple, Microsoft und Meta.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NupWEBPzsyohVPG47HsTz8
2026-09-08 08:27:05 +02:00

456 lines
16 KiB
Python

"""Modul 4: Akteursaufloesung (ebene = 'akteure').
Drei Schritte, streng getrennt:
NER liefert Kandidatenspannen - nur wo, nicht was
Lexikon entscheidet, was eine Spanne ist
Amtstabelle loest Aemter gegen das Datum des Items auf
Die Trennung ist gemessen begruendet: de_core_news_lg haelt "Merz" fuer
einen Ort, "Landtagswahl" fuer einen Ort und "Ukraine-Krieg" fuer eine
Person (siehe NOTES.md). Der Typ einer Nennung kommt deshalb aus dem
Lexikon, nie aus dem Modell. Der NER-Typ wird trotzdem mitgeschrieben -
die Geokodierung braucht ihn als Vorfilter, und er dokumentiert, woher
eine Spanne stammt.
Nicht aufloesbare Nennungen bleiben mit id null stehen. Sie sind kein
Ausschuss, sondern das Rohmaterial der Lexikonpflege; die Sicht
akteure_unaufgeloest zaehlt sie aus.
"""
import argparse
import collections
import datetime
import hashlib
import re
import sys
import time
import unicodedata
import db
import normalisierung
import version
EBENE = "akteure"
NER_MODELL = "de_core_news_lg"
STAPEL = 64
# Obergrenze fuer eine Oberflaechenform in der Nutzlast.
FORM_MAX = 100
# Nur diese NER-Typen werden als Kandidaten betrachtet. MISC bleibt drin,
# weil beide Modelle Parteien regelmaessig dort einsortieren - AfD steht
# 226-mal als MISC im sm-Lauf.
TYPEN = ("PER", "ORG", "LOC", "MISC")
# Fuzzy-Match: erlaubte Editierdistanz nach Laenge der Oberflaechenform.
# Kurze Formen vertragen keinen Fehler - aus "Wolf" wuerde sonst "Wolff",
# "Rolf" und "Golf".
def fuzzy_grenze(laenge):
if laenge < 6:
return 0
if laenge < 12:
return 1
return 2
# Woerter, die als Nachname im Lexikon stehen, in Schlagzeilen aber fast
# immer etwas anderes meinen. Handpflege, mit Begruendung je Eintrag.
NACHNAMEN_SPERRE = {
"wolf", # Katja Wolf, aber auch das Tier - in dieser Saison haeufig
"merkel", # nicht im Lexikon, aber als Nachname mehrdeutig belegt
"bund",
"land",
"recht",
"kraft",
"post",
"stark",
"reich",
"gross",
"klein",
"lang",
"kurz",
"jung",
"alt",
"neu",
"weiss",
"schwarz",
"braun",
"gruen",
"hacker", # Thomas Hacker, MdB - in Schlagzeilen fast nie gemeint
"un", # Nachname von Kim Jong-un, in Schlagzeilen die UN
"partei",
"post",
"bahn",
"welt",
"zeit",
"spiegel",
"focus",
}
# Aliase, die Wikidata fuehrt, die in Schlagzeilen aber regelmaessig etwas
# anderes meinen. Wirkt auf den Alias-Match, nicht nur auf Nachnamen.
FORMEN_SPERRE = {
"europa", # Wikidata fuehrt es als Alias der EU. Meist ist der
# Kontinent gemeint, und der ist Sache der Geokodierung.
"europas",
"union", # CDU/CSU oder Europaeische Union - ohne Kontext nicht zu
# entscheiden, und beide Lesarten sind haeufig.
}
EINSTELLUNGEN_FEST = {
"ner_modell": NER_MODELL,
"basis": "titel+teaser",
"text": "bereinigt",
"ner_typen": list(TYPEN),
"typ_aus": "lexikon",
"fuzzy": "editierdistanz_nach_laenge",
"nachnamen": "eindeutig_oder_kontext",
}
_UMLAUTE = str.maketrans({"ä": "ae", "ö": "oe", "ü": "ue", "ß": "ss"})
_UNRAT = re.compile(r"[^a-z0-9 ]+")
_MEHRFACH = re.compile(r"\s+")
# Titel und Anreden, die vor einem Namen stehen und nicht zu ihm gehoeren.
_ANREDE = re.compile(
r"^(?:der|die|das|dem|den|des|ex|alt)?[- ]?"
r"(?:bundes|landes|us|eu|nato|un|uno)?[- ]?"
r"(?:kanzler(?:in)?|praesident(?:in)?|minister(?:in)?|ministerpraesident(?:in)?|"
r"aussenminister(?:in)?|innenminister(?:in)?|chef(?:in)?|sprecher(?:in)?|"
r"vorsitzende[rn]?|abgeordnete[rn]?|spitzenkandidat(?:in)?|kandidat(?:in)?|"
r"parteichef(?:in)?|fraktionschef(?:in)?|gesandte[rn]?|premier(?:minister)?)"
r"[- ]",
)
def vergleichsform(text):
"""Form, in der Oberflaeche und Lexikon miteinander verglichen werden."""
text = unicodedata.normalize("NFKC", text).casefold().translate(_UMLAUTE)
text = unicodedata.normalize("NFKD", text)
text = "".join(z for z in text if not unicodedata.combining(z))
return _MEHRFACH.sub(" ", _UNRAT.sub(" ", text)).strip()
def ohne_anrede(form):
"""'us-praesident trump' -> 'trump'. Mehrfach, fuer Ketten."""
vorher = None
while vorher != form:
vorher = form
form = _ANREDE.sub("", form).strip()
return form or vorher
def distanz(a, b, grenze):
"""Levenshtein, abgebrochen sobald die Grenze ueberschritten ist."""
if abs(len(a) - len(b)) > grenze:
return grenze + 1
vorige = list(range(len(b) + 1))
for i, za in enumerate(a, 1):
zeile = [i]
for j, zb in enumerate(b, 1):
zeile.append(min(vorige[j] + 1, zeile[j - 1] + 1,
vorige[j - 1] + (za != zb)))
if min(zeile) > grenze:
return grenze + 1
vorige = zeile
return vorige[-1]
class Lexikon:
"""Nachschlagewerk im Speicher. Wird einmal je Lauf gebaut."""
def __init__(self, akteure, amtszeiten):
self.akteure = {a["id"]: a for a in akteure}
self.formen = collections.defaultdict(set)
self.nachnamen = collections.defaultdict(set)
self.amtszeiten = collections.defaultdict(list)
for a in akteure:
for form in [a["name"], *a["aliase"]]:
v = vergleichsform(form)
if v:
self.formen[v].add(a["id"])
if a["typ"] == "person":
teile = vergleichsform(a["name"]).split()
if len(teile) > 1 and teile[-1] not in NACHNAMEN_SPERRE:
self.nachnamen[teile[-1]].add(a["id"])
for z in amtszeiten:
self.amtszeiten[z["amt_id"]].append(z)
for eintraege in self.amtszeiten.values():
eintraege.sort(key=lambda z: z["von"])
# Fuzzy-Suche nur innerhalb eines Laengenfensters, sonst waere sie
# ein Kreuzprodukt aus 14000 Spannen und 12000 Formen.
self.nach_laenge = collections.defaultdict(list)
for v in self.formen:
self.nach_laenge[len(v)].append(v)
def inhaber(self, amt_id, datum):
"""Wer hatte dieses Amt an diesem Tag."""
treffer = []
for z in self.amtszeiten.get(amt_id, ()):
# Halboffen, wie in der Sicht amtsinhaber_ueberschneidungen:
# am Tag der Uebergabe zaehlt der Nachfolger.
if z["von"] <= datum and (z["bis"] is None or datum < z["bis"]):
treffer.append(z["person_id"])
return treffer
def schlage_nach(self, form):
"""(ids, methode) fuer eine Vergleichsform."""
if form in FORMEN_SPERRE:
return set(), None
if form in self.formen:
return self.formen[form], "alias"
kurz = ohne_anrede(form)
if kurz in FORMEN_SPERRE:
return set(), None
if kurz != form and kurz in self.formen:
return self.formen[kurz], "alias_ohne_anrede"
if kurz in self.nachnamen:
return self.nachnamen[kurz], "nachname"
grenze = fuzzy_grenze(len(kurz))
if grenze:
beste, bester_abstand = set(), grenze + 1
for laenge in range(len(kurz) - grenze, len(kurz) + grenze + 1):
for kandidat in self.nach_laenge.get(laenge, ()):
if kandidat[0] != kurz[0]:
continue
d = distanz(kurz, kandidat, grenze)
if d > grenze:
# distanz() liefert bei Abbruch grenze+1. Ohne diese
# Zeile zaehlt jeder Abbruch als Gleichstand mit
# jedem anderen Abbruch, und die Kandidatenliste
# fuellt sich mit allem, was zufaellig gleich lang
# ist und mit demselben Buchstaben beginnt.
continue
if d < bester_abstand:
beste, bester_abstand = set(self.formen[kandidat]), d
elif d == bester_abstand:
beste |= self.formen[kandidat]
if beste:
return beste, "fuzzy"
return set(), None
def lade_lexikon(conn):
with conn.cursor() as k:
k.execute("select id, typ, name, aliase, land, rolle from akteure")
akteure = [
{"id": r[0], "typ": r[1], "name": r[2], "aliase": r[3],
"land": r[4], "rolle": r[5]}
for r in k.fetchall()
]
k.execute("select amt_id, person_id, von, bis from amtsinhaber")
amtszeiten = [
{"amt_id": r[0], "person_id": r[1], "von": r[2], "bis": r[3]}
for r in k.fetchall()
]
return Lexikon(akteure, amtszeiten)
def waehle(lexikon, ids, kontext):
"""Aus mehreren Kandidaten einen machen - oder keinen.
Regelbasiert und in dieser Reihenfolge, wie die Spezifikation es
verlangt:
1. Ist genau einer gemeint, ist die Sache erledigt.
2. Wird die Partei eines Kandidaten im selben Item genannt, gewinnt er.
3. Sonst bleibt die Nennung unaufgeloest. Ein Muenzwurf waere hier der
schlimmere Fehler, weil er in der Zeitreihe nicht auffaellt.
"""
if len(ids) == 1:
return next(iter(ids)), "eindeutig"
mit_partei = [i for i in ids
if (lexikon.akteure[i].get("notiz") or "") and
vergleichsform(lexikon.akteure[i]["notiz"]) in kontext]
if len(mit_partei) == 1:
return mit_partei[0], "kontext_partei"
return None, "mehrdeutig"
def erkennungen(nlp, titel, teaser):
"""Kandidatenspannen je Feld, mit Offsets im bereinigten Text."""
felder = [("titel", normalisierung.bereinige(titel)),
("teaser", normalisierung.bereinige(teaser))]
ergebnis = []
for feld, text in felder:
if not text:
continue
for ent in nlp(text).ents:
if ent.label_ in TYPEN:
ergebnis.append({"feld": feld,
# Die Nutzlast darf keinen Volltext tragen
# (Constraint p2_nutzlast_ohne_text). Eine
# Nennung ist nie so lang; start/ende machen
# den Urtext ohnehin auffindbar.
"form": ent.text[:FORM_MAX],
"start": ent.start_char, "ende": ent.end_char,
"ner_typ": ent.label_})
return ergebnis
def aufloesen(lexikon, spannen, datum):
"""Spannen gegen das Lexikon aufloesen. Reine Funktion, testbar."""
kontext = " ".join(vergleichsform(s["form"]) for s in spannen)
erkannt = []
for s in spannen:
form = vergleichsform(s["form"])
ids, methode = lexikon.schlage_nach(form)
eintrag = dict(s)
if not ids:
eintrag.update({"id": None, "typ": None, "rolle": None,
"methode": "ner_unaufgeloest", "konfidenz": 0.4})
erkannt.append(eintrag)
continue
gewaehlt, grund = waehle(lexikon, ids, kontext)
if gewaehlt is None:
eintrag.update({"id": None, "typ": None, "rolle": None,
"methode": f"{methode}_{grund}", "konfidenz": 0.3,
"kandidaten": sorted(ids)[:5]})
erkannt.append(eintrag)
continue
a = lexikon.akteure[gewaehlt]
konfidenz = {"alias": 0.95, "alias_ohne_anrede": 0.9,
"nachname": 0.8, "fuzzy": 0.6}[methode]
if grund == "kontext_partei":
konfidenz -= 0.1
eintrag.update({"id": gewaehlt, "typ": a["typ"], "rolle": a["rolle"],
"methode": methode, "konfidenz": round(konfidenz, 2)})
# Aemter gegen das Datum des Items aufloesen. Das Amt bleibt stehen,
# die Person kommt daneben - beides ist eine Aussage ueber den Text.
if a["typ"] == "amt" and datum:
inhaber = lexikon.inhaber(gewaehlt, datum)
if len(inhaber) == 1:
eintrag["inhaber"] = inhaber[0]
eintrag["methode"] = "amt"
elif inhaber:
eintrag["inhaber_mehrdeutig"] = sorted(inhaber)
erkannt.append(eintrag)
return erkannt
def nutzlast(erkannt, lexikon_stand):
aufgeloest = [e for e in erkannt if e["id"]]
return {
"erkannt": erkannt,
"ner_modell": NER_MODELL,
"lexikon_version": lexikon_stand,
"nennungen": len(erkannt),
"aufgeloest": len(aufgeloest),
"typen": dict(collections.Counter(e["typ"] for e in aufgeloest)),
}
_SPALTEN = "select id, titel, teaser, pubdate from raw_items"
def lade(conn, slot=None):
with conn.cursor() as k:
if slot:
k.execute(_SPALTEN + " where slot = %s order by id", (slot,))
else:
k.execute(_SPALTEN + " order by id")
return k.fetchall()
def lexikon_stand(conn):
"""Fingerabdruck des Lexikons.
Geht in die coder_version ein, und zwar inhaltlich: ein einziger neuer
Alias aendert den Hash und damit die Version. Eine blosse Zeilenzahl
taete das nicht - dann waere eine Korrektur am Lexikon von aussen
unsichtbar, und alte Codings behaupteten eine Grundlage, die es nicht
mehr gibt.
"""
with conn.cursor() as k:
k.execute("""select id, typ, name, aliase, land, rolle
from akteure order by id""")
akteure_zeilen = k.fetchall()
k.execute("""select amt_id, person_id, von, bis
from amtsinhaber order by amt_id, person_id, von""")
amtszeilen = k.fetchall()
roh = repr(akteure_zeilen) + repr(amtszeilen)
kurz = hashlib.blake2b(roh.encode("utf-8"), digest_size=6).hexdigest()
return f"{len(akteure_zeilen)}+{len(amtszeilen)}/{kurz}"
def einstellungen(stand):
return dict(EINSTELLUNGEN_FEST, lexikon=stand)
def verarbeite(conn, slot=None, trocken=False):
import spacy
beginn = time.time()
lexikon = lade_lexikon(conn)
stand = lexikon_stand(conn)
v = version.coder_version(version.komponenten(akteure=einstellungen(stand)))
if not trocken:
version.eintragen(conn, version.komponenten(akteure=einstellungen(stand)))
nlp = spacy.load(NER_MODELL, exclude=["lemmatizer"])
zeilen = lade(conn, slot)
print(f"{EBENE}: {len(zeilen)} Items, Version {v}, Lexikon {stand}")
saetze, zahlen = [], collections.Counter()
for item_id, titel, teaser, pubdate in zeilen:
datum = pubdate.date() if isinstance(pubdate, datetime.datetime) else pubdate
spannen = erkennungen(nlp, titel, teaser)
erkannt = aufloesen(lexikon, spannen, datum)
zahlen["nennungen"] += len(erkannt)
zahlen["aufgeloest"] += sum(1 for e in erkannt if e["id"])
saetze.append((item_id, v, nutzlast(erkannt, stand)))
if trocken:
print(f" trocken, nichts geschrieben: {dict(zahlen)}")
return zahlen
from psycopg.types.json import Jsonb
with conn.cursor() as k:
k.executemany(
"""insert into codings (raw_item_id, coder_version, ebene, nutzlast)
values (%s, %s, %s, %s)
on conflict (raw_item_id, coder_version, ebene) do update
set nutzlast = excluded.nutzlast, kodiert_am = now()""",
[(i, ver, EBENE, Jsonb(n)) for i, ver, n in saetze],
)
k.execute(
"""insert into p2_laeufe (slot, modul, coder_version, dauer_ms,
items_gesehen, items_kodiert, items_fehler)
values (%s, %s, %s, %s, %s, %s, 0)""",
(slot, EBENE, v, int((time.time() - beginn) * 1000),
len(zeilen), len(saetze)),
)
conn.commit()
quote = zahlen["aufgeloest"] / zahlen["nennungen"] if zahlen["nennungen"] else 0
print(f" {len(saetze)} Codings, {zahlen['nennungen']} Nennungen, "
f"{zahlen['aufgeloest']} aufgeloest ({quote:.1%}), "
f"{time.time() - beginn:.1f}s")
return zahlen
def main(argumente=None):
p = argparse.ArgumentParser(description=__doc__.splitlines()[0])
g = p.add_mutually_exclusive_group(required=True)
g.add_argument("--backfill", action="store_true")
g.add_argument("--slot")
p.add_argument("--trocken", action="store_true")
a = p.parse_args(argumente)
with db.verbindung() as conn:
verarbeite(conn, slot=a.slot, trocken=a.trocken)
return 0
if __name__ == "__main__":
sys.exit(main())