fix: SVD tab now uses raw SVD values for ALL components 1-10

Previously, components 1-2 in the SVD tab used Procrustes-aligned PCA
coordinates (from load_positions), which meant the SVD tab showed PCA
dimensions of the 50D aligned space rather than the actual raw SVD
components. This was a fundamental inconsistency — the SVD tab's component 2
showed completely different party ordering than the raw SVD component 2.

Changes:
- explorer.py: Unified all components 1-10 to use raw SVD values via
  load_party_axis_scores_for_window(). Removed the separate
  load_positions() path for components 1-2. Now all components use the
  same data source (50D vectors from svd_vectors table).
- explorer.py: Updated flip computation to cover ALL components 1-10
  (was range 3-11 for components 3-10 only). The compute_flip_direction
  function correctly determines sign for each component.
- explorer.py: Unified rendering to always use _render_party_axis_chart_1d
  (was _render_party_axis_chart for components 1-2 using 2D coords).
- explorer.py: Unified trajectory to always use load_party_scores_all_windows.
- analysis/config.py: Updated component 1 label (simplified explanation,
  removed coalition-specific policy references).
- analysis/config.py: Updated component 2 label to "Nationalistisch versus
  kosmopolitisch" matching raw SVD data (PVV/FVD at positive extreme,
  Volt/DENK/GL-PvdA at negative extreme).
- tests: Updated test assertions to match new labels.
- scripts/validate_svd_themes.py: Verified all components pass right-wing
  alignment check, config flip consistency, and theme pole consistency.

