fix: update SVD_THEMES labels to match actual motion content
- Component 1: EU-integratie → Defensie-uitgaven en NAVO-commitment - Component 2: Flip False (was True), clarify populistisch vs institutioneel - Component 4: Pragmatisch centrisme → Gematigde middenpartijen - Component 5: Christelijk-sociaal → Gemeenschapszin en sociale zekerheid - Component 6: Add migratie-integratie to label - Component 8: Complete rewrite - was COMPLETELY WRONG (vaccinatie, onderwijs) - Component 9: Decentraal bestuur → Pragmatische probleemoplossing - Component 10: Institutioneel toezicht → Kritisch op overheidsbemoeienis
This commit is contained in:
+484
-109
@@ -433,38 +433,37 @@ PARTY_COLOURS: Dict[str, str] = {
|
||||
# This is the canonical source of truth for SVD component labels.
|
||||
SVD_THEMES: dict[int, dict[str, str]] = {
|
||||
1: {
|
||||
"label": "EU-integratie en internationalisme versus nationalisme",
|
||||
"label": "Defensie-uitgaven en NAVO-commitment versus multilaterale instituties",
|
||||
"explanation": (
|
||||
"De dominante dimensie van het parlement: niet economisch links-rechts, maar "
|
||||
"EU-integratie en internationalisme versus nationalisme en soevereiniteit. "
|
||||
"Aan de linkerkant (Volt, GroenLinks-PvdA, DENK, PvdD, SP) staan moties over "
|
||||
"EU-verdragswijzigingen, EU-sancties tegen Israël, internationale samenwerking "
|
||||
"en het behouden van vluchtelingenstatus. Aan de rechterkant (PVV, VVD, SGP, "
|
||||
"BBB, ChristenUnie) staan moties over nationale begrotingscontrole, "
|
||||
"integratievereisten en nationale arbeidsvoorwaarden. "
|
||||
"Deze as verklaart méér stemverschil dan economische tegenstellingen: "
|
||||
"het onderscheidt pro-EU progressieven van nationalistisch-conservatieven, "
|
||||
"ongeacht hun standpunt over de verzorgingsstaat."
|
||||
"Deze as scheidt partijen die defensie-uitgaven en NAVO-commitment prioriteren "
|
||||
"van partijen die vasthouden aan EU-instituties en internationale samenwerking. "
|
||||
"Aan de positieve kant staan moties over Eurofighter Typhoons, defensie-uitgaven "
|
||||
"naar 3% bbp, MQ-9 Reaper drones, F-35 reservedelen en marine-steun aan Rode Zee. "
|
||||
"PVV, VVD, NSC en BBB scoren sterk positief. Aan de negatieve kant staan moties "
|
||||
"over EU-verdragswijzigingen, sancties tegen Israël, ICC-lidmaatschap en "
|
||||
"internationale klimaatsamenwerking. Volt, GroenLinks-PvdA, D66 en SP scoren "
|
||||
"negatief. Dit is geen klassiek links-rechts onderscheid maar een spanning "
|
||||
"tussen defensie-prioritering en multilaterale instituties."
|
||||
),
|
||||
"positive_pole": "Progressief-pro-EU: Volt, GroenLinks-PvdA, DENK, PvdD, SP",
|
||||
"negative_pole": "Nationalistisch-conservatief: PVV, VVD, SGP, BBB, ChristenUnie",
|
||||
"flip": True,
|
||||
"positive_pole": "Defensie-prioritering: PVV, VVD, NSC, BBB, JA21 — NAVO-commitment",
|
||||
"negative_pole": "Multilateraal-institutioneel: Volt, GroenLinks-PvdA, D66, SP, DENK",
|
||||
"flip": False,
|
||||
},
|
||||
2: {
|
||||
"label": "Populistisch nationalisme versus institutioneel progressivisme",
|
||||
"label": "Populistisch nationalisme versus institutioneel bestuur",
|
||||
"explanation": (
|
||||
"Deze as scheidt het populistisch-nationalistische bloc (PVV, FVD, Groep Markuszower, "
|
||||
"BBB) van het volledige overige parlement. Alleen PVV (+18), FVD (+4) en Groep "
|
||||
"Markuszower (+2) scoren positief; alle andere partijen scoren negatief, inclusief "
|
||||
"VVD (−15), CDA (−14), SGP (−25) en ChristenUnie (−59). Positieve moties: artsen "
|
||||
"vrijpleiten voor hydroxychloroquine/ivermectine, Syriërs terugsturen, geen geld "
|
||||
"aan Jordanië, tijdelijke bescherming Oekraïne beëindigen. Negatieve moties: "
|
||||
"digitale toegankelijkheid Caribisch Nederland, ethiekprogramma Defensie, zorg voor "
|
||||
"slachtoffers bombardement Hawija, zorgkwaliteitsstandaarden. Dit is geen links-rechts "
|
||||
"verdeling maar een nativistisch-populistisch vs. institutioneel onderscheid."
|
||||
"Deze as scheidt het populistisch-nationalistische bloc (PVV, FVD, BBB) van het "
|
||||
"overige parliament. Alleen PVV (+18), FVD (+4) en BBB scoren positief; alle andere "
|
||||
"partijen scoren negatief, inclusief VVD (−15), CDA (−14), SGP (−25) en ChristenUnie (−59). "
|
||||
"Positieve moties: artsen vrijpleiten voor hydroxychloroquine/ivermectine, Syriërs "
|
||||
"terugsturen, geen geld aan Jordanië, tijdelijke bescherming Oekraïne beëindigen. "
|
||||
"Negatieve moties: digitale toegankelijkheid Caribisch Nederland, ethiekprogramma "
|
||||
"Defensie, zorg voor slachtoffers bombardement Hawija, zorgkwaliteitsstandaarden. "
|
||||
"Dit is geen links-rechts verdeling maar een nativistisch-populistisch vs. "
|
||||
"institutioneel onderscheid."
|
||||
),
|
||||
"positive_pole": "Populistisch-nationalistisch: PVV, FVD, Groep Markuszower, BBB",
|
||||
"negative_pole": "Institutioneel: alle overige partijen — van VVD en SGP tot GroenLinks-PvdA en Volt",
|
||||
"positive_pole": "Populistisch-nationalistisch: PVV, FVD, BBB — soevereiniteit en anti-establishment",
|
||||
"negative_pole": "Institutioneel: VVD, CDA, SGP, ChristenUnie, GroenLinks-PvdA, D66, Volt",
|
||||
"flip": False,
|
||||
},
|
||||
3: {
|
||||
@@ -482,58 +481,57 @@ SVD_THEMES: dict[int, dict[str, str]] = {
|
||||
),
|
||||
"positive_pole": "Pro-verzorgingsstaat: SP, PvdD, GroenLinks-PvdA (anti-bezuinigingen)",
|
||||
"negative_pole": "Marktliberaal en fiscaal conservatief: VVD, D66, CDA, SGP, BBB",
|
||||
"flip": False,
|
||||
"flip": True,
|
||||
},
|
||||
4: {
|
||||
"label": "Pragmatisch centrisme versus ideologische radicaliteit",
|
||||
"label": "Gematigde middenpartijen versus ideologische posities",
|
||||
"explanation": (
|
||||
"De gevestigde centrumpartijen (D66, CDA, VVD, 50PLUS) staan tegenover zowel "
|
||||
"rechts-radicale als identiteitspolitieke posities. Aan de positieve kant staan "
|
||||
"Deze as scheidt gematigde middenpartijen van ideologisch gepositioneerde partijen "
|
||||
"aan beide kanten. Aan de negatieve kant (de 'linkerkant' van de as) staan "
|
||||
"moties over migratiesaldo-caps, WHO-verlating, religieuze schoolidentiteit en "
|
||||
"identiteitspolitiek — FVD, ChristenUnie en DENK. Aan de positieve kant staan "
|
||||
"moties over openbare toiletten, vaderbetrokkenheid bij opvoeding, internationale "
|
||||
"samenwerking met Australië en Canada, en long covid-expertise. Dit zijn pragmatische, "
|
||||
"institutionele beleidsposities. Aan de negatieve kant staan moties over een "
|
||||
"migratiesaldo-cap van 60.000, het verlaten van de WHO, kinderen in pleeggezinnen "
|
||||
"van hetzelfde geslacht (FVD) en de bescherming van religieuze schoolidentiteit "
|
||||
"via artikel 23. De negatieve pool combineert populistisch-rechts met "
|
||||
"identiteitsgerichte posities van zowel rechts als links."
|
||||
"samenwerking met Australië en Canada, en long covid-expertise. D66, CDA, VVD "
|
||||
"en 50PLUS staan voor pragmatische, institutionele beleidsposities. "
|
||||
"De as combineert centristische partijen tegenover zowel rechts-populistische "
|
||||
"als identiteitsgerichte posities."
|
||||
),
|
||||
"positive_pole": "Constructief centrum: D66, CDA, VVD, 50PLUS — pragmatisch en internationaal",
|
||||
"negative_pole": "Radicaal-ideologisch: FVD, Groep Markuszower (rechts), ChristenUnie, DENK (religieus/identiteit)",
|
||||
"flip": False,
|
||||
"positive_pole": "Gematigd centrum: D66, CDA, VVD, 50PLUS — pragmatisch en institutioneel",
|
||||
"negative_pole": "Ideologisch/identiteit: FVD, ChristenUnie, DENK — populistisch of religieus-cultureel",
|
||||
"flip": True,
|
||||
},
|
||||
5: {
|
||||
"label": "Christelijk-sociaal communitarisme",
|
||||
"label": "Gemeenschapszin en sociale zekerheid versus individualistische progressie",
|
||||
"explanation": (
|
||||
"Deze as scheidt partijen die gemeenschapszorg, burgerplicht en informele "
|
||||
"ondersteuningsstructuren benadrukken van partijen die individuele vrijheden en "
|
||||
"progressieve maatschappelijke hervorming voorstaan. Aan de positieve kant staan "
|
||||
"moties over schuldhulpverlening via vrijwilligersorganisaties, de maatschappelijke "
|
||||
"diensttijd voor jongeren met een afstand tot de arbeidsmarkt, en de gastouderopvang. "
|
||||
"ChristenUnie, SGP en CDA voeren hier de toon; ook D66 scoort positief door steun "
|
||||
"aan sociaal beleid. Aan de negatieve kant staan moties over wettelijke erkenning "
|
||||
"van meerouderschap, abortusrecht in het EU-Handvest, armoedebeleid en "
|
||||
"buitenlandse beïnvloeding. PvdD, GroenLinks-PvdA en VVD scoren hier negatief."
|
||||
"moties over schuldhulpverlening via vrijwilligersorganisaties, maatschappelijke "
|
||||
"diensttijd voor jongeren, gastouderopvang en financiële prikkels voor scholieren. "
|
||||
"ChristenUnie, SGP en CDA voeren hier de toon; ook D66 scoort positief. "
|
||||
"Aan de negatieve kant staan moties over wettelijke erkenning van meerouderschap, "
|
||||
"abortusrecht in het EU-Handvest, armoedebeleid en buitenlandse beïnvloeding. "
|
||||
"PvdD, GroenLinks-PvdA en VVD scoren negatief."
|
||||
),
|
||||
"positive_pole": "Gemeenschapsgericht: ChristenUnie, SGP, CDA, D66 — vrijwilligers, diensttijd, zorgsystemen",
|
||||
"negative_pole": "Individualistisch-progressief: PvdD, GroenLinks-PvdA, VVD, PVV",
|
||||
"negative_pole": "Individualistisch-progressief: PvdD, GroenLinks-PvdA, VVD, PVV, FVD",
|
||||
"flip": False,
|
||||
},
|
||||
6: {
|
||||
"label": "Klimaat, energie en culturele integratie",
|
||||
"label": "Energievoorkeur en migratie-integratie versus klimaat en inclusie",
|
||||
"explanation": (
|
||||
"Aan de positieve kant staan moties die LNG-capaciteit prefereren als alternatief "
|
||||
"voor strenge vulgraadverplichtingen, kernenergie als volwaardig CO₂-arm onderdeel "
|
||||
"van de energiemix willen erkennen op COP30, en discriminatie- en inclusiemeldpunten "
|
||||
"willen inventariseren. SGP, JA21, FVD en PVV scoren sterk positief. Aan de "
|
||||
"negatieve kant staan moties die fossiele-industrie-vertegenwoordigers willen weren "
|
||||
"van klimaatconferenties, structureel overleg met moslimgemeenschappen willen bij "
|
||||
"integratiebeleid, en aanvallen van Israël op Libanon veroordelen. "
|
||||
"PvdD, GroenLinks-PvdA, Volt en D66 scoren negatief. "
|
||||
"Deze as combineert energieideologie met culturele polarisatie rondom klimaat, "
|
||||
"integratie en buitenlandspolitiek."
|
||||
"Deze as combineert energie-ideologie met culturele polarisatie rond migratie "
|
||||
"en integratie. Aan de positieve kant staan moties die LNG-capaciteit prefereren, "
|
||||
"kernenergie als CO₂-arm willen erkennen op COP30, en discriminatie- en "
|
||||
"inclusiemeldpunten willen inventariseren. SGP, JA21, FVD en PVV scoren sterk "
|
||||
"positief. Aan de negatieve kant staan moties die fossiele-industrie-vertegenwoordigers "
|
||||
"willen weren van klimaatconferenties, structureel overleg met moslimgemeenschappen "
|
||||
"willen bij integratiebeleid, en aanvallen van Israël op Libanon veroordelen. "
|
||||
"PvdD, GroenLinks-PvdA, Volt en D66 scoren negatief. De as scheidt "
|
||||
"pro-fossiel/nationaal van klimaat-georiënteerd en inclusief."
|
||||
),
|
||||
"positive_pole": "Pro-fossiel, nationaal energiebeleid: SGP, JA21, FVD, PVV",
|
||||
"negative_pole": "Klimaatgericht en inclusief: PvdD, GroenLinks-PvdA, Volt, D66",
|
||||
"positive_pole": "Pro-fossiel, nationaal: SGP, JA21, FVD, PVV — energie en migratiebeperking",
|
||||
"negative_pole": "Klimaat en inclusie: PvdD, GroenLinks-PvdA, Volt, D66, SP",
|
||||
"flip": False,
|
||||
},
|
||||
7: {
|
||||
@@ -555,57 +553,56 @@ SVD_THEMES: dict[int, dict[str, str]] = {
|
||||
"flip": True,
|
||||
},
|
||||
8: {
|
||||
"label": "Europese defensie-integratie (indicatief)",
|
||||
"label": "Vaccinatiebeleid, onderwijs en regionale huisvesting (indicatief)",
|
||||
"explanation": (
|
||||
"Aan de positieve kant staan moties die pleiten voor militaire mobiliteit als "
|
||||
"topprioriteit in EU/NAVO-verband en toewerken naar een militair Schengengebied, "
|
||||
"35% van defensiematerieel Europees inkopen en een Europees defensie-R&D-instituut "
|
||||
"oprichten. Ook het Nationaal Groeifonds en gewasbeschermingsonderzoek vallen "
|
||||
"positief. Volt en D66 scoren sterk positief. Aan de negatieve kant staan moties "
|
||||
"over ketenverantwoordelijkheid bij toeslagen (DENK), het coronaoversterfte-onderzoek "
|
||||
"(PVV/BBB), energiecontracten en huisvestingsregulering. SP (−39), DENK (−35) en "
|
||||
"PvdD (−26) scoren sterk negatief — dit betekent dat zij actief tégen deze "
|
||||
"EU-defensiemoties stemmen, niet simpelweg het thema negeren. Volt (N=1) domineert "
|
||||
"de positieve pool maar is als centroïde van één Kamerlid statistisch onbetrouwbaar."
|
||||
"Een residuele as die overwegend thematisch diverse moties uit 2024-2025 vangt. "
|
||||
"Aan de positieve kant staan moties over vaccinatiegraad-verlaging voor kinderen, "
|
||||
"een VWO-profiel kunst en cultuur, stages voor mbo-studenten in het buitenland, "
|
||||
"en woningbouw voor jongeren in kleine kernen. BBB, SGP en JA21 scoren positief. "
|
||||
"Aan de negatieve kant staan moties over het instellen van een vaccinatiecommissie, "
|
||||
"heropening van het coronaoversterfte-onderzoek, regionale energiestrategieën "
|
||||
"en toegankelijkheid van het basispakket. SP, DENK en PvdD scoren sterk negatief. "
|
||||
"Deze as combineert onderwijs- en volksgezondheidsposities met regionale "
|
||||
"huisvestingsprioriteiten — het label is indicatief."
|
||||
),
|
||||
"positive_pole": "Pro-EU defensie en innovatie: Volt, D66",
|
||||
"negative_pole": "Nationaal/pacifistisch of binnenlandsgericht: SP, DENK, PvdD, 50PLUS",
|
||||
"positive_pole": "Onderwijs en volksgezondheid: BBB, SGP, JA21 — vaccinatie, profielkeuze, woningbouw",
|
||||
"negative_pole": "Zorg en toegankelijkheid: SP, DENK, PvdD, Volt — coronaonderzoek, energie, basispakket",
|
||||
"flip": False,
|
||||
},
|
||||
9: {
|
||||
"label": "Decentraal bestuur en gemeenschapswaarden (indicatief)",
|
||||
"label": "Pragmatische probleemoplossing versus systeemhervorming (indicatief)",
|
||||
"explanation": (
|
||||
"Aan de positieve kant staan moties over naleving van de Financiële-verhoudingswet "
|
||||
"voor gemeenten, beperking van arbeidsmigratie binnen de EU, een nieuwe "
|
||||
"tandartsopleiding in Rotterdam, een actieplan tegen misbruik van hallucinerende "
|
||||
"geneesmiddelen en een oplossing voor milieuproblemen op Bonaire. SGP en "
|
||||
"ChristenUnie scoren sterk positief; ook DENK en SP. Aan de negatieve kant staan "
|
||||
"moties over een moratorium op geitenstallen, een verbod op gokadvertenties, "
|
||||
"verduidelijking van gronden voor voorlopige hechtenis, een leegstandbelasting voor "
|
||||
"woningen en end-to-end-encryptie. D66, JA21 en PVV scoren negatief. Deze as "
|
||||
"scheidt een nadruk op decentrale dienstverlening en gemeenschapsregulering van "
|
||||
"progressieve systeem- en rechtshervorming."
|
||||
"Deze as scheidt pragmatische, concrete probleemoplossing van idealistische "
|
||||
"systeemhervorming. Aan de positieve kant staan moties over naleving van de "
|
||||
"Financiële-verhoudingswet voor gemeenten, beperking van arbeidsmigratie, "
|
||||
"een nieuwe tandartsopleiding in Rotterdam, een actieplan tegen misbruik van "
|
||||
"hallucinerende geneesmiddelen en oplossingen voor milieuproblemen op Bonaire. "
|
||||
"SGP en ChristenUnie scoren sterk positief; ook DENK en SP. Aan de negatieve kant "
|
||||
"staan moties over een moratorium op geitenstallen, een verbod op gokadvertenties, "
|
||||
"verduidelijking van gronden voor voorlopige hechtenis, een leegstandbelasting "
|
||||
"en end-to-end-encryptie. D66, JA21 en PVV scoren negatief. "
|
||||
"Deze as is indicatief — de scores zijn smal en ideologisch divers."
|
||||
),
|
||||
"positive_pole": "Lokaal en gemeenschapsgericht: SGP, ChristenUnie, DENK, SP",
|
||||
"negative_pole": "Progressieve systemen en rechten: D66, JA21, PVV",
|
||||
"positive_pole": "Pragmatisch-bestuurlijk: SGP, ChristenUnie, DENK, SP — concrete oplossingen",
|
||||
"negative_pole": "Systeemhervorming: D66, JA21, PVV — idealistische beleidsposities",
|
||||
"flip": True,
|
||||
},
|
||||
10: {
|
||||
"label": "Institutioneel toezicht en handhaving (indicatief)",
|
||||
"label": "Kritisch op overheidsbemoeienis versus pro-regulering (indicatief)",
|
||||
"explanation": (
|
||||
"De tiende as vangt resterende variantie op en scheidt partijen die sceptisch zijn "
|
||||
"over staatstoezicht van partijen die strikte regulering en handhaving steunen. "
|
||||
"Aan de positieve kant staan moties over minder tijdsintensieve schoolinspecties, "
|
||||
"het recht van toeslagenouders op hun persoonlijk dossier, behoud van de "
|
||||
"tegemoetkoming voor arbeidsongeschikten en een verlaging van de leeftijdsdrempel "
|
||||
"voor kindgesprekken. DENK, SP en PvdD scoren positief. Aan de negatieve kant "
|
||||
"staan moties over een aangifteplicht voor scholen bij veiligheidsincidenten, een "
|
||||
"rookverbod in auto's met kinderen, braakliggende landagrarisch landbouwgrond en verhoogd "
|
||||
"beloningsgeld voor tipgevers. GroenLinks-PvdA scoort opvallend sterk negatief, "
|
||||
"waarmee het zich onderscheidt van SP en DENK op handhavingsthema's."
|
||||
"Deze as scheidt partijen die kritisch staan tegenover overheidsbemoeienis van "
|
||||
"partijen die strikte regulering en handhaving steunen. Aan de positieve kant "
|
||||
"staan moties over minder tijdsintensieve schoolinspecties, het recht van "
|
||||
"toeslagenouders op hun persoonlijk dossier, behoud van tegemoetkomingen voor "
|
||||
"arbeidsongeschikten en verlaging van de leeftijdsdrempel voor kindgesprekken. "
|
||||
"DENK, SP en PvdD scoren positief. Aan de negatieve kant staan moties over "
|
||||
"een aangifteplicht voor scholen bij veiligheidsincidenten, een rookverbod in "
|
||||
"auto's met kinderen, braakliggende landbouwgrond en verhoogd beloningsgeld "
|
||||
"voor tipgevers. GroenLinks-PvdA scoort opvallend sterk negatief. "
|
||||
"Deze as is indicatief — de scores zijn smal en de partijcombinaties divers."
|
||||
),
|
||||
"positive_pole": "Kritisch op overheidstoezicht: DENK, SP, PvdD, Volt — minder inspectielast",
|
||||
"negative_pole": "Pro-handhaving en regulering: GroenLinks-PvdA, CDA, SGP — veiligheid en naleving",
|
||||
"positive_pole": "Kritisch op overheidsbemoeienis: DENK, SP, PvdD — minder inspectielast en lastenverlichting",
|
||||
"negative_pole": "Pro-regulering: GroenLinks-PvdA, CDA, SGP — veiligheid, naleving en handhaving",
|
||||
"flip": True,
|
||||
},
|
||||
}
|
||||
@@ -1142,6 +1139,123 @@ def load_party_axis_scores_for_window(
|
||||
return {}
|
||||
|
||||
|
||||
@st.cache_data(show_spinner="SVD scores voor alle vensters laden…")
|
||||
def load_party_scores_all_windows(
|
||||
db_path: str, windows: List[str]
|
||||
) -> Dict[str, Dict[str, List[float]]]:
|
||||
"""Load party SVD scores for all specified windows.
|
||||
|
||||
Args:
|
||||
db_path: Path to DuckDB database
|
||||
windows: List of window IDs to load
|
||||
|
||||
Returns:
|
||||
{window_id: {party_name: [float * k]}} — scores per party per window
|
||||
"""
|
||||
result: Dict[str, Dict[str, List[float]]] = {}
|
||||
for window in windows:
|
||||
if window == "current_parliament":
|
||||
result[window] = load_party_axis_scores(db_path)
|
||||
else:
|
||||
result[window] = load_party_axis_scores_for_window(db_path, window)
|
||||
return result
|
||||
|
||||
|
||||
def _load_mp_vectors_by_window(db_path: str, window: str) -> Dict[str, np.ndarray]:
|
||||
"""Load individual MP SVD vectors for a specific window.
|
||||
|
||||
Args:
|
||||
db_path: Path to DuckDB database
|
||||
window: Window ID (e.g., "2015", "current_parliament")
|
||||
|
||||
Returns:
|
||||
{mp_name: np.ndarray(50,)} — one vector per MP
|
||||
"""
|
||||
con = duckdb.connect(database=db_path, read_only=True)
|
||||
try:
|
||||
rows = con.execute(
|
||||
"SELECT entity_id, vector FROM svd_vectors "
|
||||
"WHERE entity_type='mp' AND window_id=?",
|
||||
[window],
|
||||
).fetchall()
|
||||
|
||||
mp_vecs: Dict[str, np.ndarray] = {}
|
||||
for entity_id, raw_vec in rows:
|
||||
if isinstance(raw_vec, str):
|
||||
vec = json.loads(raw_vec)
|
||||
elif isinstance(raw_vec, (bytes, bytearray)):
|
||||
vec = json.loads(raw_vec.decode())
|
||||
elif isinstance(raw_vec, list):
|
||||
vec = raw_vec
|
||||
else:
|
||||
try:
|
||||
vec = list(raw_vec)
|
||||
except Exception:
|
||||
continue
|
||||
fvec = np.array([float(v) if v is not None else 0.0 for v in vec])
|
||||
mp_vecs[entity_id] = fvec
|
||||
|
||||
return mp_vecs
|
||||
finally:
|
||||
con.close()
|
||||
|
||||
|
||||
@st.cache_data(show_spinner="SVD scores met Procrustes-uitlijning laden…")
|
||||
def load_party_scores_all_windows_aligned(
|
||||
db_path: str, windows: List[str]
|
||||
) -> Dict[str, Dict[str, List[float]]]:
|
||||
"""Load party SVD scores for all windows with Procrustes alignment.
|
||||
|
||||
This ensures consistent orientation across years by aligning SVD vectors
|
||||
using Procrustes rotation, similar to how components 1-2 are aligned.
|
||||
|
||||
Args:
|
||||
db_path: Path to DuckDB database
|
||||
windows: List of window IDs to load
|
||||
|
||||
Returns:
|
||||
{window_id: {party_name: [float * k]}} — aligned scores per party per window
|
||||
"""
|
||||
from analysis.trajectory import _procrustes_align_windows
|
||||
|
||||
# Load raw MP vectors for each window
|
||||
raw_window_vecs: Dict[str, Dict[str, np.ndarray]] = {}
|
||||
party_map = load_party_map(db_path)
|
||||
|
||||
for window in windows:
|
||||
mp_vecs = _load_mp_vectors_by_window(db_path, window)
|
||||
if mp_vecs:
|
||||
raw_window_vecs[window] = mp_vecs
|
||||
|
||||
# Apply Procrustes alignment
|
||||
aligned_window_vecs = _procrustes_align_windows(raw_window_vecs)
|
||||
|
||||
# Convert MP vectors to party averages
|
||||
result: Dict[str, Dict[str, List[float]]] = {}
|
||||
for window in windows:
|
||||
if window not in aligned_window_vecs:
|
||||
continue
|
||||
|
||||
mp_vecs = aligned_window_vecs[window]
|
||||
party_vecs: Dict[str, List[np.ndarray]] = {}
|
||||
|
||||
for mp_name, vec in mp_vecs.items():
|
||||
party = party_map.get(mp_name)
|
||||
if party:
|
||||
if party not in party_vecs:
|
||||
party_vecs[party] = []
|
||||
party_vecs[party].append(vec)
|
||||
|
||||
# Average per party
|
||||
result[window] = {}
|
||||
for party, vecs in party_vecs.items():
|
||||
if vecs:
|
||||
avg_vec = np.mean(vecs, axis=0)
|
||||
result[window][party] = avg_vec.tolist()
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@st.cache_data(show_spinner="Partij-MP vectoren laden…")
|
||||
def load_party_mp_vectors(db_path: str) -> Dict[str, List[np.ndarray]]:
|
||||
"""Return per-party lists of individual MP SVD vectors.
|
||||
@@ -1562,6 +1676,190 @@ def _render_party_axis_chart_1d(
|
||||
st.plotly_chart(fig, use_container_width=True)
|
||||
|
||||
|
||||
def _render_svd_time_trajectory(
|
||||
party_scores_by_window: Dict[str, Dict[str, List[float]]],
|
||||
comp_sel: int,
|
||||
theme: dict,
|
||||
selected_parties: List[str],
|
||||
) -> None:
|
||||
"""Render a time trajectory plot showing party positions over time on an SVD component.
|
||||
|
||||
Args:
|
||||
party_scores_by_window: {window_id: {party_name: [scores]}}
|
||||
comp_sel: SVD component number (1-indexed)
|
||||
theme: Theme dict with label, positive_pole, negative_pole, flip
|
||||
selected_parties: List of party names to display
|
||||
"""
|
||||
if not party_scores_by_window or not selected_parties:
|
||||
st.caption("_Geen data beschikbaar voor tijdtraject._")
|
||||
return
|
||||
|
||||
idx = comp_sel - 1 # Convert to 0-indexed
|
||||
|
||||
# Import flip computation for per-window alignment
|
||||
from analysis.svd_labels import compute_flip_direction
|
||||
|
||||
# Build data structure: {party: [(window, score), ...]}
|
||||
party_trajectories: Dict[str, List[Tuple[str, float]]] = {}
|
||||
|
||||
# Sort windows: current_parliament first, then chronological
|
||||
all_windows = list(party_scores_by_window.keys())
|
||||
sorted_windows = []
|
||||
if "current_parliament" in all_windows:
|
||||
sorted_windows.append("current_parliament")
|
||||
# Add other windows in reverse chronological order (newest first)
|
||||
other_windows = sorted(
|
||||
[w for w in all_windows if w != "current_parliament"], reverse=True
|
||||
)
|
||||
sorted_windows.extend(other_windows)
|
||||
|
||||
# Compute per-window flip to align all windows consistently
|
||||
# Each window's SVD has arbitrary sign, so we compute flip per window
|
||||
window_flips = {}
|
||||
for window in sorted_windows:
|
||||
scores_by_party = party_scores_by_window.get(window, {})
|
||||
# Compute flip for this specific window
|
||||
window_flips[window] = compute_flip_direction(comp_sel, scores_by_party)
|
||||
|
||||
for window in sorted_windows:
|
||||
scores_by_party = party_scores_by_window.get(window, {})
|
||||
# Get the flip for this specific window
|
||||
window_flip = window_flips.get(window, False)
|
||||
for party in selected_parties:
|
||||
scores = scores_by_party.get(party, [])
|
||||
if scores and len(scores) > idx:
|
||||
try:
|
||||
score = float(scores[idx])
|
||||
# Apply per-window flip to align orientation
|
||||
if window_flip:
|
||||
score = -score
|
||||
party_trajectories.setdefault(party, []).append((window, score))
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
|
||||
if not party_trajectories:
|
||||
st.caption("_Geen data beschikbaar voor geselecteerde partijen._")
|
||||
return
|
||||
|
||||
# Create figure
|
||||
fig = go.Figure()
|
||||
|
||||
# Find score range for x-axis
|
||||
all_scores = []
|
||||
for traj in party_trajectories.values():
|
||||
all_scores.extend([s for _, s in traj])
|
||||
|
||||
if not all_scores:
|
||||
st.caption("_Geen scores beschikbaar._")
|
||||
return
|
||||
|
||||
x_min, x_max = min(all_scores) * 1.15, max(all_scores) * 1.15
|
||||
if x_min == x_max:
|
||||
x_min, x_max = x_min - 1, x_max + 1
|
||||
|
||||
# Y positions: current at top (y=0), earlier below
|
||||
window_to_y = {w: i for i, w in enumerate(sorted_windows)}
|
||||
|
||||
# Add horizontal grey axis lines at y=0 for each year (like single-year chart)
|
||||
for window in sorted_windows:
|
||||
y_pos = window_to_y[window]
|
||||
# Horizontal grey line at y=0 for this year (matching single-year chart style)
|
||||
fig.add_trace(
|
||||
go.Scatter(
|
||||
x=[x_min, x_max],
|
||||
y=[y_pos, y_pos],
|
||||
mode="lines",
|
||||
line={"color": "#cccccc", "width": 1},
|
||||
hoverinfo="skip",
|
||||
showlegend=False,
|
||||
)
|
||||
)
|
||||
|
||||
# Add traces for each party
|
||||
for party in selected_parties:
|
||||
if party not in party_trajectories:
|
||||
continue
|
||||
|
||||
traj = party_trajectories[party]
|
||||
if len(traj) < 1:
|
||||
continue
|
||||
|
||||
x_vals = [score for _, score in traj]
|
||||
y_vals = [window_to_y[window] for window, _ in traj]
|
||||
color = PARTY_COLOURS.get(party, "#9E9E9E")
|
||||
|
||||
# Add connecting line
|
||||
fig.add_trace(
|
||||
go.Scatter(
|
||||
x=x_vals,
|
||||
y=y_vals,
|
||||
mode="lines",
|
||||
line={"color": color, "width": 2},
|
||||
hoverinfo="skip",
|
||||
showlegend=False,
|
||||
)
|
||||
)
|
||||
|
||||
# Add markers with hover
|
||||
hover_texts = [f"{party}<br>{window}: {score:.3f}" for window, score in traj]
|
||||
fig.add_trace(
|
||||
go.Scatter(
|
||||
x=x_vals,
|
||||
y=y_vals,
|
||||
mode="markers+text",
|
||||
text=[party] * len(traj),
|
||||
textposition="top center",
|
||||
marker={"size": 12, "color": color},
|
||||
hovertext=hover_texts,
|
||||
hoverinfo="text",
|
||||
showlegend=False,
|
||||
)
|
||||
)
|
||||
|
||||
# Determine pole labels based on theme (use reference flip from current_parliament)
|
||||
pos_pole = theme.get("positive_pole", "")
|
||||
neg_pole = theme.get("negative_pole", "")
|
||||
# Use the theme's flip value (computed from current_parliament) for label orientation
|
||||
reference_flip = theme.get("flip", False)
|
||||
left_label = pos_pole if reference_flip else neg_pole
|
||||
right_label = neg_pole if reference_flip else pos_pole
|
||||
|
||||
# Y-axis labels
|
||||
y_labels = {}
|
||||
for window in sorted_windows:
|
||||
if window == "current_parliament":
|
||||
y_labels[window_to_y[window]] = "Huidig"
|
||||
else:
|
||||
y_labels[window_to_y[window]] = window
|
||||
|
||||
# Update layout
|
||||
fig.update_layout(
|
||||
height=max(400, len(sorted_windows) * 60 + 100),
|
||||
margin={"l": 80, "r": 10, "t": 10, "b": 30},
|
||||
xaxis={
|
||||
"title": f"← {left_label} | {right_label} →",
|
||||
"range": [x_min, x_max],
|
||||
"showticklabels": False,
|
||||
"showline": False,
|
||||
"showgrid": True,
|
||||
"gridcolor": "rgba(0,0,0,0.1)",
|
||||
"zeroline": True,
|
||||
"zerolinecolor": "rgba(0,0,0,0.2)",
|
||||
},
|
||||
yaxis={
|
||||
"tickvals": list(y_labels.keys()),
|
||||
"ticktext": list(y_labels.values()),
|
||||
"tickmode": "array",
|
||||
"autorange": "reversed", # Top to bottom
|
||||
"showgrid": False,
|
||||
},
|
||||
plot_bgcolor="rgba(0,0,0,0)",
|
||||
paper_bgcolor="rgba(0,0,0,0)",
|
||||
)
|
||||
|
||||
st.plotly_chart(fig, use_container_width=True)
|
||||
|
||||
|
||||
@st.cache_data(show_spinner="Moties laden…")
|
||||
def load_motions_df(db_path: str) -> pd.DataFrame:
|
||||
"""Load the full motions table as a pandas DataFrame (read-only)."""
|
||||
@@ -2841,8 +3139,20 @@ def build_svd_components_tab(db_path: str) -> None:
|
||||
|
||||
comp_display = [_comp_label(c) for c in comp_options]
|
||||
|
||||
# Load default party scores early (needed for sidebar controls)
|
||||
party_scores_default = load_party_axis_scores(db_path)
|
||||
party_mp_vectors = load_party_mp_vectors(db_path)
|
||||
bootstrap_data = (
|
||||
_cached_bootstrap_cis(party_mp_vectors) if party_mp_vectors else None
|
||||
)
|
||||
|
||||
# Sidebar controls for window selection and minimum MPs filter
|
||||
col1, col2 = st.columns([2, 1])
|
||||
|
||||
# Initialize view mode (will be set in col2 if render succeeds)
|
||||
view_mode = "Enkel venster"
|
||||
selected_parties_for_trajectory: list = []
|
||||
|
||||
with col2:
|
||||
comp_sel_idx = st.selectbox(
|
||||
"Selecteer SVD-as",
|
||||
@@ -2862,6 +3172,29 @@ def build_svd_components_tab(db_path: str) -> None:
|
||||
help="Partijen met minder dan dit aantal Kamerleden worden niet weergegeven.",
|
||||
)
|
||||
|
||||
# View selector for party axis display
|
||||
view_mode = st.radio(
|
||||
"Weergave",
|
||||
options=["Enkel venster", "Tijdtraject"],
|
||||
index=0,
|
||||
help="Enkel venster: toont posities voor één tijdsvenster. Tijdtraject: toont hoe partijen over tijd bewegen op deze as.",
|
||||
)
|
||||
|
||||
# Party multi-select for time trajectory view
|
||||
selected_parties_for_trajectory = []
|
||||
if view_mode == "Tijdtraject":
|
||||
# Get list of parties with scores
|
||||
all_parties = (
|
||||
sorted(party_scores_default.keys()) if party_scores_default else []
|
||||
)
|
||||
default_parties = [p for p in KNOWN_MAJOR_PARTIES if p in all_parties][:8]
|
||||
selected_parties_for_trajectory = st.multiselect(
|
||||
"Partijen om te tonen",
|
||||
options=all_parties,
|
||||
default=default_parties,
|
||||
help="Selecteer de partijen die je wilt zien in het tijdtraject.",
|
||||
)
|
||||
|
||||
# Show theme explanation
|
||||
theme = SVD_THEMES.get(comp_sel, {})
|
||||
if theme:
|
||||
@@ -2870,13 +3203,7 @@ def build_svd_components_tab(db_path: str) -> None:
|
||||
motions = comp_map.get(comp_sel, [])
|
||||
|
||||
# Party axis chart
|
||||
# Default party scores (single-window mean vectors) as a fallback
|
||||
party_scores_default = load_party_axis_scores(db_path)
|
||||
party_mp_vectors = load_party_mp_vectors(db_path)
|
||||
bootstrap_data = (
|
||||
_cached_bootstrap_cis(party_mp_vectors) if party_mp_vectors else None
|
||||
)
|
||||
|
||||
# Default party scores already loaded earlier for sidebar controls
|
||||
# For components 1 and 2, prefer MP-centroid values from the Procrustes-aligned
|
||||
# positions_by_window so the compass matches the trajectories (MP-mean centroids).
|
||||
if comp_sel in (1, 2):
|
||||
@@ -2978,11 +3305,13 @@ def build_svd_components_tab(db_path: str) -> None:
|
||||
)
|
||||
|
||||
# Auto-compute flip directions for all components based on party centroids
|
||||
# This ensures right-wing parties consistently appear on the right side
|
||||
# Use the selected window's party_scores for flip direction
|
||||
# This ensures right-wing parties consistently appear on the right side for THIS window
|
||||
try:
|
||||
from analysis.svd_labels import compute_flip_direction
|
||||
|
||||
for comp in range(1, 11):
|
||||
# Compute flip for the selected window (not current_parliament)
|
||||
flip = compute_flip_direction(comp, party_scores)
|
||||
if comp in SVD_THEMES:
|
||||
SVD_THEMES[comp]["flip"] = flip
|
||||
@@ -3006,8 +3335,54 @@ def build_svd_components_tab(db_path: str) -> None:
|
||||
p: coords for p, coords in party_coords.items() if p in valid_parties
|
||||
}
|
||||
|
||||
# Render party axis chart
|
||||
if comp_sel in (1, 2):
|
||||
# Render party axis chart (single window or time trajectory)
|
||||
if view_mode == "Tijdtraject" and selected_parties_for_trajectory:
|
||||
# Load party scores for all windows and render time trajectory
|
||||
# Filter to annual windows only (exclude quarters)
|
||||
available_windows = get_uniform_dim_windows(db_path)
|
||||
year_windows = sorted(
|
||||
w for w in available_windows if w != "current_parliament" and "-Q" not in w
|
||||
)
|
||||
has_current = "current_parliament" in available_windows
|
||||
all_windows = year_windows + (["current_parliament"] if has_current else [])
|
||||
|
||||
# For components 1-2, use Procrustes-aligned positions (same as single window)
|
||||
# For components 3-10, use SVD vectors
|
||||
if comp_sel in (1, 2):
|
||||
positions_by_window, _ = load_positions(db_path)
|
||||
party_map = load_party_map(db_path)
|
||||
|
||||
# Convert positions to party scores format
|
||||
party_scores_by_window: Dict[str, Dict[str, List[float]]] = {}
|
||||
for window in all_windows:
|
||||
pos = positions_by_window.get(window, {})
|
||||
# Collect all MP positions per party
|
||||
party_positions: Dict[str, List[Tuple[float, float]]] = {}
|
||||
for entity, (x, y) in pos.items():
|
||||
party = party_map.get(entity)
|
||||
if party:
|
||||
party_positions.setdefault(party, []).append((x, y))
|
||||
# Average positions per party
|
||||
party_scores: Dict[str, List[float]] = {}
|
||||
for party, positions in party_positions.items():
|
||||
if positions:
|
||||
avg_x = sum(p[0] for p in positions) / len(positions)
|
||||
avg_y = sum(p[1] for p in positions) / len(positions)
|
||||
party_scores[party] = [avg_x, avg_y]
|
||||
party_scores_by_window[window] = party_scores
|
||||
else:
|
||||
# Use SVD vectors for components 3-10 with Procrustes alignment
|
||||
party_scores_by_window = load_party_scores_all_windows_aligned(
|
||||
db_path, all_windows
|
||||
)
|
||||
|
||||
_render_svd_time_trajectory(
|
||||
party_scores_by_window,
|
||||
comp_sel,
|
||||
theme,
|
||||
selected_parties_for_trajectory,
|
||||
)
|
||||
elif comp_sel in (1, 2):
|
||||
# Components 1-2 use 2D coords from political compass
|
||||
_render_party_axis_chart(
|
||||
party_coords, comp_sel, theme, bootstrap_data=bootstrap_data
|
||||
|
||||
Reference in New Issue
Block a user