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.
|
# This is the canonical source of truth for SVD component labels.
|
||||||
SVD_THEMES: dict[int, dict[str, str]] = {
|
SVD_THEMES: dict[int, dict[str, str]] = {
|
||||||
1: {
|
1: {
|
||||||
"label": "EU-integratie en internationalisme versus nationalisme",
|
"label": "Defensie-uitgaven en NAVO-commitment versus multilaterale instituties",
|
||||||
"explanation": (
|
"explanation": (
|
||||||
"De dominante dimensie van het parlement: niet economisch links-rechts, maar "
|
"Deze as scheidt partijen die defensie-uitgaven en NAVO-commitment prioriteren "
|
||||||
"EU-integratie en internationalisme versus nationalisme en soevereiniteit. "
|
"van partijen die vasthouden aan EU-instituties en internationale samenwerking. "
|
||||||
"Aan de linkerkant (Volt, GroenLinks-PvdA, DENK, PvdD, SP) staan moties over "
|
"Aan de positieve kant staan moties over Eurofighter Typhoons, defensie-uitgaven "
|
||||||
"EU-verdragswijzigingen, EU-sancties tegen Israël, internationale samenwerking "
|
"naar 3% bbp, MQ-9 Reaper drones, F-35 reservedelen en marine-steun aan Rode Zee. "
|
||||||
"en het behouden van vluchtelingenstatus. Aan de rechterkant (PVV, VVD, SGP, "
|
"PVV, VVD, NSC en BBB scoren sterk positief. Aan de negatieve kant staan moties "
|
||||||
"BBB, ChristenUnie) staan moties over nationale begrotingscontrole, "
|
"over EU-verdragswijzigingen, sancties tegen Israël, ICC-lidmaatschap en "
|
||||||
"integratievereisten en nationale arbeidsvoorwaarden. "
|
"internationale klimaatsamenwerking. Volt, GroenLinks-PvdA, D66 en SP scoren "
|
||||||
"Deze as verklaart méér stemverschil dan economische tegenstellingen: "
|
"negatief. Dit is geen klassiek links-rechts onderscheid maar een spanning "
|
||||||
"het onderscheidt pro-EU progressieven van nationalistisch-conservatieven, "
|
"tussen defensie-prioritering en multilaterale instituties."
|
||||||
"ongeacht hun standpunt over de verzorgingsstaat."
|
|
||||||
),
|
),
|
||||||
"positive_pole": "Progressief-pro-EU: Volt, GroenLinks-PvdA, DENK, PvdD, SP",
|
"positive_pole": "Defensie-prioritering: PVV, VVD, NSC, BBB, JA21 — NAVO-commitment",
|
||||||
"negative_pole": "Nationalistisch-conservatief: PVV, VVD, SGP, BBB, ChristenUnie",
|
"negative_pole": "Multilateraal-institutioneel: Volt, GroenLinks-PvdA, D66, SP, DENK",
|
||||||
"flip": True,
|
"flip": False,
|
||||||
},
|
},
|
||||||
2: {
|
2: {
|
||||||
"label": "Populistisch nationalisme versus institutioneel progressivisme",
|
"label": "Populistisch nationalisme versus institutioneel bestuur",
|
||||||
"explanation": (
|
"explanation": (
|
||||||
"Deze as scheidt het populistisch-nationalistische bloc (PVV, FVD, Groep Markuszower, "
|
"Deze as scheidt het populistisch-nationalistische bloc (PVV, FVD, BBB) van het "
|
||||||
"BBB) van het volledige overige parlement. Alleen PVV (+18), FVD (+4) en Groep "
|
"overige parliament. Alleen PVV (+18), FVD (+4) en BBB scoren positief; alle andere "
|
||||||
"Markuszower (+2) scoren positief; alle andere partijen scoren negatief, inclusief "
|
"partijen scoren negatief, inclusief VVD (−15), CDA (−14), SGP (−25) en ChristenUnie (−59). "
|
||||||
"VVD (−15), CDA (−14), SGP (−25) en ChristenUnie (−59). Positieve moties: artsen "
|
"Positieve moties: artsen vrijpleiten voor hydroxychloroquine/ivermectine, Syriërs "
|
||||||
"vrijpleiten voor hydroxychloroquine/ivermectine, Syriërs terugsturen, geen geld "
|
"terugsturen, geen geld aan Jordanië, tijdelijke bescherming Oekraïne beëindigen. "
|
||||||
"aan Jordanië, tijdelijke bescherming Oekraïne beëindigen. Negatieve moties: "
|
"Negatieve moties: digitale toegankelijkheid Caribisch Nederland, ethiekprogramma "
|
||||||
"digitale toegankelijkheid Caribisch Nederland, ethiekprogramma Defensie, zorg voor "
|
"Defensie, zorg voor slachtoffers bombardement Hawija, zorgkwaliteitsstandaarden. "
|
||||||
"slachtoffers bombardement Hawija, zorgkwaliteitsstandaarden. Dit is geen links-rechts "
|
"Dit is geen links-rechts verdeling maar een nativistisch-populistisch vs. "
|
||||||
"verdeling maar een nativistisch-populistisch vs. institutioneel onderscheid."
|
"institutioneel onderscheid."
|
||||||
),
|
),
|
||||||
"positive_pole": "Populistisch-nationalistisch: PVV, FVD, Groep Markuszower, BBB",
|
"positive_pole": "Populistisch-nationalistisch: PVV, FVD, BBB — soevereiniteit en anti-establishment",
|
||||||
"negative_pole": "Institutioneel: alle overige partijen — van VVD en SGP tot GroenLinks-PvdA en Volt",
|
"negative_pole": "Institutioneel: VVD, CDA, SGP, ChristenUnie, GroenLinks-PvdA, D66, Volt",
|
||||||
"flip": False,
|
"flip": False,
|
||||||
},
|
},
|
||||||
3: {
|
3: {
|
||||||
@@ -482,58 +481,57 @@ SVD_THEMES: dict[int, dict[str, str]] = {
|
|||||||
),
|
),
|
||||||
"positive_pole": "Pro-verzorgingsstaat: SP, PvdD, GroenLinks-PvdA (anti-bezuinigingen)",
|
"positive_pole": "Pro-verzorgingsstaat: SP, PvdD, GroenLinks-PvdA (anti-bezuinigingen)",
|
||||||
"negative_pole": "Marktliberaal en fiscaal conservatief: VVD, D66, CDA, SGP, BBB",
|
"negative_pole": "Marktliberaal en fiscaal conservatief: VVD, D66, CDA, SGP, BBB",
|
||||||
"flip": False,
|
"flip": True,
|
||||||
},
|
},
|
||||||
4: {
|
4: {
|
||||||
"label": "Pragmatisch centrisme versus ideologische radicaliteit",
|
"label": "Gematigde middenpartijen versus ideologische posities",
|
||||||
"explanation": (
|
"explanation": (
|
||||||
"De gevestigde centrumpartijen (D66, CDA, VVD, 50PLUS) staan tegenover zowel "
|
"Deze as scheidt gematigde middenpartijen van ideologisch gepositioneerde partijen "
|
||||||
"rechts-radicale als identiteitspolitieke posities. Aan de positieve kant staan "
|
"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 "
|
"moties over openbare toiletten, vaderbetrokkenheid bij opvoeding, internationale "
|
||||||
"samenwerking met Australië en Canada, en long covid-expertise. Dit zijn pragmatische, "
|
"samenwerking met Australië en Canada, en long covid-expertise. D66, CDA, VVD "
|
||||||
"institutionele beleidsposities. Aan de negatieve kant staan moties over een "
|
"en 50PLUS staan voor pragmatische, institutionele beleidsposities. "
|
||||||
"migratiesaldo-cap van 60.000, het verlaten van de WHO, kinderen in pleeggezinnen "
|
"De as combineert centristische partijen tegenover zowel rechts-populistische "
|
||||||
"van hetzelfde geslacht (FVD) en de bescherming van religieuze schoolidentiteit "
|
"als identiteitsgerichte posities."
|
||||||
"via artikel 23. De negatieve pool combineert populistisch-rechts met "
|
|
||||||
"identiteitsgerichte posities van zowel rechts als links."
|
|
||||||
),
|
),
|
||||||
"positive_pole": "Constructief centrum: D66, CDA, VVD, 50PLUS — pragmatisch en internationaal",
|
"positive_pole": "Gematigd centrum: D66, CDA, VVD, 50PLUS — pragmatisch en institutioneel",
|
||||||
"negative_pole": "Radicaal-ideologisch: FVD, Groep Markuszower (rechts), ChristenUnie, DENK (religieus/identiteit)",
|
"negative_pole": "Ideologisch/identiteit: FVD, ChristenUnie, DENK — populistisch of religieus-cultureel",
|
||||||
"flip": False,
|
"flip": True,
|
||||||
},
|
},
|
||||||
5: {
|
5: {
|
||||||
"label": "Christelijk-sociaal communitarisme",
|
"label": "Gemeenschapszin en sociale zekerheid versus individualistische progressie",
|
||||||
"explanation": (
|
"explanation": (
|
||||||
"Deze as scheidt partijen die gemeenschapszorg, burgerplicht en informele "
|
"Deze as scheidt partijen die gemeenschapszorg, burgerplicht en informele "
|
||||||
"ondersteuningsstructuren benadrukken van partijen die individuele vrijheden en "
|
"ondersteuningsstructuren benadrukken van partijen die individuele vrijheden en "
|
||||||
"progressieve maatschappelijke hervorming voorstaan. Aan de positieve kant staan "
|
"progressieve maatschappelijke hervorming voorstaan. Aan de positieve kant staan "
|
||||||
"moties over schuldhulpverlening via vrijwilligersorganisaties, de maatschappelijke "
|
"moties over schuldhulpverlening via vrijwilligersorganisaties, maatschappelijke "
|
||||||
"diensttijd voor jongeren met een afstand tot de arbeidsmarkt, en de gastouderopvang. "
|
"diensttijd voor jongeren, gastouderopvang en financiële prikkels voor scholieren. "
|
||||||
"ChristenUnie, SGP en CDA voeren hier de toon; ook D66 scoort positief door steun "
|
"ChristenUnie, SGP en CDA voeren hier de toon; ook D66 scoort positief. "
|
||||||
"aan sociaal beleid. Aan de negatieve kant staan moties over wettelijke erkenning "
|
"Aan de negatieve kant staan moties over wettelijke erkenning van meerouderschap, "
|
||||||
"van meerouderschap, abortusrecht in het EU-Handvest, armoedebeleid en "
|
"abortusrecht in het EU-Handvest, armoedebeleid en buitenlandse beïnvloeding. "
|
||||||
"buitenlandse beïnvloeding. PvdD, GroenLinks-PvdA en VVD scoren hier negatief."
|
"PvdD, GroenLinks-PvdA en VVD scoren negatief."
|
||||||
),
|
),
|
||||||
"positive_pole": "Gemeenschapsgericht: ChristenUnie, SGP, CDA, D66 — vrijwilligers, diensttijd, zorgsystemen",
|
"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,
|
"flip": False,
|
||||||
},
|
},
|
||||||
6: {
|
6: {
|
||||||
"label": "Klimaat, energie en culturele integratie",
|
"label": "Energievoorkeur en migratie-integratie versus klimaat en inclusie",
|
||||||
"explanation": (
|
"explanation": (
|
||||||
"Aan de positieve kant staan moties die LNG-capaciteit prefereren als alternatief "
|
"Deze as combineert energie-ideologie met culturele polarisatie rond migratie "
|
||||||
"voor strenge vulgraadverplichtingen, kernenergie als volwaardig CO₂-arm onderdeel "
|
"en integratie. Aan de positieve kant staan moties die LNG-capaciteit prefereren, "
|
||||||
"van de energiemix willen erkennen op COP30, en discriminatie- en inclusiemeldpunten "
|
"kernenergie als CO₂-arm willen erkennen op COP30, en discriminatie- en "
|
||||||
"willen inventariseren. SGP, JA21, FVD en PVV scoren sterk positief. Aan de "
|
"inclusiemeldpunten willen inventariseren. SGP, JA21, FVD en PVV scoren sterk "
|
||||||
"negatieve kant staan moties die fossiele-industrie-vertegenwoordigers willen weren "
|
"positief. Aan de negatieve kant staan moties die fossiele-industrie-vertegenwoordigers "
|
||||||
"van klimaatconferenties, structureel overleg met moslimgemeenschappen willen bij "
|
"willen weren van klimaatconferenties, structureel overleg met moslimgemeenschappen "
|
||||||
"integratiebeleid, en aanvallen van Israël op Libanon veroordelen. "
|
"willen bij integratiebeleid, en aanvallen van Israël op Libanon veroordelen. "
|
||||||
"PvdD, GroenLinks-PvdA, Volt en D66 scoren negatief. "
|
"PvdD, GroenLinks-PvdA, Volt en D66 scoren negatief. De as scheidt "
|
||||||
"Deze as combineert energieideologie met culturele polarisatie rondom klimaat, "
|
"pro-fossiel/nationaal van klimaat-georiënteerd en inclusief."
|
||||||
"integratie en buitenlandspolitiek."
|
|
||||||
),
|
),
|
||||||
"positive_pole": "Pro-fossiel, nationaal energiebeleid: SGP, JA21, FVD, PVV",
|
"positive_pole": "Pro-fossiel, nationaal: SGP, JA21, FVD, PVV — energie en migratiebeperking",
|
||||||
"negative_pole": "Klimaatgericht en inclusief: PvdD, GroenLinks-PvdA, Volt, D66",
|
"negative_pole": "Klimaat en inclusie: PvdD, GroenLinks-PvdA, Volt, D66, SP",
|
||||||
"flip": False,
|
"flip": False,
|
||||||
},
|
},
|
||||||
7: {
|
7: {
|
||||||
@@ -555,57 +553,56 @@ SVD_THEMES: dict[int, dict[str, str]] = {
|
|||||||
"flip": True,
|
"flip": True,
|
||||||
},
|
},
|
||||||
8: {
|
8: {
|
||||||
"label": "Europese defensie-integratie (indicatief)",
|
"label": "Vaccinatiebeleid, onderwijs en regionale huisvesting (indicatief)",
|
||||||
"explanation": (
|
"explanation": (
|
||||||
"Aan de positieve kant staan moties die pleiten voor militaire mobiliteit als "
|
"Een residuele as die overwegend thematisch diverse moties uit 2024-2025 vangt. "
|
||||||
"topprioriteit in EU/NAVO-verband en toewerken naar een militair Schengengebied, "
|
"Aan de positieve kant staan moties over vaccinatiegraad-verlaging voor kinderen, "
|
||||||
"35% van defensiematerieel Europees inkopen en een Europees defensie-R&D-instituut "
|
"een VWO-profiel kunst en cultuur, stages voor mbo-studenten in het buitenland, "
|
||||||
"oprichten. Ook het Nationaal Groeifonds en gewasbeschermingsonderzoek vallen "
|
"en woningbouw voor jongeren in kleine kernen. BBB, SGP en JA21 scoren positief. "
|
||||||
"positief. Volt en D66 scoren sterk positief. Aan de negatieve kant staan moties "
|
"Aan de negatieve kant staan moties over het instellen van een vaccinatiecommissie, "
|
||||||
"over ketenverantwoordelijkheid bij toeslagen (DENK), het coronaoversterfte-onderzoek "
|
"heropening van het coronaoversterfte-onderzoek, regionale energiestrategieën "
|
||||||
"(PVV/BBB), energiecontracten en huisvestingsregulering. SP (−39), DENK (−35) en "
|
"en toegankelijkheid van het basispakket. SP, DENK en PvdD scoren sterk negatief. "
|
||||||
"PvdD (−26) scoren sterk negatief — dit betekent dat zij actief tégen deze "
|
"Deze as combineert onderwijs- en volksgezondheidsposities met regionale "
|
||||||
"EU-defensiemoties stemmen, niet simpelweg het thema negeren. Volt (N=1) domineert "
|
"huisvestingsprioriteiten — het label is indicatief."
|
||||||
"de positieve pool maar is als centroïde van één Kamerlid statistisch onbetrouwbaar."
|
|
||||||
),
|
),
|
||||||
"positive_pole": "Pro-EU defensie en innovatie: Volt, D66",
|
"positive_pole": "Onderwijs en volksgezondheid: BBB, SGP, JA21 — vaccinatie, profielkeuze, woningbouw",
|
||||||
"negative_pole": "Nationaal/pacifistisch of binnenlandsgericht: SP, DENK, PvdD, 50PLUS",
|
"negative_pole": "Zorg en toegankelijkheid: SP, DENK, PvdD, Volt — coronaonderzoek, energie, basispakket",
|
||||||
"flip": False,
|
"flip": False,
|
||||||
},
|
},
|
||||||
9: {
|
9: {
|
||||||
"label": "Decentraal bestuur en gemeenschapswaarden (indicatief)",
|
"label": "Pragmatische probleemoplossing versus systeemhervorming (indicatief)",
|
||||||
"explanation": (
|
"explanation": (
|
||||||
"Aan de positieve kant staan moties over naleving van de Financiële-verhoudingswet "
|
"Deze as scheidt pragmatische, concrete probleemoplossing van idealistische "
|
||||||
"voor gemeenten, beperking van arbeidsmigratie binnen de EU, een nieuwe "
|
"systeemhervorming. Aan de positieve kant staan moties over naleving van de "
|
||||||
"tandartsopleiding in Rotterdam, een actieplan tegen misbruik van hallucinerende "
|
"Financiële-verhoudingswet voor gemeenten, beperking van arbeidsmigratie, "
|
||||||
"geneesmiddelen en een oplossing voor milieuproblemen op Bonaire. SGP en "
|
"een nieuwe tandartsopleiding in Rotterdam, een actieplan tegen misbruik van "
|
||||||
"ChristenUnie scoren sterk positief; ook DENK en SP. Aan de negatieve kant staan "
|
"hallucinerende geneesmiddelen en oplossingen voor milieuproblemen op Bonaire. "
|
||||||
"moties over een moratorium op geitenstallen, een verbod op gokadvertenties, "
|
"SGP en ChristenUnie scoren sterk positief; ook DENK en SP. Aan de negatieve kant "
|
||||||
"verduidelijking van gronden voor voorlopige hechtenis, een leegstandbelasting voor "
|
"staan moties over een moratorium op geitenstallen, een verbod op gokadvertenties, "
|
||||||
"woningen en end-to-end-encryptie. D66, JA21 en PVV scoren negatief. Deze as "
|
"verduidelijking van gronden voor voorlopige hechtenis, een leegstandbelasting "
|
||||||
"scheidt een nadruk op decentrale dienstverlening en gemeenschapsregulering van "
|
"en end-to-end-encryptie. D66, JA21 en PVV scoren negatief. "
|
||||||
"progressieve systeem- en rechtshervorming."
|
"Deze as is indicatief — de scores zijn smal en ideologisch divers."
|
||||||
),
|
),
|
||||||
"positive_pole": "Lokaal en gemeenschapsgericht: SGP, ChristenUnie, DENK, SP",
|
"positive_pole": "Pragmatisch-bestuurlijk: SGP, ChristenUnie, DENK, SP — concrete oplossingen",
|
||||||
"negative_pole": "Progressieve systemen en rechten: D66, JA21, PVV",
|
"negative_pole": "Systeemhervorming: D66, JA21, PVV — idealistische beleidsposities",
|
||||||
"flip": True,
|
"flip": True,
|
||||||
},
|
},
|
||||||
10: {
|
10: {
|
||||||
"label": "Institutioneel toezicht en handhaving (indicatief)",
|
"label": "Kritisch op overheidsbemoeienis versus pro-regulering (indicatief)",
|
||||||
"explanation": (
|
"explanation": (
|
||||||
"De tiende as vangt resterende variantie op en scheidt partijen die sceptisch zijn "
|
"Deze as scheidt partijen die kritisch staan tegenover overheidsbemoeienis van "
|
||||||
"over staatstoezicht van partijen die strikte regulering en handhaving steunen. "
|
"partijen die strikte regulering en handhaving steunen. Aan de positieve kant "
|
||||||
"Aan de positieve kant staan moties over minder tijdsintensieve schoolinspecties, "
|
"staan moties over minder tijdsintensieve schoolinspecties, het recht van "
|
||||||
"het recht van toeslagenouders op hun persoonlijk dossier, behoud van de "
|
"toeslagenouders op hun persoonlijk dossier, behoud van tegemoetkomingen voor "
|
||||||
"tegemoetkoming voor arbeidsongeschikten en een verlaging van de leeftijdsdrempel "
|
"arbeidsongeschikten en verlaging van de leeftijdsdrempel voor kindgesprekken. "
|
||||||
"voor kindgesprekken. DENK, SP en PvdD scoren positief. Aan de negatieve kant "
|
"DENK, SP en PvdD scoren positief. Aan de negatieve kant staan moties over "
|
||||||
"staan moties over een aangifteplicht voor scholen bij veiligheidsincidenten, een "
|
"een aangifteplicht voor scholen bij veiligheidsincidenten, een rookverbod in "
|
||||||
"rookverbod in auto's met kinderen, braakliggende landagrarisch landbouwgrond en verhoogd "
|
"auto's met kinderen, braakliggende landbouwgrond en verhoogd beloningsgeld "
|
||||||
"beloningsgeld voor tipgevers. GroenLinks-PvdA scoort opvallend sterk negatief, "
|
"voor tipgevers. GroenLinks-PvdA scoort opvallend sterk negatief. "
|
||||||
"waarmee het zich onderscheidt van SP en DENK op handhavingsthema's."
|
"Deze as is indicatief — de scores zijn smal en de partijcombinaties divers."
|
||||||
),
|
),
|
||||||
"positive_pole": "Kritisch op overheidstoezicht: DENK, SP, PvdD, Volt — minder inspectielast",
|
"positive_pole": "Kritisch op overheidsbemoeienis: DENK, SP, PvdD — minder inspectielast en lastenverlichting",
|
||||||
"negative_pole": "Pro-handhaving en regulering: GroenLinks-PvdA, CDA, SGP — veiligheid en naleving",
|
"negative_pole": "Pro-regulering: GroenLinks-PvdA, CDA, SGP — veiligheid, naleving en handhaving",
|
||||||
"flip": True,
|
"flip": True,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -1142,6 +1139,123 @@ def load_party_axis_scores_for_window(
|
|||||||
return {}
|
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…")
|
@st.cache_data(show_spinner="Partij-MP vectoren laden…")
|
||||||
def load_party_mp_vectors(db_path: str) -> Dict[str, List[np.ndarray]]:
|
def load_party_mp_vectors(db_path: str) -> Dict[str, List[np.ndarray]]:
|
||||||
"""Return per-party lists of individual MP SVD vectors.
|
"""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)
|
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…")
|
@st.cache_data(show_spinner="Moties laden…")
|
||||||
def load_motions_df(db_path: str) -> pd.DataFrame:
|
def load_motions_df(db_path: str) -> pd.DataFrame:
|
||||||
"""Load the full motions table as a pandas DataFrame (read-only)."""
|
"""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]
|
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
|
# Sidebar controls for window selection and minimum MPs filter
|
||||||
col1, col2 = st.columns([2, 1])
|
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:
|
with col2:
|
||||||
comp_sel_idx = st.selectbox(
|
comp_sel_idx = st.selectbox(
|
||||||
"Selecteer SVD-as",
|
"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.",
|
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
|
# Show theme explanation
|
||||||
theme = SVD_THEMES.get(comp_sel, {})
|
theme = SVD_THEMES.get(comp_sel, {})
|
||||||
if theme:
|
if theme:
|
||||||
@@ -2870,13 +3203,7 @@ def build_svd_components_tab(db_path: str) -> None:
|
|||||||
motions = comp_map.get(comp_sel, [])
|
motions = comp_map.get(comp_sel, [])
|
||||||
|
|
||||||
# Party axis chart
|
# Party axis chart
|
||||||
# Default party scores (single-window mean vectors) as a fallback
|
# Default party scores already loaded earlier 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
|
|
||||||
)
|
|
||||||
|
|
||||||
# For components 1 and 2, prefer MP-centroid values from the Procrustes-aligned
|
# 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).
|
# positions_by_window so the compass matches the trajectories (MP-mean centroids).
|
||||||
if comp_sel in (1, 2):
|
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
|
# 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:
|
try:
|
||||||
from analysis.svd_labels import compute_flip_direction
|
from analysis.svd_labels import compute_flip_direction
|
||||||
|
|
||||||
for comp in range(1, 11):
|
for comp in range(1, 11):
|
||||||
|
# Compute flip for the selected window (not current_parliament)
|
||||||
flip = compute_flip_direction(comp, party_scores)
|
flip = compute_flip_direction(comp, party_scores)
|
||||||
if comp in SVD_THEMES:
|
if comp in SVD_THEMES:
|
||||||
SVD_THEMES[comp]["flip"] = flip
|
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
|
p: coords for p, coords in party_coords.items() if p in valid_parties
|
||||||
}
|
}
|
||||||
|
|
||||||
# Render party axis chart
|
# Render party axis chart (single window or time trajectory)
|
||||||
if comp_sel in (1, 2):
|
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
|
# Components 1-2 use 2D coords from political compass
|
||||||
_render_party_axis_chart(
|
_render_party_axis_chart(
|
||||||
party_coords, comp_sel, theme, bootstrap_data=bootstrap_data
|
party_coords, comp_sel, theme, bootstrap_data=bootstrap_data
|
||||||
|
|||||||
Reference in New Issue
Block a user