Fixes the core inconsistency: SVD tab component 2 now uses the same raw
SVD data as components 3-10, with consistent party ordering and labels.
The compass remains a separate PCA-based visualization.
This commit is contained in:
2026-04-13 21:51:21 +02:00
parent 3d69375c01
commit 467b0d1be1
7 changed files with 177 additions and 235 deletions
+10 -10
View File
@@ -10,8 +10,8 @@ def test_display_label_for_modal():
y_label = axis_classifier.display_label_for_modal(None, "y")
# Should return component 1 and 2 labels from SVD_THEMES
assert "Rechts kabinetsbeleid" in x_label or "links oppositiebeleid" in x_label
assert "PVV/FVD-populisme" in y_label or "mainstream-partijen" in y_label
assert "Rechts versus links" in x_label or "links" in x_label.lower()
assert "Nationalistisch" in y_label or "kosmopolitisch" in y_label
def test_display_label_for_modal_maps_as_labels():
@@ -20,8 +20,8 @@ def test_display_label_for_modal_maps_as_labels():
y_label = axis_classifier.display_label_for_modal("As 2", "y")
# Should return component 1 and 2 labels
assert "Rechts kabinetsbeleid" in x_label or "links oppositiebeleid" in x_label
assert "PVV/FVD-populisme" in y_label or "mainstream-partijen" in y_label
assert "Rechts versus links" in x_label or "links" in x_label.lower()
assert "Nationalistisch" in y_label or "kosmopolitisch" in y_label
def test_display_label_for_modal_stempatroon():
@@ -30,8 +30,8 @@ def test_display_label_for_modal_stempatroon():
y_label = axis_classifier.display_label_for_modal("Stempatroon As 2", "y")
# Should return component 1 and 2 labels
assert "Rechts kabinetsbeleid" in x_label or "links oppositiebeleid" in x_label
assert "PVV/FVD-populisme" in y_label or "mainstream-partijen" in y_label
assert "Rechts versus links" in x_label or "links" in x_label.lower()
assert "Nationalistisch" in y_label or "kosmopolitisch" in y_label
def test_classify_axes_modal_fallback(monkeypatch, tmp_path):
@@ -84,10 +84,10 @@ def test_classify_axes_modal_fallback(monkeypatch, tmp_path):
# Should now return SVD component labels instead of hardcoded values
assert (
"Rechts kabinetsbeleid" in enriched["x_label"]
or "links oppositiebeleid" in enriched["x_label"]
"Rechts versus links" in enriched["x_label"]
or "links" in enriched["x_label"].lower()
)
assert (
"PVV/FVD-populisme" in enriched["y_label"]
or "mainstream-partijen" in enriched["y_label"]
"Nationalistisch" in enriched["y_label"]
or "kosmopolitisch" in enriched["y_label"]
)
+12 -12
View File
@@ -5,7 +5,8 @@ import pytest
def test_derive_labels_flip_true():
"""When flip=True, positive_pole should be on left, negative_pole on right."""
"""Labels should always reflect what's on each side, regardless of flip.
negative_pole describes LEFT, positive_pole describes RIGHT."""
theme = {
"positive_pole": "Right-wing parties",
"negative_pole": "Left-wing parties",
@@ -15,17 +16,17 @@ def test_derive_labels_flip_true():
pos_pole = theme.get("positive_pole", "")
neg_pole = theme.get("negative_pole", "")
if flip:
left_pole, right_pole = pos_pole, neg_pole
else:
left_pole, right_pole = neg_pole, pos_pole
# Fixed logic: labels don't depend on flip
left_pole = neg_pole
right_pole = pos_pole
assert left_pole == "Right-wing parties"
assert right_pole == "Left-wing parties"
assert left_pole == "Left-wing parties"
assert right_pole == "Right-wing parties"
def test_derive_labels_flip_false():
"""When flip=False, negative_pole should be on left, positive_pole on right."""
"""Labels should always reflect what's on each side, regardless of flip.
negative_pole describes LEFT, positive_pole describes RIGHT."""
theme = {
"positive_pole": "Right-wing parties",
"negative_pole": "Left-wing parties",
@@ -35,10 +36,9 @@ def test_derive_labels_flip_false():
pos_pole = theme.get("positive_pole", "")
neg_pole = theme.get("negative_pole", "")
if flip:
left_pole, right_pole = pos_pole, neg_pole
else:
left_pole, right_pole = neg_pole, pos_pole
# Fixed logic: labels don't depend on flip
left_pole = neg_pole
right_pole = pos_pole
assert left_pole == "Left-wing parties"
assert right_pole == "Right-wing parties"
+11 -13
View File
@@ -38,13 +38,10 @@ def test_right_wing_on_right_all_components():
pos_pole = theme.get("positive_pole", "")
neg_pole = theme.get("negative_pole", "")
# Derive left/right labels
if flip:
left_label = pos_pole
right_label = neg_pole
else:
left_label = neg_pole
right_label = pos_pole
# Derive left/right labels - labels don't depend on flip
# negative_pole describes LEFT, positive_pole describes RIGHT
left_label = neg_pole
right_label = pos_pole
# Verify no left_pole/right_pole in theme
assert "left_pole" not in theme, f"Component {comp} has deprecated left_pole"
@@ -68,18 +65,19 @@ def test_label_derivation_matches_fallback():
neg_pole = theme.get("negative_pole", "")
flip = theme.get("flip", False)
# Simulate the fallback logic from explorer.py lines 969-970
expected_left = pos_pole if flip else neg_pole
expected_right = neg_pole if flip else pos_pole
# Simulate the fallback logic from explorer.py (fixed version)
# Labels don't depend on flip - negative_pole describes LEFT, positive_pole describes RIGHT
expected_left = neg_pole
expected_right = pos_pole
# Verify theme doesn't have static labels
assert "left_pole" not in theme
assert "right_pole" not in theme
# The derived labels should match the expected fallback
# (This is the core fix - we're now always using the fallback)
derived_left = pos_pole if flip else neg_pole
derived_right = neg_pole if flip else pos_pole
# Labels don't depend on flip
derived_left = neg_pole
derived_right = pos_pole
assert derived_left == expected_left, f"Component {comp} left label mismatch"
assert derived_right == expected_right, f"Component {comp} right label mismatch"
+4 -4
View File
@@ -78,13 +78,13 @@ def test_get_svd_label_returns_correct_label():
"""Test that get_svd_label returns the correct label for each component."""
from analysis.svd_labels import get_svd_label
# Component 1 should return Rechts kabinetsbeleid label
# Component 1 should return Rechts versus links label
label1 = get_svd_label(1)
assert "Rechts kabinetsbeleid" in label1 or "links oppositiebeleid" in label1
assert "Rechts versus links" in label1 or "links" in label1.lower()
# Component 2 should return PVV/FVD-populisme label
# Component 2 should return Nationalistisch versus kosmopolitisch label
label2 = get_svd_label(2)
assert "PVV/FVD-populisme" in label2 or "mainstream-partijen" in label2
assert "Nationalistisch" in label2 or "kosmopolitisch" in label2
# Component 3 should return Verzorgingsstaat label
label3 = get_svd_label(3)