From 823df6f9ee8549763d5aaa86fa70015d35ff9a5d Mon Sep 17 00:00:00 2001 From: Sven Geboers Date: Sun, 12 Apr 2026 21:02:28 +0200 Subject: [PATCH] fix: resolve SVD axis label alignment and score mismatch in tijdtraject view Two related bugs fixed: 1. Label alignment: Removed static left_pole/right_pole from SVD_THEMES entries. These labels assumed a fixed flip direction but could mismatch with runtime flip computation, causing right-wing parties to appear on the wrong side. Labels are now always derived from positive_pole, negative_pole, and the runtime flip direction. 2. Score mismatch: Changed tijdtraject view for components 3-10 from load_party_scores_all_windows_aligned() to load_party_scores_all_windows(). Procrustes alignment rotates the full 50-dim vector space to align components 1-2, 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 these components. Also updated svd_labels.py to prefer analysis.config as the canonical source for SVD_THEMES, falling back to explorer only when config is unavailable. --- analysis/config.py | 20 -------------------- analysis/svd_labels.py | 22 +++++++++++++++++++--- explorer.py | 25 +++++++++++-------------- scripts/validate_svd_themes.py | 13 +++---------- 4 files changed, 33 insertions(+), 47 deletions(-) diff --git a/analysis/config.py b/analysis/config.py index 4bc0e88..5694675 100644 --- a/analysis/config.py +++ b/analysis/config.py @@ -80,8 +80,6 @@ SVD_THEMES: dict[int, dict[str, str]] = { ), "positive_pole": "Rechts: PVV, VVD, NSC, BBB, JA21 — kabinetsbeleid, defensie en restricties", "negative_pole": "Links: GroenLinks-PvdA, SP, PvdD, Volt, DENK — oppositie, zorg en multilateraal", - "left_pole": "Links: GroenLinks-PvdA, SP, PvdD, Volt, DENK — oppositie, zorg en multilateraal", - "right_pole": "Rechts: PVV, VVD, NSC, BBB, JA21 — kabinetsbeleid, defensie en restricties", "flip": False, }, 2: { @@ -97,8 +95,6 @@ SVD_THEMES: dict[int, dict[str, str]] = { ), "positive_pole": "PVV en FVD — soevereiniteit en anti-establishment", "negative_pole": "Overige partijen: VVD, CDA, SGP, ChristenUnie, GroenLinks-PvdA, D66, Volt, BBB", - "left_pole": "Overige partijen: VVD, CDA, SGP, ChristenUnie, GroenLinks-PvdA, D66, Volt, BBB", - "right_pole": "PVV en FVD — soevereiniteit en anti-establishment", "flip": False, }, 3: { @@ -116,8 +112,6 @@ SVD_THEMES: dict[int, dict[str, str]] = { ), "positive_pole": "Pro-verzorgingsstaat: SP, PvdD, GroenLinks-PvdA (anti-bezuinigingen)", "negative_pole": "Marktliberaal en fiscaal conservatief: VVD, D66, CDA, SGP, BBB", - "left_pole": "Marktliberaal en fiscaal conservatief: VVD, D66, CDA, SGP, BBB", - "right_pole": "Pro-verzorgingsstaat: SP, PvdD, GroenLinks-PvdA (anti-bezuinigingen)", "flip": True, }, 4: { @@ -132,8 +126,6 @@ SVD_THEMES: dict[int, dict[str, str]] = { ), "positive_pole": "D66, CDA, JA21 — moties met brede steun", "negative_pole": "NSC, BBB — moties met andere focus", - "left_pole": "NSC, BBB — moties met andere focus", - "right_pole": "D66, CDA, JA21 — moties met brede steun", "flip": True, }, 5: { @@ -150,8 +142,6 @@ SVD_THEMES: dict[int, dict[str, str]] = { ), "positive_pole": "Christelijk-sociaal: ChristenUnie, SGP, CDA, NSC — gemeenschap en vrijwilligers", "negative_pole": "Progressief-individueel: SP, VVD, GL-PvdA, PvdD, Volt — individuele rechten", - "left_pole": "Progressief-individueel: SP, VVD, GL-PvdA, PvdD, Volt — individuele rechten", - "right_pole": "Christelijk-sociaal: ChristenUnie, SGP, CDA, NSC — gemeenschap en vrijwilligers", "flip": False, }, 6: { @@ -168,8 +158,6 @@ SVD_THEMES: dict[int, dict[str, str]] = { ), "positive_pole": "Restrictief migratiebeleid: PVV, JA21, BBB, CDA, ChristenUnie, VVD, SGP, FVD, DENK", "negative_pole": "Progressieve inclusie: SP, PvdD, D66, GL-PvdA, Volt — klimaat en diversiteit", - "left_pole": "Progressieve inclusie: SP, PvdD, D66, GL-PvdA, Volt — klimaat en diversiteit", - "right_pole": "Restrictief migratiebeleid: PVV, JA21, BBB, CDA, ChristenUnie, VVD, SGP, FVD, DENK", "flip": False, }, 7: { @@ -188,8 +176,6 @@ SVD_THEMES: dict[int, dict[str, str]] = { ), "positive_pole": "Praktisch-bestuurlijk: ChristenUnie, Volt, SGP, DENK, SP", "negative_pole": "Ideologisch-principieel: GroenLinks-PvdA, VVD, FVD, JA21", - "left_pole": "Ideologisch-principieel: GroenLinks-PvdA, VVD, FVD, JA21", - "right_pole": "Praktisch-bestuurlijk: ChristenUnie, Volt, SGP, DENK, SP", "flip": True, }, 8: { @@ -207,8 +193,6 @@ SVD_THEMES: dict[int, dict[str, str]] = { ), "positive_pole": "Onderwijs en volksgezondheid: BBB, SGP, JA21 — vaccinatie, profielkeuze, woningbouw", "negative_pole": "Zorg en toegankelijkheid: SP, DENK, PvdD, Volt — coronaonderzoek, energie, basispakket", - "left_pole": "Zorg en toegankelijkheid: SP, DENK, PvdD, Volt — coronaonderzoek, energie, basispakket", - "right_pole": "Onderwijs en volksgezondheid: BBB, SGP, JA21 — vaccinatie, profielkeuze, woningbouw", "flip": False, }, 9: { @@ -227,8 +211,6 @@ SVD_THEMES: dict[int, dict[str, str]] = { ), "positive_pole": "Pragmatisch-bestuurlijk: SGP, ChristenUnie, DENK, SP — concrete oplossingen", "negative_pole": "Systeemhervorming: D66, JA21, PVV — idealistische beleidsposities", - "left_pole": "Systeemhervorming: D66, JA21, PVV — idealistische beleidsposities", - "right_pole": "Pragmatisch-bestuurlijk: SGP, ChristenUnie, DENK, SP — concrete oplossingen", "flip": True, }, 10: { @@ -247,8 +229,6 @@ SVD_THEMES: dict[int, dict[str, str]] = { ), "positive_pole": "Kritisch op overheidsbemoeienis: DENK, SP, PvdD — minder inspectielast en lastenverlichting", "negative_pole": "Pro-regulering: GroenLinks-PvdA, CDA, SGP — veiligheid, naleving en handhaving", - "left_pole": "Pro-regulering: GroenLinks-PvdA, CDA, SGP — veiligheid, naleving en handhaving", - "right_pole": "Kritisch op overheidsbemoeienis: DENK, SP, PvdD — minder inspectielast en lastenverlichting", "flip": True, }, } diff --git a/analysis/svd_labels.py b/analysis/svd_labels.py index 80a72a5..9c758f8 100644 --- a/analysis/svd_labels.py +++ b/analysis/svd_labels.py @@ -34,12 +34,28 @@ def _get_svd_themes() -> Dict[int, Dict[str, str]]: if _svd_themes_cache is not None: return _svd_themes_cache + # Prefer the lightweight canonical source in analysis.config which is + # intentionally free of heavy runtime dependencies. Fall back to + # explorer.SVD_THEMES only when the config module is unavailable or + # doesn't expose SVD_THEMES. try: - # Import SVD_THEMES from explorer at runtime to avoid circular imports - # explorer.py now exports SVD_THEMES at module level + from analysis import config as _cfg + + _svd_themes_cache = getattr(_cfg, "SVD_THEMES", {}) or {} + if _svd_themes_cache: + return _svd_themes_cache + except Exception: + _logger.exception( + "Could not import analysis.config or read SVD_THEMES; falling back to explorer" + ) + + try: + # Import explorer at runtime as a last resort; explorer may pull in + # heavy dependencies (duckdb/plotly) so we only try this if config + # didn't provide the themes. import explorer - _svd_themes_cache = explorer.SVD_THEMES + _svd_themes_cache = getattr(explorer, "SVD_THEMES", {}) or {} return _svd_themes_cache except ImportError as e: _logger.warning("Could not import explorer.SVD_THEMES: %s", e) diff --git a/explorer.py b/explorer.py index 784dfb1..e67baf8 100644 --- a/explorer.py +++ b/explorer.py @@ -2741,10 +2741,13 @@ def build_svd_components_tab(db_path: str) -> None: party_scores[party] = [avg_x, avg_y] party_scores_by_window[window] = party_scores else: - # Use SVD vectors for components 3-10 with Procrustes alignment - party_scores_by_window = load_party_scores_all_windows_aligned( - db_path, all_windows - ) + # Use raw (non-aligned) SVD vectors for components 3-10. + # Procrustes alignment rotates the full 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 handles + # orientation alignment for these components. + party_scores_by_window = load_party_scores_all_windows(db_path, all_windows) _render_svd_time_trajectory( party_scores_by_window, @@ -2806,16 +2809,10 @@ def build_svd_components_tab(db_path: str) -> None: pos_pole = theme.get("positive_pole", "") if theme else "" neg_pole = theme.get("negative_pole", "") if theme else "" - # Use semantic left/right pole labels if available, otherwise compute from flip - semantic_left = theme.get("left_pole") if theme else None - semantic_right = theme.get("right_pole") if theme else None - if semantic_left and semantic_right: - left_pole, right_pole = semantic_left, semantic_right - left_motions, right_motions = ( - (pos_motions, neg_motions) if flip else (neg_motions, pos_motions) - ) - left_arrow, right_arrow = ("▲", "▼") if flip else ("▼", "▲") - elif flip: + # Derive left/right labels from flip direction + # flip=True: positive_pole on left, negative_pole on right + # flip=False: negative_pole on left, positive_pole on right + if flip: left_pole, right_pole = pos_pole, neg_pole left_motions, right_motions = pos_motions, neg_motions left_arrow, right_arrow = "▲", "▼" diff --git a/scripts/validate_svd_themes.py b/scripts/validate_svd_themes.py index 18bec28..0aecb81 100644 --- a/scripts/validate_svd_themes.py +++ b/scripts/validate_svd_themes.py @@ -201,9 +201,8 @@ def check_theme_consistency( ) -> List[Dict]: """Check that theme pole labels are consistent with actual party positions. - Note: left_pole/right_pole describe the SEMANTIC left/right after flip, - not the political left/right spectrum. This check verifies that the - parties mentioned in each pole are actually on the expected side. + Note: positive_pole/negative_pole describe the SVD axis orientation. + Labels are derived at runtime from flip direction. Returns list of divergence reports. """ @@ -276,13 +275,7 @@ def main() -> int: print(f" Diff (post-flip R - L): {d['diff']:.4f}") print(f" Right scores: {d['right_scores']}") print(f" Left scores: {d['left_scores']}") - elif d["issue"] == "theme_pole_mismatch": - print(f" Label: {d.get('label', '')}") - print(f" Left pole: {d['left_pole']}") - print(f" Right pole: {d['right_pole']}") - print(f" Left mean: {d['left_mean']:.4f} ({d['left_parties']})") - print(f" Right mean: {d['right_mean']:.4f} ({d['right_parties']})") - print(f" Diff (left - right): {d['diff']:.4f}") + elif d["issue"] == "missing_canonical_party_data": print(f" Expected right: {canonical_right}") print(f" Expected left: {canonical_left}")