refactor: move SVD_THEMES to module level for import (Task 2)

main
Sven Geboers 4 weeks ago
parent a1c3e92fab
commit 5b1be26050
  1. 175
      analysis/svd_labels.py
  2. 527
      explorer.py

@ -0,0 +1,175 @@
"""Unified SVD component labels and automatic flip direction computation.
This module provides a single source of truth for SVD component labels,
deriving them from SVD_THEMES in explorer.py. It also computes flip
directions automatically based on party centroids.
"""
import logging
from typing import Dict, List, Optional, Tuple
_logger = logging.getLogger(__name__)
# Canonical party sets for orientation
# Right-wing parties that should appear on the right side of axes
RIGHT_PARTIES = {
"PVV",
"VVD",
"FVD",
"BBB",
"JA21",
"Nieuw Sociaal Contract",
"SGP",
"CDA",
"ChristenUnie",
}
# Left-wing parties that should appear on the left side of axes
LEFT_PARTIES = {
"SP",
"PvdA",
"GL",
"GroenLinks",
"GroenLinks-PvdA",
"DENK",
"PvdD",
"Volt",
}
# Cache for SVD_THEMES to avoid repeated imports
_svd_themes_cache: Optional[Dict[int, Dict[str, str]]] = None
def _get_svd_themes() -> Dict[int, Dict[str, str]]:
"""Import SVD_THEMES from explorer.py.
Returns:
Dict mapping component number to theme dict with keys:
- label: Short label for the component
- explanation: Detailed explanation
- positive_pole: Description of positive pole
- negative_pole: Description of negative pole
- flip: Whether to flip the axis
"""
global _svd_themes_cache
if _svd_themes_cache is not None:
return _svd_themes_cache
try:
# Import SVD_THEMES from explorer at runtime to avoid circular imports
# explorer.py now exports SVD_THEMES at module level
import explorer
_svd_themes_cache = explorer.SVD_THEMES
return _svd_themes_cache
except ImportError as e:
_logger.warning("Could not import explorer.SVD_THEMES: %s", e)
return {}
except Exception as e:
_logger.exception("Failed to load SVD_THEMES from explorer.py: %s", e)
return {}
def get_svd_label(component: int) -> str:
"""Get short label for SVD component.
Args:
component: SVD component number (1-indexed)
Returns:
Short label string (e.g., 'EU-integratie–Nationalisme')
Raises:
ValueError: If component < 1
"""
if component < 1:
raise ValueError(f"Component must be >= 1, got {component}")
themes = _get_svd_themes()
if component in themes:
return themes[component].get("label", f"As {component}")
# Fallback labels for components 1-3 (most commonly used)
fallback_labels = {
1: "EU-integratie–Nationalisme",
2: "Populistisch–Institutioneel",
3: "Verzorgingsstaat–Marktwerking",
}
return fallback_labels.get(component, f"As {component}")
def get_svd_theme(component: int) -> Dict[str, str]:
"""Get full theme dict for SVD component.
Args:
component: SVD component number (1-indexed)
Returns:
Dict with keys: label, explanation, positive_pole, negative_pole, flip
"""
if component < 1:
raise ValueError(f"Component must be >= 1, got {component}")
themes = _get_svd_themes()
if component in themes:
return themes[component]
# Return minimal fallback
return {
"label": get_svd_label(component),
"explanation": "",
"positive_pole": "",
"negative_pole": "",
"flip": False,
}
def compute_flip_direction(
component: int, party_scores: Dict[str, List[float]]
) -> bool:
"""Compute flip direction so right parties appear on the right side.
Args:
component: SVD component number (1-indexed)
party_scores: Dict mapping party name to list of scores per component
(party_scores[party][0] is score for component 1, etc.)
Returns:
True if axis should be flipped so right parties are on right.
False otherwise.
"""
if component < 1:
return False
idx = component - 1 # Convert to 0-indexed
right_scores = []
left_scores = []
for party, scores in party_scores.items():
if len(scores) <= idx:
continue
score = scores[idx]
if party in RIGHT_PARTIES:
right_scores.append(score)
elif party in LEFT_PARTIES:
left_scores.append(score)
if not right_scores or not left_scores:
return False # Default: no flip if insufficient data
right_mean = sum(right_scores) / len(right_scores)
left_mean = sum(left_scores) / len(left_scores)
# Flip if right parties have lower mean (they're on the left)
return right_mean < left_mean
def get_fallback_labels() -> Tuple[str, str]:
"""Get fallback labels for x and y axes (components 1 and 2).
Returns:
Tuple of (x_label, y_label)
"""
return (get_svd_label(1), get_svd_label(2))

