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
This commit is contained in:
2026-04-13 23:59:50 +02:00
parent 3a6710091a
commit cf549dcc1c
8 changed files with 1030 additions and 89 deletions
@@ -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`)