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
main
Sven Geboers 3 weeks ago
parent b1847f8d07
commit 4d6c777d54
  1. 55
      analysis/political_axis.py
  2. 32
      tests/test_axis_label_fallback.py
  3. 12
      tests/test_svd_labels.py

@ -1,13 +1,13 @@
"""political_axis.py — Project MP SVD vectors onto an ideological axis. """political_axis.py — Project MP SVD vectors onto an ideological axis.
Two modes: Two modes:
1. PCA mode (default): compute the first principal component of all MP SVD 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 vectors for a window and project each MP onto it. The sign is arbitrary
but consistent within a window. but consistent within a window.
2. Anchor mode: define the axis as the vector from the centroid of 2. Anchor mode: define the axis as the vector from the centroid of
``left_parties`` to the centroid of ``right_parties``. Project all MPs ``left_parties`` to the centroid of ``right_parties``. Project all MPs
onto this normalised anchor axis. onto this normalised anchor axis.
Both modes return a dict mapping mp_name scalar score for the given window. 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 except Exception: # pragma: no cover - allow importing module in lightweight test envs
duckdb = None # type: ignore 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__) _logger = logging.getLogger(__name__)
@ -262,35 +269,13 @@ def compute_2d_axes(
} }
# Canonical party sets used for axis orientation (global and per-window). # Canonical party sets used for axis orientation (global and per-window).
# Defined outside the try-block so they're always in scope. # Use CANONICAL_LEFT/RIGHT from config for consistency with SVD components tab.
right_parties = { # X-axis: CANONICAL_RIGHT (right) vs CANONICAL_LEFT (left)
"PVV", # Y-axis: CANONICAL_LEFT (progressive) vs cons_parties (conservative)
"VVD", right_parties = CANONICAL_RIGHT
"FVD", left_parties = CANONICAL_LEFT
"BBB", cons_parties = CANONICAL_RIGHT # Conservative = right-leaning parties
"JA21", prog_parties = CANONICAL_LEFT # Progressive = left-leaning parties
"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",
}
# Ensure consistent left/right and progressive/conservative orientation # Ensure consistent left/right and progressive/conservative orientation
# by checking canonical party centroids and flipping axis signs if needed. # by checking canonical party centroids and flipping axis signs if needed.

@ -10,8 +10,12 @@ 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
assert "Rechts versus links" in x_label or "links" in x_label.lower() # SVD_THEMES[0] = "Economische sectorbelangen versus sociale welvaart"
assert "Nationalistisch" in y_label or "kosmopolitisch" in y_label # 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(): 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") x_label = axis_classifier.display_label_for_modal("As 1", "x")
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 # Should return component 1 and 2 labels from SVD_THEMES
assert "Rechts versus links" in x_label or "links" in x_label.lower() assert (
assert "Nationalistisch" in y_label or "kosmopolitisch" in y_label "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(): 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") x_label = axis_classifier.display_label_for_modal("Stempatroon As 1", "x")
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 # Should return component 1 and 2 labels from SVD_THEMES
assert "Rechts versus links" in x_label or "links" in x_label.lower() assert (
assert "Nationalistisch" in y_label or "kosmopolitisch" in y_label "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): 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): if not enriched or not isinstance(enriched, dict):
pytest.skip("classify_axes returned no enrichment in this environment") 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 ( assert (
"Rechts versus links" in enriched["x_label"] "Economische sectorbelangen" in enriched["x_label"]
or "links" in enriched["x_label"].lower() or "sectorbelangen" in enriched["x_label"].lower()
) )
assert ( assert (
"Nationalistisch" in enriched["y_label"] "Nationalistisch" in enriched["y_label"]
or "kosmopolitisch" in enriched["y_label"] or "nationalistisch" in enriched["y_label"].lower()
) )

@ -78,17 +78,17 @@ 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 Rechts versus links label # Component 1 should return "Economische sectorbelangen versus sociale welvaart"
label1 = get_svd_label(1) 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) 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) 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(): def test_compute_flip_direction_right_on_left():

Loading…
Cancel
Save