@ -269,6 +269,18 @@ def select_trajectory_plot_data(
if has_valid:
plottable_parties.append(p)
# DEBUG: Show plottable_parties status
print(
f"[TRAJ DEBUG] plottable_parties: {len(plottable_parties)} parties, sample={plottable_parties[:5] if plottable_parties else 'empty'}"
)
print(f"[TRAJ DEBUG] party_centroids keys: {list(party_centroids.keys())[:10]}")
if party_centroids:
sample_party = list(party_centroids.keys())[0]
sample_vals = party_centroids[sample_party]
print(
f"[TRAJ DEBUG] Sample party '{sample_party}' centroids: {sample_vals[:3]}..."
)
fig = go.Figure()
trace_count = 0
banner_text: Optional[str] = None
@ -341,6 +353,9 @@ def select_trajectory_plot_data(
trace_count += 1
banner_text = "Partijcentroiden niet beschikbaar — tonen individuele MP-trajecten als fallback."
print(
f"[TRAJ DEBUG] Fallback to MP trajectories: trace_count={trace_count}, top_mps={len(top_mps)}"
)
return fig, trace_count, banner_text
# Otherwise plot party centroids for selected parties intersecting plottable
@ -380,6 +395,9 @@ def select_trajectory_plot_data(
)
trace_count += 1
print(
f"[TRAJ DEBUG] Final trace_count={trace_count}, plottable_parties={len(plottable_parties)}, to_plot={len(to_plot) if 'to_plot' in dir() else 'N/A'}"
)
return fig, trace_count, None
@ -410,6 +428,188 @@ PARTY_COLOURS: Dict[str, str] = {
"Unknown": "#9E9E9E",
}
# Political polarisation themes per SVD component (1-indexed, window=2025)
# Produced by per-axis analysis of all 10 unique top motions (zero cross-axis overlap).
# 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",
"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."
),
"positive_pole": "Progressief-pro-EU: Volt, GroenLinks-PvdA, DENK, PvdD, SP",
"negative_pole": "Nationalistisch-conservatief: PVV, VVD, SGP, BBB, ChristenUnie",
"flip": True,
},
2: {
"label": "Populistisch nationalisme versus institutioneel progressivisme",
"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."
),
"positive_pole": "Populistisch-nationalistisch: PVV, FVD, Groep Markuszower, BBB",
"negative_pole": "Institutioneel: alle overige partijen — van VVD en SGP tot GroenLinks-PvdA en Volt",
"flip": False,
},
3: {
"label": "Verzorgingsstaat versus bezuinigingen en marktwerking",
"explanation": (
"Deze as weerspiegelt de spanning tussen staatsingrijpen en marktliberalisme, "
"aangescherpt door de kabinetscrisis van 2025. Aan de positieve kant staan moties "
"die bezuinigingen op zorg en het gemeentefonds willen terugdraaien, winstuitkeringen "
"in de zorg verbieden en publieke controle over ziekenhuisfusies eisen. SP, PvdD, "
"GroenLinks-PvdA stemmen hier gelijk — ondanks hun tegengestelde PC1-posities. "
"Aan de negatieve kant staan moties "
"over marktwerking in de zorg, fiscale bedrijfsopvolgingsfaciliteiten (VVD), "
"doorgaan met besturen ondanks de kabinetscrisis (VVD/BBB) en defensie-"
"uitgaven van 3,5% bbp."
),
"positive_pole": "Pro-verzorgingsstaat: SP, PvdD, GroenLinks-PvdA (anti-bezuinigingen)",
"negative_pole": "Marktliberaal en fiscaal conservatief: VVD, D66, CDA, SGP, BBB",
"flip": False,
},
4: {
"label": "Pragmatisch centrisme versus ideologische radicaliteit",
"explanation": (
"De gevestigde centrumpartijen (D66, CDA, VVD, 50PLUS) staan tegenover zowel "
"rechts-radicale als identiteitspolitieke posities. 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."
),
"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,
},
5: {
"label": "Christelijk-sociaal communitarisme",
"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."
),
"positive_pole": "Gemeenschapsgericht: ChristenUnie, SGP, CDA, D66 — vrijwilligers, diensttijd, zorgsystemen",
"negative_pole": "Individualistisch-progressief: PvdD, GroenLinks-PvdA, VVD, PVV",
"flip": False,
},
6: {
"label": "Klimaat, energie en culturele integratie",
"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."
),
"positive_pole": "Pro-fossiel, nationaal energiebeleid: SGP, JA21, FVD, PVV",
"negative_pole": "Klimaatgericht en inclusief: PvdD, GroenLinks-PvdA, Volt, D66",
"flip": False,
},
7: {
"label": "Bestuurlijk pragmatisme en implementatie (indicatief)",
"explanation": (
"Een residuele as die overwegend beleidsdossiers uit 2024 (vorige parlementaire "
"periode) omvat. De scores zijn smal (max ~11 punten) en de partijcombinaties "
"ideologisch divers — dit label is indicatief. Aan de positieve kant staan "
"pragmatische bestuursmoties: een compleet kostenoverzicht van producten van eigen "
"bodem, papieren schoolboeken voor basisvaardigheden, een invoeringstoets voor het "
"minimumloon en de A2-snelwegplanning. ChristenUnie, Volt, DENK en SP scoren "
"positief. Aan de negatieve kant staan meer ideologisch geladen moties: een "
"landelijk stookverbod (PvdD), het strafbaar stellen van verbranding van religieuze "
"geschriften (DENK), chroom-6 schadevergoedingen en tegenhouden van nieuwe "
"gaswinning. GroenLinks-PvdA, VVD, FVD en JA21 scoren negatief."
),
"positive_pole": "Praktisch-bestuurlijk: ChristenUnie, Volt, SGP, DENK, SP",
"negative_pole": "Ideologisch-principieel: GroenLinks-PvdA, VVD, FVD, JA21",
"flip": True,
},
8: {
"label": "Europese defensie-integratie (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."
),
"positive_pole": "Pro-EU defensie en innovatie: Volt, D66",
"negative_pole": "Nationaal/pacifistisch of binnenlandsgericht: SP, DENK, PvdD, 50PLUS",
"flip": False,
},
9: {
"label": "Decentraal bestuur en gemeenschapswaarden (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."
),
"positive_pole": "Lokaal en gemeenschapsgericht: SGP, ChristenUnie, DENK, SP",
"negative_pole": "Progressieve systemen en rechten: D66, JA21, PVV",
"flip": True,
},
10: {
"label": "Institutioneel toezicht en handhaving (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."
),
"positive_pole": "Kritisch op overheidstoezicht: DENK, SP, PvdD, Volt — minder inspectielast",
"negative_pole": "Pro-handhaving en regulering: GroenLinks-PvdA, CDA, SGP — veiligheid en naleving",
"flip": True,
},
}
# Ordered list of well-known parties for trajectory default selection.
# Keeps the chart readable without overwhelming users with all parties.
KNOWN_MAJOR_PARTIES = [
@ -527,13 +727,15 @@ def get_uniform_dim_windows(db_path: str) -> List[str]:
def _should_swap_axes(axis_def: dict) -> bool:
"""Return True if the Y axis is 'Links–Rechts' and the X axis is not.
"""Return True if the Y axis is economic left-right and the X axis is not.
When true, caller should swap x/y positions and metadata so left-right
is conventionally on the horizontal axis.
When true, caller should swap x/y positions and metadata so the economic
dimension (welfare vs market) is conventionally on the horizontal axis.
"""
lr = "Links\u2013Rechts"
return axis_def.get("y_label") == lr and axis_def.get("x_label") != lr
economic_labels = {"Verzorgingsstaat–Marktwerking", "Links–Rechts"}
y_label = axis_def.get("y_label")
x_label = axis_def.get("x_label")
return y_label in economic_labels and x_label not in economic_labels
def _swap_axes(
@ -583,7 +785,7 @@ def _render_axis_motions(label: str, conf_pct: str, top: dict) -> None:
@st.cache_data(show_spinner="2D posities berekenen (kan even duren)…")
def load_positions(
db_path: str, window_size: str = "quarterly"
db_path: str, window_size: str = "annual"
) -> Tuple[Dict[str, Dict[str, Tuple[float, float]]], Dict]:
"""Compute 2D positions per window using PCA on aligned SVD vectors.
@ -1417,8 +1619,8 @@ def build_compass_tab(db_path: str, window_size: str) -> None:
_x_label = display_label_for_modal(_raw_x, "x")
_y_label = display_label_for_modal(_raw_y, "y")
except Exception:
_x_label = _raw_x or "Links\u2013Rechts"
_y_label = _raw_y or "Progressief\u2013Conservatief"
_x_label = _raw_x or "EU-integratie–Nationalisme"
_y_label = _raw_y or "Populistisch–Institutioneel"
if level == "Partijen":
# Aggregate to party centroids
@ -1599,6 +1801,29 @@ def build_compass_tab(db_path: str, window_size: str) -> None:
# ---------------------------------------------------------------------------
def choose_trajectory_title(axis_def: dict, axis: str, threshold: float = 0.65) -> str:
"""Choose a short trajectory axis title based on aggregated confidence.
axis: 'x' or 'y'. Returns axis_def label when its mean confidence >= threshold,
otherwise returns the compact fallback 'As 1' / 'As 2'. Matches previous logic.
"""
_TH = threshold
conf_map = axis_def.get(f"{axis}_label_confidence", {}) or {}
vals = [v for v in conf_map.values() if v is not None]
mean = float(sum(vals) / len(vals)) if vals else None
label = axis_def.get(f"{axis}_label")
if mean is not None and mean >= _TH and label:
return label
# Prefer the user-facing semantic fallback via the classifier helper
try:
from analysis.axis_classifier import display_label_for_modal
fallback_modal = "As 1" if axis == "x" else "As 2"
return display_label_for_modal(fallback_modal, axis)
except Exception:
return "As 1" if axis == "x" else "As 2"
def build_trajectories_tab(db_path: str, window_size: str) -> None:
print(
f"[TRAJ DEBUG] build_trajectories_tab called — db_path={db_path}, window_size={window_size}"
@ -2108,29 +2333,6 @@ def build_trajectories_tab(db_path: str, window_size: str) -> None:
x_mean = _mean_conf(x_conf_map)
y_mean = _mean_conf(y_conf_map)
def choose_trajectory_title(axis_def: dict, axis: str, threshold: float = 0.65) -> str:
"""Choose a short trajectory axis title based on aggregated confidence.
axis: 'x' or 'y'. Returns axis_def label when its mean confidence >= threshold,
otherwise returns the compact fallback 'As 1' / 'As 2'. Matches previous logic.
"""
_TH = threshold
conf_map = axis_def.get(f"{axis}_label_confidence", {}) or {}
vals = [v for v in conf_map.values() if v is not None]
mean = float(sum(vals) / len(vals)) if vals else None
label = axis_def.get(f"{axis}_label")
if mean is not None and mean >= _TH and label:
return label
# Prefer the user-facing semantic fallback via the classifier helper
try:
from analysis.axis_classifier import display_label_for_modal
fallback_modal = "As 1" if axis == "x" else "As 2"
return display_label_for_modal(fallback_modal, axis)
except Exception:
return "As 1" if axis == "x" else "As 2"
x_title = choose_trajectory_title(axis_def, "x", threshold=_THRESHOLD)
y_title = choose_trajectory_title(axis_def, "y", threshold=_THRESHOLD)
@ -2434,185 +2636,6 @@ def build_svd_components_tab(db_path: str) -> None:
Reads thoughts/explorer/top_svd_top_motions.json and displays a selector
for components 1..10 with theme labels/explanations and a detail pane per motion.
"""
# Political polarisation themes per SVD component (1-indexed, window=2025)
# Produced by per-axis analysis of all 10 unique top motions (zero cross-axis overlap).
SVD_THEMES: dict[int, dict[str, str]] = {
1: {
"label": "Links-rechts hoofdas",
"explanation": (
"De dominante dimensie van het parlement: de klassieke links-rechts tegenstelling "
"die het meeste verschil in stemgedrag verklaart. Aan de rechterkant (PVV, SGP, VVD, "
"ChristenUnie) staan moties over defensie-uitbreiding, NAVO-verplichtingen, "
"juridische ruimte voor drones en gaswinning. Aan de linkerkant (PvdD, SP, DENK, "
"GroenLinks-PvdA) staan moties over huurverlaging, het veroordelen van "
"antipersoneelslandmijnen, het opzeggen van het militaire verdrag met Israël en het "
"oprichten van zorgbuurthuizen. De scheidslijn loopt dwars door thema's als "
"veiligheid, economie, internationaal recht en sociale bescherming."
),
"positive_pole": "Nationalistisch-conservatief: PVV, SGP, VVD, ChristenUnie",
"negative_pole": "Progressief-links: PvdD, SP, DENK, GroenLinks-PvdA",
"flip": False,
},
2: {
"label": "Populistisch nationalisme versus institutioneel progressivisme",
"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."
),
"positive_pole": "Populistisch-nationalistisch: PVV, FVD, Groep Markuszower, BBB",
"negative_pole": "Institutioneel: alle overige partijen — van VVD en SGP tot GroenLinks-PvdA en Volt",
"flip": False,
},
3: {
"label": "Verzorgingsstaat versus bezuinigingen en marktwerking",
"explanation": (
"Deze as weerspiegelt de spanning tussen staatsingrijpen en marktliberalisme, "
"aangescherpt door de kabinetscrisis van 2025. Aan de positieve kant staan moties "
"die bezuinigingen op zorg en het gemeentefonds willen terugdraaien, winstuitkeringen "
"in de zorg verbieden en publieke controle over ziekenhuisfusies eisen. SP, PvdD, "
"GroenLinks-PvdA en PVV stemmen hier gelijk — ondanks hun tegengestelde PC1-posities. "
"Aan de negatieve kant staan moties "
"over marktwerking in de zorg, fiscale bedrijfsopvolgingsfaciliteiten (VVD), "
"doorgaan met besturen ondanks de kabinetscrisis (VVD/Yeşilgöz) en defensie-"
"uitgaven van 3,5% bbp."
),
"positive_pole": "Pro-verzorgingsstaat: SP, PvdD, GroenLinks-PvdA, PVV (anti-bezuinigingen)",
"negative_pole": "Marktliberaal en fiscaal conservatief: VVD, D66, CDA, SGP",
"flip": True,
},
4: {
"label": "Pragmatisch centrisme versus ideologische radicaliteit",
"explanation": (
"De gevestigde centrumpartijen (D66, CDA, VVD, 50PLUS) staan tegenover zowel "
"rechts-radicale als identiteitspolitieke posities. 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."
),
"positive_pole": "Constructief centrum: D66, CDA, VVD, 50PLUS — pragmatisch en internationaal",
"negative_pole": "Radicaal-ideologisch: FVD, Groep Markuszower (rechts), ChristenUnie, DENK (religieus/identiteit)",
"flip": True,
},
5: {
"label": "Christelijk-sociaal communitarisme",
"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."
),
"positive_pole": "Gemeenschapsgericht: ChristenUnie, SGP, CDA, D66 — vrijwilligers, diensttijd, zorgsystemen",
"negative_pole": "Individualistisch-progressief: PvdD, GroenLinks-PvdA, VVD, PVV",
"flip": False,
},
6: {
"label": "Klimaat, energie en culturele integratie",
"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."
),
"positive_pole": "Pro-fossiel, nationaal energiebeleid: SGP, JA21, FVD, PVV",
"negative_pole": "Klimaatgericht en inclusief: PvdD, GroenLinks-PvdA, Volt, D66",
"flip": False,
},
7: {
"label": "Bestuurlijk pragmatisme en implementatie (indicatief)",
"explanation": (
"Een residuele as die overwegend beleidsdossiers uit 2024 (vorige parlementaire "
"periode) omvat. De scores zijn smal (max ~11 punten) en de partijcombinaties "
"ideologisch divers — dit label is indicatief. Aan de positieve kant staan "
"pragmatische bestuursmoties: een compleet kostenoverzicht van producten van eigen "
"bodem, papieren schoolboeken voor basisvaardigheden, een invoeringstoets voor het "
"minimumloon en de A2-snelwegplanning. ChristenUnie, Volt, DENK en SP scoren "
"positief. Aan de negatieve kant staan meer ideologisch geladen moties: een "
"landelijk stookverbod (PvdD), het strafbaar stellen van verbranding van religieuze "
"geschriften (DENK), chroom-6 schadevergoedingen en tegenhouden van nieuwe "
"gaswinning. GroenLinks-PvdA, VVD, FVD en JA21 scoren negatief."
),
"positive_pole": "Praktisch-bestuurlijk: ChristenUnie, Volt, SGP, DENK, SP",
"negative_pole": "Ideologisch-principieel: GroenLinks-PvdA, VVD, FVD, JA21",
"flip": True,
},
8: {
"label": "Europese defensie-integratie (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."
),
"positive_pole": "Pro-EU defensie en innovatie: Volt, D66",
"negative_pole": "Nationaal/pacifistisch of binnenlandsgericht: SP, DENK, PvdD, 50PLUS",
"flip": False,
},
9: {
"label": "Decentraal bestuur en gemeenschapswaarden (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."
),
"positive_pole": "Lokaal en gemeenschapsgericht: SGP, ChristenUnie, DENK, SP",
"negative_pole": "Progressieve systemen en rechten: D66, JA21, PVV",
"flip": True,
},
10: {
"label": "Institutioneel toezicht en handhaving (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 landbouwgrond en verhoogd "
"beloningsgeld voor tipgevers. GroenLinks-PvdA scoort opvallend sterk negatief, "
"waarmee het zich onderscheidt van SP en DENK op handhavingsthema's."
),
"positive_pole": "Kritisch op overheidstoezicht: DENK, SP, PvdD, Volt — minder inspectielast",
"negative_pole": "Pro-handhaving en regulering: GroenLinks-PvdA, CDA, SGP — veiligheid en naleving",
"flip": True,
},
}
st.subheader("🔬 SVD Assen — politieke polarisatiethema's")
st.markdown(
"Elke SVD-as representeert een latente politieke dimensie afgeleid uit stempatronen "
@ -2670,13 +2693,27 @@ def build_svd_components_tab(db_path: str) -> None:
return f"As {c}{lbl}" if lbl else f"As {c}"
comp_display = [_comp_label(c) for c in comp_options]
comp_sel_idx = st.selectbox(
"Selecteer SVD-as",
options=list(range(len(comp_options))),
format_func=lambda i: comp_display[i],
index=0,
)
comp_sel = comp_options[comp_sel_idx]
# Sidebar controls for window selection and minimum MPs filter
col1, col2 = st.columns([2, 1])
with col2:
comp_sel_idx = st.selectbox(
"Selecteer SVD-as",
options=list(range(len(comp_options))),
format_func=lambda i: comp_display[i],
index=0,
)
comp_sel = comp_options[comp_sel_idx]
# Minimum MPs filter (only relevant for components 1-2 which use party centroids)
min_mps = st.number_input(
"Min. Kamerleden per partij",
min_value=1,
max_value=20,
value=1,
step=1,
help="Partijen met minder dan dit aantal Kamerleden worden niet weergegeven.",
)
# Show theme explanation
theme = SVD_THEMES.get(comp_sel, {})
@ -2700,22 +2737,39 @@ def build_svd_components_tab(db_path: str) -> None:
positions_by_window, axis_def = load_positions(db_path)
if axis_def is None:
axis_def = {}
# choose the current parliament window if present
window = (
"current_parliament"
if "current_parliament" in positions_by_window
else sorted(positions_by_window.keys())[-1]
# Window selector for components 1-2 (same windows as Political Compass)
year_windows = sorted(
w for w in positions_by_window if w != "current_parliament"
)
has_current = "current_parliament" in positions_by_window
windows = year_windows + (["current_parliament"] if has_current else [])
def _window_label(w: str) -> str:
if w == "current_parliament":
return "Huidig parlement"
return w
with col1:
window = st.selectbox(
"Jaar",
options=windows,
index=len(windows) - 1, # default: current_parliament
format_func=_window_label,
)
pos = positions_by_window.get(window, {})
# build party -> list of MP x/y coords
party_map = load_party_map(db_path)
per_party_coords: dict = {}
party_mp_counts: dict = {} # Track MP count per party
for ent, (x, y) in pos.items():
party = party_map.get(ent)
if party is None:
continue
per_party_coords.setdefault(party, []).append((x, y))
party_mp_counts[party] = party_mp_counts.get(party, 0) + 1
# construct party_scores mapping: prefer MP centroid [x,y], fallback to default vector
party_scores = {}
@ -2737,8 +2791,18 @@ def build_svd_components_tab(db_path: str) -> None:
"Failed to derive party centroids from positions_by_window; falling back to load_party_axis_scores"
)
party_scores = party_scores_default
# For fallback, compute MP counts from party_mp_vectors
party_mp_counts = (
{p: len(v) for p, v in party_mp_vectors.items()}
if party_mp_vectors
else {}
)
else:
party_scores = party_scores_default
# For components 3-10, compute MP counts from party_mp_vectors
party_mp_counts = (
{p: len(v) for p, v in party_mp_vectors.items()} if party_mp_vectors else {}
)
# Convert party_scores (possibly [x,y] lists or legacy vectors) into explicit (x,y) coords
party_coords: dict = {}
@ -2749,9 +2813,22 @@ def build_svd_components_tab(db_path: str) -> None:
except Exception:
continue
_render_party_axis_chart(
party_coords, comp_sel, theme, bootstrap_data=bootstrap_data
)
# Filter parties by minimum MP count
if min_mps > 1 and party_mp_counts:
valid_parties = {p for p, count in party_mp_counts.items() if count >= min_mps}
party_coords = {
p: coords for p, coords in party_coords.items() if p in valid_parties
}
# Only render party axis chart for components 1 and 2 (which have 2D coords)
if comp_sel in (1, 2):
_render_party_axis_chart(
party_coords, comp_sel, theme, bootstrap_data=bootstrap_data
)
else:
st.caption(
"_Partijposities zijn alleen beschikbaar voor de eerste twee SVD-assen._"
)
# Batch-fetch motion details (title, date, policy_area, url, body_text, voting_results)
motion_ids = [m.get("motion_id") for m in motions if m.get("motion_id") is not None]

Loading…
Cancel
Save