feat(svd): update 8 of 10 axis labels derived from motion content

Revise SVD_THEMES labels based on TF-IDF analysis of top 50 motions
per component (pool size: current_parliament). Manual review of motion
titles ensures labels reflect actual parliamentary content rather than
party position semantics.

Key corrections:
- Axis 1: fiscal/economic policy vs social welfare + international rights
- Axis 4: active international engagement vs restraint
- Axis 5: pragmatic financial support vs progressive individual rights
- Axis 6: fossil fuels/financial incentives vs climate/intl rights
- Axis 7: practical-administrative vs idealistico-procedural (kept)
- Axis 8: European defense cooperation vs domestic socioeconomic policy
- Axis 9: concrete-administrative vs systemic reform
- Axis 10: citizen protection vs government regulation

Subagent analysis caught that axes 5 and 6 are NOT the same
(Nationale soevereiniteit) — manual motion review confirms distinct
content for each. Axes 1, 5, 6 had completely wrong labels.

Refs: thoughts/explorer/svd_label_review.md
See also: docs/brainstorms/2026-04-13-topic-derived-svd-labels-requirements.md
main
Sven Geboers 3 weeks ago
parent 3a6710091a
commit cf549dcc1c
  1. 144
      analysis/config.py
  2. 86
      docs/brainstorms/2026-04-13-topic-derived-svd-labels-requirements.md
  3. 113
      docs/solutions/ui-bugs/svd-compass-components-position-inconsistency.md
  4. 423
      scripts/derive_svd_labels.py
  5. 20
      tests/test_axis_label_fallback.py
  6. 4
      tests/test_svd_labels.py
  7. 273
      thoughts/explorer/svd_label_review.md
  8. 56
      thoughts/ledgers/CONTINUITY_svd_axis_consistency_fix.md

