You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
motief/docs/solutions/ui-bugs/svd-axis-pole-labels-incorr...

6.7 KiB

title date module problem_type component symptoms root_cause resolution_type severity tags
SVD axis labels: derive left/right from runtime flip, not static fields 2026-04-12 analysis ui_bug analysis [SVD axis labels showed wrong orientation for components where runtime flip differed from static flip value Right-wing parties (PVV, FVD) appeared on the LEFT side of axes despite being canonical right parties Components 3-10 in tijdtraject view showed scores incomparable with single-window view] logic_error code_fix high [svd axis-labels pole-labels parliamentary-explorer left-right-axis procrustes]

SVD Axis Labels: Derive Left/Right from Runtime Flip, Not Static Fields

Problem

SVD axis pole labels showed wrong orientation after the runtime flip mechanism was applied. Right-wing parties appeared on the LEFT side of axes despite being canonical right parties. Additionally, components 3-10 in the tijdtraject (time trajectory) view showed party scores that were incomparable with the single-window view.

Symptoms

  • Axis labels like "← PVV en FVD — soevereiniteit en anti-establishment" appeared on the left side when they should be on the right
  • The flip mechanism (compute_flip_direction) correctly negated party scores, but labels were tied to static pre-computed fields
  • Components 3-10 in build_svd_components_tab used Procrustes-aligned scores that were rotated by the component 1-2 alignment, making them meaningless

What Didn't Work

The 2026-04-05 fix added static left_pole/right_pole fields to SVD_THEMES, pre-computed based on the static flip value in config. This failed because:

  1. compute_flip_direction() determines flip at runtime by comparing mean scores of canonical right vs left parties against actual voting data
  2. The static flip value in config could differ from the runtime result when voting patterns shift
  3. When runtime flip differed from the static config, the pre-computed left_pole/right_pole pointed to the wrong side

Root Cause Detail: Dynamic Flip Override

The bug was compounded by explorer.py lines 2636-2649, where compute_flip_direction() dynamically overwrites SVD_THEMES[comp]["flip"] for all components (1-10) at runtime:

# explorer.py lines 2677-2690
for comp in range(1, 11):
    flip = compute_flip_direction(comp, party_scores)
    if comp in SVD_THEMES:
        SVD_THEMES[comp]["flip"] = flip

When PVV/FVD had negative scores on component 2:

  1. compute_flip_direction(2, party_scores) returned True (right parties have lower mean)
  2. SVD_THEMES[2]["flip"] was overwritten from False to True
  3. With flip=True, scores were negated (PVV/FVD became positive → appeared on RIGHT)
  4. But the label derivation logic (explorer.py lines 954-957, 1073-1077) was backwards:
    left_label = theme.get("left_pole", pos_pole if flip else neg_pole)
    right_label = theme.get("right_pole", neg_pole if flip else pos_pole)
    
    When flip=True, left_label was set to pos_pole (which described PVV/FVD), but PVV/FVD were now on the RIGHT side after negation.

This meant labels were misaligned with the actual data whenever the runtime flip differed from the static config flip.

Solution

Bug 1: Label derivation

Removed static left_pole/right_pole from all 10 SVD_THEMES entries in analysis/config.py. Labels are now always derived at render time from positive_pole/negative_pole and the runtime flip direction:

# analysis/svd_labels.py — derive left/right from runtime flip
if flip:
    left_pole, right_pole = pos_pole, neg_pole  # flip=True: positive on left
else:
    left_pole, right_pole = neg_pole, pos_pole  # flip=False: negative on left

The key insight: negative_pole always describes what's on the LEFT, positive_pole always describes what's on the RIGHT — regardless of flip. The flip only affects which raw SVD direction maps to left vs right.

Bug 2: Score mismatch in tijdtraject view

Changed components 3-10 in build_svd_components_tab from load_party_scores_all_windows_aligned() to load_party_scores_all_windows():

# explorer.py — components 3-10 use per-window scores (not Procrustes-aligned)
party_scores_by_window = load_party_scores_all_windows(db_path, all_windows)

Why: Procrustes alignment rotates the full 50-dim vector space to align components 1-2 across windows, but this also transforms components 3-10, making their scores incomparable with the single-window view. Per-window flip computation already handles orientation alignment for components 3-10.

Bug 3: Config as canonical SVD_THEMES source

Updated analysis/svd_labels.py to prefer analysis.config as the canonical source for SVD_THEMES, falling back to explorer only when config is unavailable. Config is intentionally lightweight and free of heavy runtime dependencies (duckdb, plotly).

Prevention: Tests added

Added tests/test_svd_axis_alignment.py with 3 tests:

  • test_right_wing_on_right_all_components: Verifies canonical right parties appear on right for all 10 components
  • test_label_derivation_matches_fallback: Verifies label derivation logic
  • test_config_no_deprecated_fields: Asserts no left_pole/right_pole in config

Run with: .venv/bin/python -m pytest tests/test_svd_axis_alignment.py -v

Why This Works

The flip direction is determined by comparing canonical right vs left party average scores against actual voting data. The label derivation follows a simple rule: negative_pole = left, positive_pole = right. Since the flip operation moves the canonical right parties to the positive side, the labels always match.

For components 3-10, per-window scores are computed independently with per-window flip, so they remain comparable with single-window views. Procrustes only needs to align components 1-2 (the political compass axes).

Prevention

  • Never add static left_pole/right_pole fields to SVD_THEMES — derive them at render time
  • Run tests/test_svd_axis_alignment.py after any SVD recomputation
  • Components 3-10 in tijdtraject view must use load_party_scores_all_windows(), not the aligned variant
  • The key invariant: negative_pole = LEFT, positive_pole = RIGHT — flip only determines which raw direction maps to which side
  • analysis/config.py — SVD_THEMES (no left_pole/right_pole)
  • analysis/svd_labels.py_get_svd_themes() preferring config source
  • explorer.py — label derivation in trajectory rendering, component 3-10 scoring fix
  • tests/test_svd_axis_alignment.py — new tests validating alignment
  • scripts/validate_svd_themes.py — validation hook (updated to not expect left_pole/right_pole)