From 4d6c777d5447c7a61c720c8bd48232b9b54aa023 Mon Sep 17 00:00:00 2001 From: Sven Geboers Date: Mon, 13 Apr 2026 22:50:11 +0200 Subject: [PATCH] fix: use CANONICAL_LEFT/RIGHT in compass PCA for consistency with SVD components tab Previously the compass (political_axis.py) used hardcoded party sets that excluded Volt and PvdD, while the SVD components tab (svd_labels.py) used CANONICAL_LEFT/RIGHT which includes them. This caused inconsistencies in axis orientation where Volt appeared most left on the compass but PvdD appeared most left in the SVD components visualization. Changes: - Import CANONICAL_LEFT/RIGHT from config in political_axis.py - Replace hardcoded party sets with CANONICAL_LEFT/RIGHT for axis orientation - Update tests to match new SVD_THEMES labels --- analysis/political_axis.py | 55 +++++++++++-------------------- tests/test_axis_label_fallback.py | 32 +++++++++++------- tests/test_svd_labels.py | 12 +++---- 3 files changed, 46 insertions(+), 53 deletions(-) diff --git a/analysis/political_axis.py b/analysis/political_axis.py index 01719f7..ed337d2 100644 --- a/analysis/political_axis.py +++ b/analysis/political_axis.py @@ -1,13 +1,13 @@ """political_axis.py — Project MP SVD vectors onto an ideological axis. Two modes: - 1. PCA mode (default): compute the first principal component of all MP SVD - vectors for a window and project each MP onto it. The sign is arbitrary - but consistent within a window. + 1. PCA mode (default): compute the first principal component of all MP SVD + vectors for a window and project each MP onto it. The sign is arbitrary + but consistent within a window. - 2. Anchor mode: define the axis as the vector from the centroid of - ``left_parties`` to the centroid of ``right_parties``. Project all MPs - onto this normalised anchor axis. + 2. Anchor mode: define the axis as the vector from the centroid of + ``left_parties`` to the centroid of ``right_parties``. Project all MPs + onto this normalised anchor axis. Both modes return a dict mapping mp_name → scalar score for the given window. """ @@ -24,6 +24,13 @@ try: except Exception: # pragma: no cover - allow importing module in lightweight test envs duckdb = None # type: ignore +# Import canonical party sets from config for consistent orientation with SVD components tab +try: + from .config import CANONICAL_LEFT, CANONICAL_RIGHT +except Exception: # pragma: no cover - fallback for test environments + CANONICAL_LEFT: frozenset = frozenset() + CANONICAL_RIGHT: frozenset = frozenset() + _logger = logging.getLogger(__name__) @@ -262,35 +269,13 @@ def compute_2d_axes( } # Canonical party sets used for axis orientation (global and per-window). - # Defined outside the try-block so they're always in scope. - right_parties = { - "PVV", - "VVD", - "FVD", - "BBB", - "JA21", - "Nieuw Sociaal Contract", - } - left_parties = {"SP", "PvdA", "GL", "GroenLinks", "GroenLinks-PvdA", "DENK"} - cons_parties = { - "PVV", - "VVD", - "FVD", - "CDA", - "SGP", - "BBB", - "JA21", - "Nieuw Sociaal Contract", - } - prog_parties = { - "GL", - "GroenLinks", - "PvdA", - "PvdD", - "SP", - "GroenLinks-PvdA", - "DENK", - } + # Use CANONICAL_LEFT/RIGHT from config for consistency with SVD components tab. + # X-axis: CANONICAL_RIGHT (right) vs CANONICAL_LEFT (left) + # Y-axis: CANONICAL_LEFT (progressive) vs cons_parties (conservative) + right_parties = CANONICAL_RIGHT + left_parties = CANONICAL_LEFT + cons_parties = CANONICAL_RIGHT # Conservative = right-leaning parties + prog_parties = CANONICAL_LEFT # Progressive = left-leaning parties # Ensure consistent left/right and progressive/conservative orientation # by checking canonical party centroids and flipping axis signs if needed. diff --git a/tests/test_axis_label_fallback.py b/tests/test_axis_label_fallback.py index e6cb4d5..97f505a 100644 --- a/tests/test_axis_label_fallback.py +++ b/tests/test_axis_label_fallback.py @@ -10,8 +10,12 @@ 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 versus links" in x_label or "links" in x_label.lower() - assert "Nationalistisch" in y_label or "kosmopolitisch" in y_label + # SVD_THEMES[0] = "Economische sectorbelangen versus sociale welvaart" + # SVD_THEMES[1] = "Nationalistische versus multilateralistische oriëntatie" + assert ( + "Economische sectorbelangen" in x_label or "sectorbelangen" in x_label.lower() + ) + assert "Nationalistisch" in y_label or "nationalistisch" in y_label.lower() def test_display_label_for_modal_maps_as_labels(): @@ -19,9 +23,11 @@ def test_display_label_for_modal_maps_as_labels(): x_label = axis_classifier.display_label_for_modal("As 1", "x") y_label = axis_classifier.display_label_for_modal("As 2", "y") - # Should return component 1 and 2 labels - assert "Rechts versus links" in x_label or "links" in x_label.lower() - assert "Nationalistisch" in y_label or "kosmopolitisch" in y_label + # Should return component 1 and 2 labels from SVD_THEMES + assert ( + "Economische sectorbelangen" in x_label or "sectorbelangen" in x_label.lower() + ) + assert "Nationalistisch" in y_label or "nationalistisch" in y_label.lower() def test_display_label_for_modal_stempatroon(): @@ -29,9 +35,11 @@ def test_display_label_for_modal_stempatroon(): x_label = axis_classifier.display_label_for_modal("Stempatroon As 1", "x") y_label = axis_classifier.display_label_for_modal("Stempatroon As 2", "y") - # Should return component 1 and 2 labels - assert "Rechts versus links" in x_label or "links" in x_label.lower() - assert "Nationalistisch" in y_label or "kosmopolitisch" in y_label + # Should return component 1 and 2 labels from SVD_THEMES + assert ( + "Economische sectorbelangen" in x_label or "sectorbelangen" in x_label.lower() + ) + assert "Nationalistisch" in y_label or "nationalistisch" in y_label.lower() def test_classify_axes_modal_fallback(monkeypatch, tmp_path): @@ -82,12 +90,12 @@ def test_classify_axes_modal_fallback(monkeypatch, tmp_path): if not enriched or not isinstance(enriched, dict): pytest.skip("classify_axes returned no enrichment in this environment") - # Should now return SVD component labels instead of hardcoded values + # Should now return SVD component labels from SVD_THEMES assert ( - "Rechts versus links" in enriched["x_label"] - or "links" in enriched["x_label"].lower() + "Economische sectorbelangen" in enriched["x_label"] + or "sectorbelangen" in enriched["x_label"].lower() ) assert ( "Nationalistisch" in enriched["y_label"] - or "kosmopolitisch" in enriched["y_label"] + or "nationalistisch" in enriched["y_label"].lower() ) diff --git a/tests/test_svd_labels.py b/tests/test_svd_labels.py index 46725b5..d58dd50 100644 --- a/tests/test_svd_labels.py +++ b/tests/test_svd_labels.py @@ -78,17 +78,17 @@ 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 versus links label + # Component 1 should return "Economische sectorbelangen versus sociale welvaart" label1 = get_svd_label(1) - assert "Rechts versus links" in label1 or "links" in label1.lower() + assert "Economische sectorbelangen" in label1 or "sectorbelangen" in label1.lower() - # Component 2 should return Nationalistisch versus kosmopolitisch label + # Component 2 should return "Nationalistische versus multilateralistische oriëntatie" label2 = get_svd_label(2) - assert "Nationalistisch" in label2 or "kosmopolitisch" in label2 + assert "Nationalistisch" in label2 or "nationalistisch" in label2.lower() - # Component 3 should return Verzorgingsstaat label + # Component 3 should return "Verzorgingsstaat versus defensie en nationale veiligheid" label3 = get_svd_label(3) - assert "Verzorgingsstaat" in label3 or "Marktwerking" in label3 + assert "Verzorgingsstaat" in label3 or "verzorgingsstaat" in label3.lower() def test_compute_flip_direction_right_on_left():