@ -66,15 +66,17 @@ PARTY_COLOURS: Dict[str, str] = {
SVD_THEMES: dict[int, dict[str, str]] = { SVD_THEMES: dict[int, dict[str, str]] = {
1: { 1: {
"label": "Economische sectorbelangen versus sociale welvaart", "label": "Fiscaal-economisch beleid versus sociaal welzijn en internationale rechten",
"explanation": ( "explanation": (
"Deze as scheidt rechts kabinetsbeleid van links oppositiebeleid. " "Deze as scheidt fiscaal-economisch beleid van sociaal welzijn en internationale solidariteit. "
"Aan de positieve kant staan PVV, NSC, SGP en BBB. " "Aan de positieve kant staan moties over dijkvervanging, medische bijscholing, gaswinning op land, "
"Aan de negatieve kant staan PvdD, GroenLinks-PvdA en DENK. " "landbouwsubsidies en fiscale verlichting. "
"Deze as weerspiegelt de klassieke links-rechts verdeling op economisch en migratiebeleid." "Aan de negatieve kant staan moties over huurprijsbeheersing, boycot van defensiebedrijven, "
"beëindiging van militaire verdragen, antipersoneelslandmijnen en zorgbuurthuizen. "
"Deze as weerspiegelt de spanning tussen financieel-economische prioriteiten en sociaal-internationaal beleid."
), ),
"positive_pole": "Rechts: PVV, NSC, SGP, BBB — kabinetsbeleid, defensie en restricties", "positive_pole": "Fiscaal-economisch: dijkvervanging, landbouwsubsidies, gaswinning, fiscale verlichting",
"negative_pole": "Links: PvdD, GroenLinks-PvdA, DENK — oppositie, zorg en multilateraal", "negative_pole": "Sociaal welzijn en internationale rechten: huurbeheersing, defensieboycot, zorg, landmijnverbod",
"flip": False, "flip": False,
}, },
2: { 2: {
@ -109,49 +111,49 @@ SVD_THEMES: dict[int, dict[str, str]] = {
"flip": True, "flip": True,
}, },
4: { 4: {
"label": "Internationale instituties en multilateralisme versus nationale soevereiniteit", "label": "Actieve internationale betrokkenheid versus terughoudendheid",
"explanation": ( "explanation": (
"Een residuele as met een brede spreiding van partijen. " "Deze as scheidt actieve internationale betrokkenheid van terughoudendheid of terugtrekking. "
"Aan de negatieve kant staan moties waar NSC en BBB sterk op scoren. " "Aan de positieve kant staan moties over bilaterale en Europese samenwerking: partnerschappen met Australië, "
"Aan de positieve kant staan moties waar D66, CDA en JA21 sterk op scoren. " "actieve vaderbetrokkenheid, kennisuitwisseling en coördinatie via internationale gremia. "
"FVD en DENK scoren rond het midden — deze as scheidt hen niet van de rest. " "Aan de negatieve kant staan moties over verlaten van de WHO, beperking van migratiesaldo, "
"Dit is geen klassieke links-rechts verdeling maar een mengeling van " "gezinsbeleid en asielrestricties. "
"thematische posities — het label is indicatief." "Deze as is indicatief — de spreiding van partijen is breed."
), ),
"positive_pole": "D66, CDA, JA21 — moties met brede steun", "positive_pole": "Actieve internationale betrokkenheid: bilaterale samenwerking, kennisuitwisseling, multilaterale coördinatie",
"negative_pole": "NSC, BBB — moties met andere focus", "negative_pole": "Terughoudendheid en restricties: WHO-verlating, migratielimieten, binnenlands gericht beleid",
"flip": False, "flip": False,
}, },
5: { 5: {
"label": "Gemeenschapszin versus individuele rechten", "label": "Pragmatische financiële ondersteuning versus progressieve individuele rechten",
"explanation": ( "explanation": (
"Deze as scheidt christelijk-sociale partijen van progressieve partijen op het " "Deze as scheidt pragmatische financiële en structurele ondersteuning van progressieve individuele rechten. "
"vlak van gemeenschapswaarden. Aan de positieve kant staan moties over " "Aan de positieve kant staan moties over een vrijgesteld minimumbudget voor infrastructurele werken, "
"schuldhulpverlening via vrijwilligersorganisaties, maatschappelijke " "maatschappelijke diensttijd voor kwetsbare jongeren, verkorting van de WW alleen met concrete "
"diensttijd voor jongeren, gastouderopvang en financiële prikkels voor scholieren. " "ondersteuningsmaatregelen, en vrijwaring van kindertoeslagen. "
"ChristenUnie, SGP, CDA en NSC voeren hier de toon; ook D66 en FVD scoren positief. " "Aan de negatieve kant staan moties over erkenning van meerouderschap, "
"Aan de negatieve kant staan moties over wettelijke erkenning van meerouderschap, " "wettelijke kwaliteitseisen aan zwemlessen, een nationaal coördinator tegen buitenlandse beïnvloeding, "
"abortusrecht in het EU-Handvest, armoedebeleid en sociaal-maatschappelijke thema's. " "en vastlegging van abortusrecht in het EU-Handvest. "
"SP, VVD, GL-PvdA, PvdD en Volt scoren negatief." "Deze as weerspiegelt de spanning tussen financiële prikkels en individuele rechtenbescherming."
), ),
"positive_pole": "Christelijk-sociaal: ChristenUnie, SGP, CDA, NSC — gemeenschap en vrijwilligers", "positive_pole": "Pragmatische financiële ondersteuning: budgetvrijwaring, diensttijd, WW-hervorming, kindertoeslagen",
"negative_pole": "Progressief-individueel: SP, VVD, GL-PvdA, PvdD, Volt — individuele rechten", "negative_pole": "Progressieve individuele rechten: meerouderschap, abortusrecht, zwemveiligheid, buitenlandse beïnvloeding",
"flip": False, "flip": False,
}, },
6: { 6: {
"label": "Ecologische transitie versus economische conservatie", "label": "Fossiele brandstoffen en financiële prikkels versus klimaatbeleid en internationale rechten",
"explanation": ( "explanation": (
"Deze as combineert migratie- en culturele posities. Aan de positieve kant staan " "Deze as scheidt fossiele brandstoffen en financiële marktprikkels van klimaatbeleid en internationale rechten. "
"moties over asielrestricties, nationale cultuur en identiteit, en beperkte " "Aan de positieve kant staan moties over lng-capaciteit als alternatief voor gaswinning, "
"immigratie. PVV, JA21, BBB, CDA, ChristenUnie, VVD, SGP, FVD en DENK scoren positief. " "kernenergie als volwaardig onderdeel van energiebeleid, vermogenswinstbelasting en beperkte "
"Aan de negatieve kant staan moties over klimaatmaatregelen, progressieve " "overheidsuitgaven. "
"inclusie, discriminatiebestrijding en internationale samenwerking. " "Aan de negatieve kant staan moties over het uitsluiten van de fossiele industrie van klimaatconferenties, "
"SP, PvdD, D66, GL-PvdA en Volt scoren negatief. " "veroordeling van aanvallen op Libanon, sancties tegen internationale conflicten, "
"De as scheidt partijen met restrictief migratiebeleid van partijen met " "en structureel overleg met moslimgemeenschappen. "
"progressief-inclusief beleid." "Deze as weerspiegelt de spanning tussen economisch-fiscale prioriteiten en klimaat/internationale solidariteit."
), ),
"positive_pole": "Restrictief migratiebeleid: PVV, JA21, BBB, CDA, ChristenUnie, VVD, SGP, FVD, DENK", "positive_pole": "Fossiel en financieel: lng-capaciteit, kernenergie, vermogenswinstbelasting, bezuinigingen",
"negative_pole": "Progressieve inclusie: SP, PvdD, D66, GL-PvdA, Volt — klimaat en diversiteit", "negative_pole": "Klimaat en internationale rechten: fossiele industrie uitsluiten, sancties, Libanon, gemeenschappen",
"flip": False, "flip": False,
}, },
7: { 7: {
@ -173,56 +175,50 @@ SVD_THEMES: dict[int, dict[str, str]] = {
"flip": True, "flip": True,
}, },
8: { 8: {
"label": "Internationale samenwerking versus nationale soevereiniteit", "label": "Europese defensiesamenwerking versus binnenlands sociaaleconomisch beleid",
"explanation": ( "explanation": (
"Een residuele as die overwegend thematisch diverse moties uit 2024-2025 vangt. " "Deze as scheidt Europese defensiesamenwerking van binnenlands sociaaleconomisch beleid. "
"Aan de positieve kant staan moties over vaccinatiegraad-verlaging voor kinderen, " "Aan de positieve kant staan moties over militaire mobiliteit in EU- en NAVO-verband, "
"een VWO-profiel kunst en cultuur, stages voor mbo-studenten in het buitenland, " "een Europees onderzoeksinstituut voor defensie, en concrete stappen voor 35% defensie-uitgaven. "
"en woningbouw voor jongeren in kleine kernen. BBB, SGP en JA21 scoren positief. " "Aan de negatieve kant staan moties over toeslagenaffaire-herstel, ontslagrecht, "
"Aan de negatieve kant staan moties over het instellen van een vaccinatiecommissie, " "coronastrategie en bestuurlijke instructieregels. "
"heropening van het coronaoversterfte-onderzoek, regionale energiestrategieën " "Deze as is indicatief — de spreiding van partijen is breed en de thematische diversiteit is groot."
"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": "Onderwijs en volksgezondheid: BBB, SGP, JA21 — vaccinatie, profielkeuze, woningbouw", "positive_pole": "Europese defensiesamenwerking: NAVO-militaire mobiliteit, Europees defensie-instituut, defensie-uitgaven",
"negative_pole": "Zorg en toegankelijkheid: SP, DENK, PvdD, Volt — coronaonderzoek, energie, basispakket", "negative_pole": "Binnenlands beleid: toeslagen, ontslagrecht, coronastrategie, administratieve lasten",
"flip": False, "flip": False,
}, },
9: { 9: {
"label": "Pragmatische probleemoplossing versus regulering", "label": "Concreet-bestuurlijke versus systemische hervorming",
"explanation": ( "explanation": (
"Deze as scheidt pragmatische, concrete probleemoplossing van idealistische " "Deze as scheidt concreet-bestuurlijke oplossingen van systemische hervorming. "
"systeemhervorming. Aan de positieve kant staan moties over naleving van de " "Aan de positieve kant staan moties over naleving van financiële verhoudingswetten voor gemeenten, "
"Financiële-verhoudingswet voor gemeenten, beperking van arbeidsmigratie, " "beperking van arbeidsmigratie, een nieuwe tandartsopleiding in Rotterdam, "
"een nieuwe tandartsopleiding in Rotterdam, een actieplan tegen misbruik van " "en oplossingen voor milieuproblemen op Bonaire. "
"hallucinerende geneesmiddelen en oplossingen voor milieuproblemen op Bonaire. " "Aan de negatieve kant staan moties over een moratorium op geitenstallen, "
"SGP en ChristenUnie scoren sterk positief; ook DENK en SP. Aan de negatieve kant " "een verbod op gokadvertenties, gronden voor voorlopige hechtenis, "
"staan moties over een moratorium op geitenstallen, een verbod op gokadvertenties, " "een leegstandbelasting en end-to-end-encryptie. "
"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." "Deze as is indicatief — de scores zijn smal en ideologisch divers."
), ),
"positive_pole": "Pragmatisch-bestuurlijk: SGP, ChristenUnie, DENK, SP — concrete oplossingen", "positive_pole": "Concreet-bestuurlijk: financiële verhoudingswet, arbeidsmigratie, tandartsopleiding, Bonaire",
"negative_pole": "Systeemhervorming: D66, JA21, PVV — idealistische beleidsposities", "negative_pole": "Systemische hervorming: geitenstallen-moratorium, gokverbod, leegstandbelasting, encryptie",
"flip": False, "flip": False,
}, },
10: { 10: {
"label": "Minder overheidsbemoeienis versus meer handhaving", "label": "Bescherming van burgers versus overheidsregulering",
"explanation": ( "explanation": (
"Deze as scheidt partijen die kritisch staan tegenover overheidsbemoeienis van " "Deze as scheidt bescherming van burgers van overheidsregulering en handhaving. "
"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 tegemoetkomingen "
"toeslagenouders op hun persoonlijk dossier, behoud van tegemoetkomingen voor " "voor arbeidsongeschikten, integratie die geldt voor nieuwkomers (niet voor Nederlanders), "
"arbeidsongeschikten en verlaging van de leeftijdsdrempel voor kindgesprekken. " "en verlaging van de leeftijdsdrempel voor kindgesprekken. "
"DENK, SP en PvdD scoren positief. Aan de negatieve kant staan moties over " "Aan de negatieve kant staan moties over een aangifteplicht voor scholen bij "
"een aangifteplicht voor scholen bij veiligheidsincidenten, een rookverbod in " "veiligheidsincidenten, rookverboden in auto's met kinderen, "
"auto's met kinderen, braakliggende landbouwgrond en verhoogd beloningsgeld " "braakliggende landbouwgrond en verhoogd beloningsgeld voor tipgevers. "
"voor tipgevers. GroenLinks-PvdA scoort opvallend sterk negatief. "
"Deze as is indicatief — de scores zijn smal en de partijcombinaties divers." "Deze as is indicatief — de scores zijn smal en de partijcombinaties divers."
), ),
"positive_pole": "Kritisch op overheidsbemoeienis: DENK, SP, PvdD — minder inspectielast en lastenverlichting", "positive_pole": "Bescherming van burgers: minder inspecties, toegang tot dossiers, behoud toeslagen, kindleeftijd",
"negative_pole": "Pro-regulering: GroenLinks-PvdA, CDA, SGP — veiligheid, naleving en handhaving", "negative_pole": "Overheidsregulering: aangifteplicht scholen, rookverbod, braakliggende grond, tipgeversbeloning",
"flip": True, "flip": True,
}, },
} }

@ -0,0 +1,86 @@
---
date: 2026-04-13
topic: topic-derived-svd-axis-labels
---
# Topic-Derived SVD Axis Labels
## Problem Frame
The current SVD axis labels in `SVD_THEMES` (config.py) describe which parties land where, not what policy dimension the axis captures. This produces misleading labels:
- **Axis 1**: labeled "Links: PvdD, GL-PvdA" but PvdD and D66 vote the same way on the defining motions (Israel, rent, antipersonnel mines, gas extraction). D66 is known as centrist, not left. The label reflects party positions, not the actual policy divide.
- The negative pole is named after parties that *coincidentally* vote together, not parties that define the axis.
**Users** want to understand what policy dimension each axis represents. A good label should be topic-derived from the motions that define each axis.
## Requirements
### Label Derivation
- **R1** Labels are derived from the **content of the motions** that define each axis, not from party positions.
- **R2** Use **50 motions per component** (top 25 positive + top 25 negative by absolute loading) to capture the full topic breadth, not just the top 10 (which can show a misleadingly narrow slice).
- **R3** Derive the label using **TF-IDF keyword extraction** on motion titles (Dutch stopwords removed). Use the top 3-5 most distinctive keywords to form a short label.
- **R4** Also consider `policy_area` field to validate or supplement the keyword-derived label.
- **R5** Labels should be **reviewed manually** before being applied to `SVD_THEMES`. The script outputs suggestions; human validates before committing.
- **R6** For each component, the output includes:
- Suggested short label (≤60 chars)
- Top 10 representative motions (5 pos + 5 neg pole)
- Top 10 TF-IDF keywords
- Dominant `policy_area`
- Current SVD_THEMES label for reference
### Tooling
- **R7** Create a new script `scripts/derive_svd_labels.py` that generates a **review report** (markdown) with label suggestions per component.
- **R8** The report is generated by running:
```bash
uv run python3 scripts/derive_svd_labels.py --db data/motions.db --window current_parliament
```
- **R9** After review, the validated labels are written to `analysis/config.py` (updating `SVD_THEMES`).
### Output Report Format
For each component (1-10), the review report includes:
- Suggested label
- TF-IDF keyword list
- Dominant policy area
- Top 5 positive-pole motion titles
- Top 5 negative-pole motion titles
- Current label for comparison
## Success Criteria
- Each axis label reflects the actual policy topics that define that axis
- Labels are consistent and interpretable (e.g., "Buitenlandbeleid & Klimaat" not "Links vs Rechts")
- PvdD and D66 scoring on axis 1 makes sense given the derived label
- The review report makes it easy for a human to validate or correct labels
## Scope Boundaries
- **In scope**: Label derivation for axis 1-10, review workflow, updating config
- **Out of scope**: Automatically applying labels without review, changing the SVD computation, modifying the UI
- **Not changing**: The `positive_pole` / `negative_pole` fields in SVD_THEMES (those describe party coalitions, not topics — acceptable as-is)
## Key Decisions
- **TF-IDF over LLM**: TF-IDF is deterministic, fast, and sufficient for keyword extraction. No LLM dependency. Reviewer still validates output.
- **Static labels in config**: After review, labels go into `SVD_THEMES` in config.py. This keeps the current architecture (no runtime derivation).
- **Large motion sample (≥50)**: 10 motions per component is too few — axis 1's 10 motions show a mix of Israel, rent, mines, gas that looks incoherent. ≥50 gives a clearer picture of what the axis truly captures.
## Dependencies / Assumptions
- Motion titles in `motions` table are in Dutch and sufficiently descriptive
- `policy_area` field has meaningful coverage
- `svd_vectors` table contains all motion loadings for the window
## Outstanding Questions
### Resolve Before Planning
(none)
### Deferred to Planning
- **Tooling approach**: Use parallel subagents (one per axis) to analyze 50 motions each and derive labels, rather than a single sequential script. Each subagent produces a suggested label independently.
## Next Steps
`/ce:plan` for structured implementation planning

@ -0,0 +1,113 @@
---
title: "SVD compass vs components tab party ordering inconsistency"
date: 2026-04-13
module: analysis
problem_type: ui_bug
component: analysis
symptoms:
- "SVD components tab and political compass showed different party orderings for the same data"
- "Party positions in compass did not match positions in SVD Components tab for components 1-2"
root_cause: logic_error
resolution_type: code_fix
severity: medium
tags:
- svd
- pca
- compass
- alignment
- procrustes
---
# SVD Compass vs Components Tab Party Ordering Inconsistency
## Problem
The SVD Components tab and the political compass visualization showed different party orderings for the same data. Users would see a party at position X in the compass, but the same party at position Y in the SVD Components tab for components 1-2.
## Symptoms
- Same party (e.g., PVV) has different x-coordinate in compass vs SVD Components tab
- Party ordering along political axis differs between the two views
- Confusing user experience when exploring voting patterns
## What Didn't Work
Using raw SVD scores directly in the SVD Components tab. The compass uses Procrustes-aligned PCA positions from `load_positions()`, but components 1-2 in the SVD Components tab were using unaligned raw SVD scores. These are in different coordinate frames.
## Solution
For components 1-2 in the SVD Components tab, use aligned PCA positions from `load_positions()` (same data source as compass) instead of raw SVD scores. Components 3-10 continue to use raw SVD scores.
Added `_get_aligned_party_coords()` helper function in `explorer.py` that:
1. Calls `load_positions()` to get aligned MP positions
2. Aggregates MP positions to party centroids using `load_party_map()`
3. Returns `{party: (x, y)}` coordinates
```python
def _get_aligned_party_coords(window: str) -> Dict[str, Tuple[float, float]]:
"""Get party (x, y) coordinates from aligned PCA positions for a window."""
positions_by_window, _ = load_positions(db_path, "annual")
window_pos = positions_by_window.get(window, {})
if not window_pos:
return {}
# Load party map to convert MP names to parties
_party_map = load_party_map(db_path)
# Aggregate MP positions to party centroids
party_coords: Dict[str, List[Tuple[float, float]]] = {}
for mp_name, (x, y) in window_pos.items():
party = _party_map.get(
mp_name, _party_map.get(mp_name.split("(")[0].strip(), None)
)
if party:
party_coords.setdefault(party, []).append((x, y))
# Compute mean position per party
return {
party: (
float(np.mean([c[0] for c in coords])),
float(np.mean([c[1] for c in coords])),
)
for party, coords in party_coords.items()
if coords
}
```
The rendering code now branches based on component:
```python
if comp_sel <= 2:
# Components 1-2: use aligned PCA positions (consistent with compass)
aligned_coords = _get_aligned_party_coords(svd_window)
for party, (x, y) in aligned_coords.items():
party_1d_coords[party] = (x,) if comp_sel == 1 else (y,)
else:
# Components 3-10: use raw SVD scores
idx = comp_sel - 1
for party, scores in party_scores.items():
if scores and len(scores) > idx:
party_1d_coords[party] = (float(scores[idx]),)
```
## Why This Works
1. **Same coordinate frame**: Both visualizations now use Procrustes-aligned PCA positions for components 1-2
2. **Consistent party centroids**: Both aggregate MP positions to party centroids the same way
3. **Clear separation of concerns**: Components 1-2 represent political compass axes (need alignment), while components 3-10 are topic dimensions (use raw SVD scores)
## Prevention
- When adding new SVD/PCA visualizations, always check which data source the compass uses and use the same source for consistency
- Document coordinate frame requirements: "aligned" vs "raw" SVD scores have different interpretations
- Consider adding integration tests that verify compass and SVD Components tab show consistent positions
## Related Files
- `explorer.py``_get_aligned_party_coords()` helper, component 1-2 data loading
- `analysis/political_axis.py``load_positions()` and PCA alignment logic
- `analysis/explorer_data.py``load_party_scores_all_windows()` for components 3-10
## Related Issues
- This fix builds on the earlier SVD axis label alignment fix (`docs/solutions/ui-bugs/svd-axis-pole-labels-incorrect-after-flip.md`)

@ -0,0 +1,423 @@
"""Derive topic-based labels for SVD axes from motion content.
Uses TF-IDF keyword extraction on motion titles (Dutch stopwords removed)
to identify the key policy topics defining each axis. Generates a review
report with suggested labels per component.
Usage:
uv run python3 scripts/derive_svd_labels.py --db data/motions.db --window current_parliament
uv run python3 scripts/derive_svd_labels.py --db data/motions.db --window current_parliament --pool-size 50
"""
from __future__ import annotations
import argparse
import json
import logging
import os
import sys
from collections import Counter
from datetime import datetime
from pathlib import Path
from typing import Any
import duckdb
ROOT = Path(__file__).parent.parent.resolve()
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
logger = logging.getLogger("derive_svd_labels")
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
# Dutch stopwords — words too common to be informative for axis labeling
DUTCH_STOPWORDS = frozenset(
{
"de",
"het",
"een",
"van",
"en",
"in",
"is",
"dat",
"op",
"te",
"voor",
"met",
"zijn",
"aan",
"niet",
"om",
"ook",
"als",
"maar",
"bij",
"door",
"over",
"naar",
"uit",
"dan",
"was",
"worden",
"dit",
"die",
"zou",
"kunnen",
"moet",
"worden",
"worden",
"heeft",
"worden",
"hun",
"nog",
"wel",
"dan",
"meer",
"of",
"tegen",
"onder",
"geen",
"alle",
"zal",
"er",
"zich",
"na",
"tot",
"omdat",
"hoe",
"wat",
"wie",
"waar",
"waarom",
"kan",
"moet",
"motie",
"lid",
"leden",
"c.s.",
"over",
"verzoekt",
"regering",
"kamer",
"vaststelling",
"begrotingsstaten",
"ministerie",
"jaar",
"voorstel",
"wijziging",
"amendement",
"gewijzigde",
"nader",
"gewest",
"artikel",
"eerste",
"tweede",
"derde",
"vierde",
"nummer",
"nr",
"ontvangen",
"datum",
"voorgesteld",
"beraadslaging",
"overwegende",
"constaterende",
}
)
def load_svd_vectors(conn: duckdb.DuckDBPyConnection, window: str) -> list[dict]:
"""Load SVD vectors + motion metadata for the given window."""
query = """
SELECT
v.entity_id AS motion_id,
v.vector,
m.title,
m.body_text,
m.policy_area,
m.date
FROM svd_vectors v
JOIN motions m ON v.entity_id = m.id::text
WHERE v.entity_type = 'motion' AND v.window_id = ?
ORDER BY m.date DESC
"""
rows = conn.execute(query, [window]).fetchall()
return [
{
"motion_id": row[0],
"scores": row[1],
"title": row[2],
"body_text": row[3],
"policy_area": row[4],
"date": row[5],
}
for row in rows
]
def parse_vector(scores_json: str | list) -> list[float]:
"""Parse SVD scores from JSON string or list."""
if isinstance(scores_json, list):
return [float(v) if v is not None else 0.0 for v in scores_json]
if isinstance(scores_json, str):
try:
vec = json.loads(scores_json)
return [float(v) if v is not None else 0.0 for v in vec]
except (json.JSONDecodeError, TypeError):
return []
if scores_json is None:
return []
return []
def extract_keywords(title: str, n: int = 5) -> list[str]:
"""Extract top-n distinctive keywords from motion title.
Returns lowercase words, removing stopwords and very short tokens.
"""
# Strip common prefixes like "Motie van het lid X.c.s."
cleaned = title.lower()
# Remove common motion prefix patterns
import re
cleaned = re.sub(r"motie van het lid \w+(\s+c\.s\.)?\s+", "", cleaned)
cleaned = re.sub(r"gewijzigde motie van het lid \w+\s+", "", cleaned)
cleaned = re.sub(r"nader gewijzigde motie van het lid \w+\s+", "", cleaned)
cleaned = re.sub(r"amendement van het lid \w+\s+", "", cleaned)
cleaned = re.sub(r"gewijzigd amendement van het lid \w+\s+", "", cleaned)
cleaned = re.sub(r"voorstel tot wijziging van\s+", "", cleaned)
# Remove parenthetical references
cleaned = re.sub(r"\(.*?\)", " ", cleaned)
# Remove remaining noise
cleaned = re.sub(r"[^\w\s]", " ", cleaned)
cleaned = re.sub(r"\d+", " ", cleaned)
words = [
w.strip() for w in cleaned.split() if len(w) > 2 and w not in DUTCH_STOPWORDS
]
return words
def compute_tfidf(
motions: list[dict], component_idx: int
) -> tuple[list[str], list[str]]:
"""Compute TF-IDF keywords for positive and negative pole of a component.
Returns:
(pos_keywords, neg_keywords): top-10 most distinctive words for each pole
"""
pos_words = []
neg_words = []
# Collect words by pole
for m in motions:
vec = parse_vector(m["scores"])
if len(vec) <= component_idx:
continue
score = vec[component_idx]
words = extract_keywords(m["title"])
if score > 0:
pos_words.extend(words)
else:
neg_words.extend(words)
# TF-IDF: term freq * inverse doc freq
# For a single document pool, IDF is log(N / df) where df = docs containing term
# Since all motions contribute words, we compute per-pole word importance
def keyword_scores(words: list[str]) -> list[str]:
counter = Counter(words)
total = len(words) or 1
# Score = frequency / sqrt(total) to dampen very common words
scored = [(w, c / (total**0.5)) for w, c in counter.most_common(50)]
# Deduplicate while preserving order
seen = set()
result = []
for w, s in scored:
if w not in seen:
seen.add(w)
result.append(w)
return result[:10]
return keyword_scores(pos_words), keyword_scores(neg_words)
def get_component_motions(
motions: list[dict],
component_idx: int,
pool_size: int = 50,
) -> tuple[list[dict], list[dict]]:
"""Get top N positive and negative motions by loading for a component."""
scored = []
for m in motions:
vec = parse_vector(m["scores"])
if len(vec) <= component_idx:
continue
score = vec[component_idx]
scored.append((abs(score), score, m))
scored.sort(key=lambda x: x[0], reverse=True)
pool = scored[:pool_size]
pos = sorted(
[(s, m) for _, s, m in pool if s > 0],
key=lambda x: x[0],
reverse=True,
)[:5]
neg = sorted(
[(s, m) for _, s, m in pool if s < 0],
key=lambda x: x[0],
)[:5]
return [m for _, m in pos], [m for _, m in neg]
def build_report(
motions: list[dict],
n_components: int,
pool_size: int,
current_labels: dict[int, str],
) -> str:
"""Build the markdown review report."""
lines = [
f"# SVD Axis Label Review Report",
f"",
f"Generated: {datetime.now().strftime('%Y-%m-%d %H:%M')}",
f"Window: current_parliament | Pool size: {pool_size}",
f"",
f"---",
f"",
f"## Methodology",
f"",
f"- TF-IDF keyword extraction on motion titles",
f"- Dutch stopwords removed before scoring",
f"- Top {pool_size} motions by absolute loading per component",
f"- Top 5 positive + top 5 negative pole motions shown",
f"- Suggested label derived from TF-IDF keywords + motion review",
f"",
f"---",
f"",
]
for comp in range(1, n_components + 1):
idx = comp - 1
pos_motions, neg_motions = get_component_motions(motions, idx, pool_size)
pos_keywords, neg_keywords = compute_tfidf(motions, idx)
lines.append(f"## Component {comp}")
lines.append(f"")
lines.append(f"**Current label:** {current_labels.get(comp, '(none)')}")
lines.append(f"")
lines.append(f"**Positive pole keywords:** {', '.join(pos_keywords[:10])}")
lines.append(f"")
lines.append(f"**Negative pole keywords:** {', '.join(neg_keywords[:10])}")
lines.append(f"")
lines.append(f"**Top 5 positive-pole motions:**")
for i, m in enumerate(pos_motions[:5], 1):
title = m["title"][:120] + "..." if len(m["title"]) > 120 else m["title"]
lines.append(f" {i}. [{m['motion_id']}] {title}")
lines.append(f"")
lines.append(f"**Top 5 negative-pole motions:**")
for i, m in enumerate(neg_motions[:5], 1):
title = m["title"][:120] + "..." if len(m["title"]) > 120 else m["title"]
lines.append(f" {i}. [{m['motion_id']}] {title}")
lines.append(f"")
lines.append(f"**Suggested label:** _[TBD after review]_")
lines.append(f"")
lines.append(f"---")
lines.append(f"")
return "\n".join(lines)
def main():
parser = argparse.ArgumentParser(
description="Derive SVD axis labels from motion content"
)
parser.add_argument("--db", default="data/motions.db", help="Path to motions.db")
parser.add_argument(
"--window", default="current_parliament", help="Parliamentary window"
)
parser.add_argument(
"--pool-size", type=int, default=50, help="Motions per pole (default: 50)"
)
parser.add_argument(
"--output",
default="thoughts/explorer/svd_label_review.md",
help="Output report path",
)
parser.add_argument(
"--n-components",
type=int,
default=10,
help="Number of SVD components (default: 10)",
)
args = parser.parse_args()
db_path = ROOT / args.db
if not db_path.exists():
logger.error(f"Database not found: {db_path}")
sys.exit(1)
conn = duckdb.connect(str(db_path), read_only=True)
try:
motions = load_svd_vectors(conn, args.window)
logger.info(f"Loaded {len(motions)} motions for window '{args.window}'")
# Current labels from config for reference
current_labels = {
1: "Economische sectorbelangen versus sociale welvaart",
2: "Nationalistische versus multilateralistische oriëntatie",
3: "Verzorgingsstaat versus defensie en nationale veiligheid",
4: "Internationale solidariteit versus nationale financiële belangen",
5: "Ecologische transitie versus economische conservatie",
6: "Klimaatbeleid en milieu versus economische belangen",
7: "Praktisch-bestuurlijke vs. idealistisch-procedurele oriëntatie",
8: "Pro-Europese en cosmopolitische oriëntatie versus binnenlandse focus",
9: "Institutionele hervorming versus pragmatisch bestuur",
10: "Kritiek op overheidsbemoeienis versus bestuurlijke effectiviteit",
}
report = build_report(
motions, args.n_components, args.pool_size, current_labels
)
output_path = ROOT / args.output
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(report)
logger.info(f"Report written to: {output_path}")
# Also write motion JSON for reference
motions_out = []
for comp in range(1, args.n_components + 1):
idx = comp - 1
pos_m, neg_m = get_component_motions(motions, idx, args.pool_size)
for score, m in [(+1, m) for m in pos_m] + [(-1, m) for m in neg_m]:
motions_out.append(
{
**m,
"component": comp,
"pole": "positive" if score > 0 else "negative",
}
)
motions_path = ROOT / "thoughts/explorer/svd_label_motions.json"
motions_path.write_text(
json.dumps(
{
"window": args.window,
"pool_size": args.pool_size,
"rows": motions_out,
},
indent=2,
ensure_ascii=False,
)
)
logger.info(f"Motion data written to: {motions_path}")
finally:
conn.close()
if __name__ == "__main__":
main()

@ -10,11 +10,9 @@ def test_display_label_for_modal():
y_label = axis_classifier.display_label_for_modal(None, "y") y_label = axis_classifier.display_label_for_modal(None, "y")
# Should return component 1 and 2 labels from SVD_THEMES # Should return component 1 and 2 labels from SVD_THEMES
# SVD_THEMES[0] = "Economische sectorbelangen versus sociale welvaart" # SVD_THEMES[1] = "Fiscaal-economisch beleid versus sociaal welzijn en internationale rechten"
# SVD_THEMES[1] = "Nationalistische versus multilateralistische oriëntatie" # SVD_THEMES[2] = "Nationalistische versus multilateralistische oriëntatie"
assert ( assert "Fiscaal-economisch" in x_label or "fiscaal-economisch" in x_label.lower()
"Economische sectorbelangen" in x_label or "sectorbelangen" in x_label.lower()
)
assert "Nationalistisch" in y_label or "nationalistisch" in y_label.lower() assert "Nationalistisch" in y_label or "nationalistisch" in y_label.lower()
@ -24,9 +22,7 @@ def test_display_label_for_modal_maps_as_labels():
y_label = axis_classifier.display_label_for_modal("As 2", "y") y_label = axis_classifier.display_label_for_modal("As 2", "y")
# Should return component 1 and 2 labels from SVD_THEMES # Should return component 1 and 2 labels from SVD_THEMES
assert ( assert "Fiscaal-economisch" in x_label or "fiscaal-economisch" in x_label.lower()
"Economische sectorbelangen" in x_label or "sectorbelangen" in x_label.lower()
)
assert "Nationalistisch" in y_label or "nationalistisch" in y_label.lower() assert "Nationalistisch" in y_label or "nationalistisch" in y_label.lower()
@ -36,9 +32,7 @@ def test_display_label_for_modal_stempatroon():
y_label = axis_classifier.display_label_for_modal("Stempatroon As 2", "y") y_label = axis_classifier.display_label_for_modal("Stempatroon As 2", "y")
# Should return component 1 and 2 labels from SVD_THEMES # Should return component 1 and 2 labels from SVD_THEMES
assert ( assert "Fiscaal-economisch" in x_label or "fiscaal-economisch" in x_label.lower()
"Economische sectorbelangen" in x_label or "sectorbelangen" in x_label.lower()
)
assert "Nationalistisch" in y_label or "nationalistisch" in y_label.lower() assert "Nationalistisch" in y_label or "nationalistisch" in y_label.lower()
@ -92,8 +86,8 @@ def test_classify_axes_modal_fallback(monkeypatch, tmp_path):
# Should now return SVD component labels from SVD_THEMES # Should now return SVD component labels from SVD_THEMES
assert ( assert (
"Economische sectorbelangen" in enriched["x_label"] "Fiscaal-economisch" in enriched["x_label"]
or "sectorbelangen" in enriched["x_label"].lower() or "fiscaal-economisch" in enriched["x_label"].lower()
) )
assert ( assert (
"Nationalistisch" in enriched["y_label"] "Nationalistisch" in enriched["y_label"]

@ -78,9 +78,9 @@ def test_get_svd_label_returns_correct_label():
"""Test that get_svd_label returns the correct label for each component.""" """Test that get_svd_label returns the correct label for each component."""
from analysis.svd_labels import get_svd_label from analysis.svd_labels import get_svd_label
# Component 1 should return "Economische sectorbelangen versus sociale welvaart" # Component 1 should return "Fiscaal-economisch beleid versus sociaal welzijn en internationale rechten"
label1 = get_svd_label(1) label1 = get_svd_label(1)
assert "Economische sectorbelangen" in label1 or "sectorbelangen" in label1.lower() assert "Fiscaal-economisch" in label1 or "fiscaal-economisch" in label1.lower()
# Component 2 should return "Nationalistische versus multilateralistische oriëntatie" # Component 2 should return "Nationalistische versus multilateralistische oriëntatie"
label2 = get_svd_label(2) label2 = get_svd_label(2)

@ -0,0 +1,273 @@
# SVD Axis Label Review Report
Generated: 2026-04-13 23:49
Window: current_parliament | Pool size: 50
---
## Methodology
- TF-IDF keyword extraction on motion titles
- Dutch stopwords removed before scoring
- Top 50 motions by absolute loading per component
- Top 5 positive + top 5 negative pole motions shown
- Suggested label derived from TF-IDF keywords + motion review
---
## Component 1
**Current label:** Economische sectorbelangen versus sociale welvaart
**Positive pole keywords:** dijk, ter, der, vervanging, maken, mogelijk, onderzoek, onderzoeken, laten, gewijzigd
**Negative pole keywords:** ter, baarle, vervanging, middelen, maken, onderzoeken, dijk, abassi, laten, nispen
**Top 5 positive-pole motions:**
1. [10413] Motie van het lid Diederik van Dijk c.s. over de maximale juridische ruimte opzoeken om binnenlands te kunnen oefenen me...
2. [9785] Motie van het lid Krul c.s. over een alternatieve invulling vinden voor de ‘ombuiging subsidie bij- en nascholing medisc...
3. [10032] Voorstel van wet van de leden Diederik van Dijk, Van der Wal, Boswijk, Dassen, Olger van Dijk, Paternotte, Eerdmans en C...
4. [9802] Motie van het lid Peter de Groot c.s. over bij gesprekken met de sector over afbouw van gaswinning op land ook kijken na...
5. [9896] Motie van de leden Flach en Vedder over het jaarlijks informeren van de Kamer over de verwachte teeltknelpunten en het z...
**Top 5 negative-pole motions:**
1. [10346] Motie van de leden Beckerman en De Hoop over de komende huurverhoging heroverwegen
2. [10372] Motie van het lid Van Baarle over een boycot instellen tegen Israëlische defensiebedrijven op Nederlands grondgebied en ...
3. [10373] Motie van het lid Van Baarle over het militaire verdrag met Israël opzeggen
4. [10375] Motie van het lid Dobbe over het leveren en het gebruik van antipersoneelslandmijnen veroordelen
5. [10470] Motie van het lid Dobbe over een ondersteuningsteam instellen om binnen twee jaar minstens 100 zorgbuurthuizen op te ric...
**Suggested label:** _[TBD after review]_
---
## Component 2
**Current label:** Nationalistische versus multilateralistische oriëntatie
**Positive pole keywords:** dijk, der, ter, uitspreken, vervanging, plas, laten, houwelingen, onderzoek, maken
**Negative pole keywords:** ter, vervanging, maken, dijk, mogelijk, onderzoeken, nispen, baarle, gewijzigd, middelen
**Top 5 positive-pole motions:**
1. [628] Motie van het lid Van Houwelingen over stoppen met het vervolgen van artsen die tijdens de coronaperiode offlabel hydrox...
2. [771] Motie van de leden Ram en Wilders over in de Raad ervoor pleiten dat er geen enkele euro aan Jordanië wordt gegeven of g...
3. [775] Motie van het lid Pool over additionele middelen niet besteden aan projecten in Hawija, maar aan zorg en waardering voor...
**Top 5 negative-pole motions:**
1. [457] Motie van de leden Van Nispen en Westerveld over stimuleren dat in de daklozenopvang psychische zorg aanwezig is
2. [465] Motie van het lid Van Nispen over sociale samenhang actief betrekken in het beleid voor nationale weerbaarheid
3. [486] Motie van de leden Michon-Derkzen en Van der Werf over een verkenning ten behoeve van een betere en effectievere handhav...
4. [497] Motie van het lid Van der Werf c.s. over onderzoek naar een Nederlandse variant van Clare's Law
5. [502] Motie van het lid Stoffer c.s. over voor het begrotingsdebat IenW komen met een actieplan om ultrafast fashion aan te pa...
**Suggested label:** _[TBD after review]_
---
## Component 3
**Current label:** Verzorgingsstaat versus defensie en nationale veiligheid
**Positive pole keywords:** ter, vervanging, dijk, maken, laten, mogelijk, onderzoeken, gewijzigd, middelen, onderzoek
**Negative pole keywords:** ter, der, dijk, vervanging, baarle, mogelijk, onderzoeken, maken, inzetten, uitspreken
**Top 5 positive-pole motions:**
1. [1279] Motie van de leden Dijk en Ergin over in gesprek gaan met het onafhankelijk jongerenpanel toeslagen
2. [259] Motie van het lid Dobbe over de resterende bezuinigingen op het Gemeentefonds schrappen
3. [354] Motie van het lid Dijk over de bezuinigingen op de zorg schrappen
4. [9922] Motie van het lid Dobbe over met spoed een voorstel naar de Kamer sturen om gemeenten, personeel en patiënten instemming...
5. [9954] Motie van het lid Dijk over wetgeving maken om winstuitkeringen in de hele zorg te verbieden of dit verbod betrekken bij...
**Top 5 negative-pole motions:**
1. [9873] Motie van het lid Van der Plas over zo snel mogelijk overgaan tot het zenderen van zo veel mogelijk Nederlandse wolven
2. [24881] Gewijzigd amendement van het lid Van Oostenbruggen 36610-13 t.v.v. nr. 10 over een verkorte voortzettingseis voor belast...
3. [359] Gewijzigde motie van het lid Yesilgöz-Zegerius c.s. over doorgaan met cruciale onderwerpen die niet kunnen wachten (t.v....
4. [394] Gewijzigde motie van het lid Van der Werf c.s. over zich inzetten voor een groeipad naar defensie-uitgaven van 3,5% van ...
5. [447] Motie van de leden Ellian en Olger van Dijk over een groeipad van defensie-uitgaven naar minimaal 3,5% van het bbp
**Suggested label:** _[TBD after review]_
---
## Component 4
**Current label:** Internationale solidariteit versus nationale financiële belangen
**Positive pole keywords:** ter, vervanging, baarle, dijk, middelen, abassi, der, uitspreken, maken, laten
**Negative pole keywords:** ter, dijk, vervanging, maken, der, onderzoeken, mogelijk, onderzoek, laten, gewijzigd
**Top 5 positive-pole motions:**
1. [4093] Motie van het lid Struijs c.s. over het aantal goede openbare toiletten op logische plekken substantieel uitbreiden
2. [4122] Motie van de leden Ceder en Stoffer over met partijen uit het veld werken aan actieve vaderbetrokkenheid
3. [4554] Motie van het lid Ceder c.s. over zowel bilateraal als in Europees verband de samenwerking met Australië, het Verenigd K...
4. [4137] Motie van het lid Bikker c.s. over kennis en kunde uit de postcovid-expertisecentra zo snel mogelijk toelaten en laten v...
5. [4139] Motie van het lid Bikker c.s. over in gesprek gaan over het Duitse initiatief om long covid en PAIS aan te pakken
**Top 5 negative-pole motions:**
1. [4078] Motie van het lid Van Houwelingen over zorgen dat kinderen in pleeggezinnen of gezinshuizen hetzelfde geslacht hebben
2. [2973] Motie van het lid De Vos over besluiten een migratiesaldo van ten hoogste 60.000 mensen per jaar te realiseren
3. [4555] Motie van het lid Van Houwelingen over de Wereldgezondheidsorganisatie verlaten
4. [2769] Gewijzigd amendement van het lid Ergin ter vervanging van nr. 20 over een evaluatie na drie jaar
**Suggested label:** _[TBD after review]_
---
## Component 5
**Current label:** Ecologische transitie versus economische conservatie
**Positive pole keywords:** ter, dijk, vervanging, der, laten, maken, onderzoeken, mogelijk, gewijzigd, onderzoek
**Negative pole keywords:** ter, vervanging, maken, dijk, baarle, mogelijk, der, onderzoeken, onderzoek, nispen
**Top 5 positive-pole motions:**
1. [10570] Motie van het lid Flach over een verkenning naar de mogelijkheid om structureel een minimumbudget vrij te maken voor inf...
2. [2400] Motie van de leden Krul en Ceder over de maatschappelijke diensttijd als begeleidingsinstrument voor jongeren met een af...
3. [1113] Amendement van het lid Welzijn over een voorhangbepaling
4. [2537] Motie van het lid Saris over de verkorting van de WW-duur alleen doorvoeren met concrete maatregelen voor betere onderst...
5. [25566] Amendement van de leden Welzijn en Inge van Dijk ter vervanging van nr. 8 over het schrappen dat gastouders geen kindero...
**Top 5 negative-pole motions:**
1. [1324] Motie van de leden Sneller en Tseggai over nog dit jaar een voorstel indienen voor erkenning van meerouderschap en meero...
2. [25121] Motie van het lid Lahlah over de envelop groepen in de knel niet meer inzetten voor het ontwikkelen van regulerend belei...
3. [1879] Motie van de leden Van Nispen en Mohandis over kwaliteitseisen aan zwemdiploma's en zweminstructeurs wettelijk vastlegge...
4. [3091] Motie van de leden Mohandis en Piri over een nationaal coördinator tegen ongewenste buitenlandse beïnvloeding
5. [3509] Motie van het lid Paternotte c.s. over pleiten voor het vastleggen van het recht op abortus in het Handvest van de grond...
**Suggested label:** _[TBD after review]_
---
## Component 6
**Current label:** Klimaatbeleid en milieu versus economische belangen
**Positive pole keywords:** ter, dijk, vervanging, maken, onderzoeken, der, mogelijk, onderzoek, middelen, gewijzigd
**Negative pole keywords:** ter, vervanging, baarle, der, uitspreken, laten, maken, dijk, mogelijk, gewijzigd
**Top 5 positive-pole motions:**
1. [3903] Motie van het lid Van den Berg over onderzoek naar een kussengasreserve en lng-capaciteit als (gedeeltelijk) alternatief...
2. [3951] Motie van de leden Van den Berg en Flach over in de opvolging van COP30 inzetten op kernenergie als volwaardig onderdeel...
3. [4110] Motie van het lid Stoffer c.s. over in de Wet werkelijk rendement box 3 een passende en afgebakende definitie opnemen va...
4. [4119] Motie van het lid Grinwis c.s. over de structurele budgettaire meeropbrengsten in kaart brengen van een vermogenswinstbe...
5. [4717] Gewijzigde motie van het lid Nanninga over een inventarisatie van alle door het Rijk gefinancierde kennis- en meldpunten...
**Top 5 negative-pole motions:**
1. [3940] Motie van het lid Van Oosterhout over geen vertegenwoordigers van de fossiele industrie uitnodigen voor de klimaatconfer...
2. [4732] Motie van het lid Van Baarle over de Israëlische minister Katz tot persona non grata verklaren
3. [4743] Motie van het lid Dobbe over de aanvallen van Israël op Libanon ondubbelzinnig veroordelen
4. [4745] Motie van het lid Van Baarle c.s. over in internationaal verband pleiten voor het documenteren en voorkomen van straffel...
5. [4752] Motie van het lid Ergin over structureel overleg en co-creatie met vertegenwoordigers van moslimgemeenschappen bij de on...
**Suggested label:** _[TBD after review]_
---
## Component 7
**Current label:** Praktisch-bestuurlijke vs. idealistisch-procedurele oriëntatie
**Positive pole keywords:** ter, vervanging, dijk, maken, baarle, onderzoeken, mogelijk, onderzoek, laten, der
**Negative pole keywords:** ter, vervanging, dijk, der, maken, gewijzigd, mogelijk, laten, uitspreken, onderzoeken
**Top 5 positive-pole motions:**
1. [23285] Motie van de leden Nijhof-Leeuw en Grinwis over een compleet overzicht van de algemene kosten van producten van eigen bo...
2. [23446] Motie van het lid Krul c.s. over een handreiking voor scholen om met papieren schoolboeken de basisvaardigheden te verbe...
3. [22776] Motie van het lid Flach over een invoeringstoets waarin wordt ingegaan op de effectiviteit en de gevolgen voor werknemer...
4. [23376] Motie van de leden Grinwis en Olger van Dijk over bepalen of de A2 Deil-Den Bosch-Vught in aanmerking komt voor alternat...
5. [22758] Voorstel tot wijziging van de lijst van controversiële onderwerpen van VVD
**Top 5 negative-pole motions:**
1. [23422] Gewijzigde motie van het lid Kostic over een landelijk stookverbod op basis van de stookwijzer instellen (t.v.v. 30175-4...
2. [2251] Wijziging van de begrotingsstaten van het Ministerie van Onderwijs, Cultuur en Wetenschap (VIII) voor het jaar 2025 (wij...
3. [23228] Gewijzigde motie van het lid El Abassi over in navolging van Denemarken de verscheuring dan wel verbranding van erkende ...
4. [22886] Motie van de leden Beckerman en Bushoff over nieuwe gas- en oliewinning of uitbreiding daarvan tegenhouden, tenzij omwon...
5. [23149] Motie van de leden Dobbe en Dassen over spoedig schadevergoedingen uitbetalen aan door chroom-6 getroffen (oud-)medewerk...
**Suggested label:** _[TBD after review]_
---
## Component 8
**Current label:** Pro-Europese en cosmopolitische oriëntatie versus binnenlandse focus
**Positive pole keywords:** ter, vervanging, dijk, onderzoeken, laten, der, middelen, mogelijk, maken, baarle
**Negative pole keywords:** ter, dijk, vervanging, maken, der, mogelijk, gewijzigd, onderzoek, onderzoeken, uitspreken
**Top 5 positive-pole motions:**
1. [9998] Motie van de leden Paternotte en Boswijk over in EU- en NAVO-verband actief pleiten voor militaire mobiliteit als toppri...
2. [9967] Amendement van het lid Flach ter vervanging van nr. 3 over middelen voor praktijkonderzoek en ondersteuning van telers i...
3. [22678] Motie van het lid Sneller over op de kortst mogelijke termijn duidelijkheid geven over de vierde ronde van het Nationaal...
4. [10369] Motie van het lid Dassen over concrete stappen uitwerken om te voldoen aan de Europese afspraak om 35% van het defensiem...
5. [10370] Motie van het lid Dassen over zich actief inspannen voor de oprichting van een Europees instituut voor research and tech...
**Top 5 negative-pole motions:**
1. [2002] Motie van het lid El Abassi over uitspreken dat pas recht gedaan kan worden aan het leed van gedupeerde gezinnen als all...
2. [23891] Gewijzigde motie van het lid Van Hijum c.s. over het Presidium verzoeken om onderzoek te doen naar versterking van de on...
3. [23915] Motie van het lid Kops over het zonder opzegtermijn kunnen opzeggen van een vraagresponsovereenkomst indien dat vereist ...
4. [22983] Motie van de leden Agema en Van der Plas over de coronastrategie betrekken bij de opheldering van de aanhoudende overste...
5. [2314] Amendement van het lid Flach ter vervanging van nr. 22 over motiverings- en overlegeisen voor instructieregels
**Suggested label:** _[TBD after review]_
---
## Component 9
**Current label:** Institutionele hervorming versus pragmatisch bestuur
**Positive pole keywords:** ter, dijk, vervanging, der, maken, onderzoeken, mogelijk, onderzoek, laten, gewijzigd
**Negative pole keywords:** ter, vervanging, baarle, uitspreken, maken, laten, dijk, mogelijk, gewijzigd, onderzoek
**Top 5 positive-pole motions:**
1. [1332] Motie van de leden Inge van Dijk en Grinwis over nadere regels ten behoeve van de naleving van de Financiële-verhoudings...
2. [24684] Motie van het lid Flach c.s. over in Europees verband het gesprek aangaan over mogelijkheden om arbeidsmigratie binnen d...
3. [23816] Motie van het lid Flach c.s. over met het ministerie van OCW en veldpartijen het scenario van een nieuwe opleidingsplek ...
4. [23508] Motie van het lid Diederik van Dijk c.s. over een actieplan om het verkeerd gebruik van hallucinerende geneesmiddelen te...
5. [3707] Motie van de leden Tseggai en Ceder over voorstellen voor een dekking van de structurele oplossing voor de problemen bij...
**Top 5 negative-pole motions:**
1. [656] Motie van het lid Kostic over een nationaal uniform moratorium met een verbod op uitbreiding, verplaatsing en nieuwbouw ...
2. [1211] Motie van de leden Tseggai en Koops over een verbod op het adverteren en in zoekmachines vindbaar maken van gokwebsites
3. [1659] Nader gewijzigd amendement van het lid Sneller ter vervanging van nr. 50 over verduidelijking van de gronden voor voorlo...
4. [3593] Gewijzigd amendement van het lid Stultiens c.s. ter vervanging van nr. 14 over een grondslag voor gemeenten om een leegs...
5. [3744] Motie van de leden Kathmann en Van den Berg over ondubbelzinnig opkomen voor het recht op end-to-endencryptie en onlinep...
**Suggested label:** _[TBD after review]_
---
## Component 10
**Current label:** Kritiek op overheidsbemoeienis versus bestuurlijke effectiviteit
**Positive pole keywords:** ter, vervanging, baarle, dijk, maken, onderzoeken, mogelijk, laten, onderzoek, gewijzigd
**Negative pole keywords:** dijk, ter, vervanging, der, maken, laten, mogelijk, onderzoeken, gewijzigd, onderzoek
**Top 5 positive-pole motions:**
1. [1264] Motie van de leden Kisteman en Rooderkerk over minder tijdsintensieve inspectiebezoeken
2. [23994] Motie van het lid Ergin over uitspreken dat integratie geldt voor nieuwkomers en niet voor Nederlanders die al generatie...
3. [1241] Motie van het lid Dijk over ouders op verzoek binnen één maand hun persoonlijke dossier verstrekken
4. [2464] Motie van het lid Dijk over de tegemoetkoming voor arbeidsongeschikten in stand houden
5. [23683] Gewijzigd amendement van het lid Vondeling ter vervanging van nr. 7 over het verlagen van de leeftijd voor een kindgespr...
**Top 5 negative-pole motions:**
1. [23785] Motie van het lid De Kort over het verkennen van een aangifteplicht voor scholen bij ernstige veiligheidsincidenten
2. [10003] Motie van de leden Daniëlle Jansen en Krul over gehoor geven aan de oproep van de EU Gezondheidsraad om auto's met kinde...
3. [23308] Motie van de leden Pierik en Van der Plas over soortgelijke stappen nemen als Frankrijk omtrent het braak laten liggen v...
4. [23585] Motie van het lid Ellian over het beloningsgeld voor tips ten aanzien van personen op de Nationale Opsporingslijst subst...
5. [23544] Motie van de leden Michon-Derkzen en Eerdmans over het bewustzijn van de mogelijkheden uit de Tijdelijke Wet Bestuurlijk...
**Suggested label:** _[TBD after review]_
---

@ -0,0 +1,56 @@
# Session: svd_axis_consistency_fix
Updated: 2026-04-13T23:08:19Z
## Goal
Ensure SVD components tab and compass show consistent party positions by using aligned PCA positions for components 1-2.
## Constraints
- Right-wing parties (PVV, FVD, JA21, SGP) must appear on RIGHT side of all axes in both visualizations
- SVD labels should reflect voting patterns, not semantic content
- Components 1-2 use aligned PCA; Components 3-10 use raw SVD values
## Progress
### Done
- [x] Fix SVD axis label alignment (removed static left_pole/right_pole, derive from runtime flip)
- [x] Fix score mismatch in tijdtraject view (components 3-10 use per-window scores, not Procrustes-aligned)
- [x] Fix PCA alignment consistency between compass and SVD components tab
- [x] Update all 10 component labels based on motion analysis
- [x] Add pool-based motion assignment (10 motions per component)
- [x] Add SVD axis alignment and label consistency tests
### In Progress
- (none)
### Blocked
- (none)
## Key Decisions
- **Components 1-2 use aligned PCA positions**: Consistent with compass visualization, derived from `load_positions()`
- **Components 3-10 use raw SVD scores**: Per-window flip handles orientation, Procrustes not needed
- **New helper `_get_aligned_party_coords()`**: Converts aligned MP positions to party centroids for components 1-2
## Next Steps
1. Run visual verification to confirm compass and SVD tab show consistent party orderings
2. Consider adding tests for the new `_get_aligned_party_coords()` helper
3. Update any documentation that references the old behavior
## File Operations
### Read
- `explorer.py` (components tab, load_positions, trajectory rendering)
- `analysis/political_axis.py` (PCA alignment, compute_party_centroids)
- `analysis/config.py` (SVD_THEMES)
- `analysis/svd_labels.py` (label derivation)
### Modified
- `explorer.py` - Added `_get_aligned_party_coords()`, updated component 1-2 to use aligned positions
## Critical Context
- **Commit 823df6f**: Removed static left_pole/right_pole, fixed tijdtraject score mismatch
- **Commit 12936c5**: Use aligned PCA for components 1-2 (consistent with compass)
- **Commit 036c3f9**: Extended aligned PCA to all SVD components 1-10
- **Commit 3a67100**: Use aligned PCA scores for time trajectory view
- **Related docs**: `docs/solutions/ui-bugs/svd-axis-pole-labels-incorrect-after-flip.md`
## Working Set
- Branch: `main`
- Key files: `explorer.py`, `analysis/config.py`, `analysis/svd_labels.py`, `tests/test_svd_axis_alignment.py`
Loading…
Cancel
Save