diff --git a/explorer.py b/explorer.py index ac13813..6a5b95d 100644 --- a/explorer.py +++ b/explorer.py @@ -2410,6 +2410,9 @@ def build_svd_components_tab(db_path: str) -> None: Reads thoughts/explorer/top_svd_top_motions.json and displays a selector for components 1..10 with theme labels/explanations and a detail pane per motion. + + Components 1-2 use aligned PCA positions (consistent with compass). + Components 3-10 use raw SVD scores. """ st.subheader("🔬 SVD Assen — politieke polarisatiethema's") st.markdown( @@ -2556,7 +2559,7 @@ def build_svd_components_tab(db_path: str) -> None: key=f"svd_window_{comp_sel}", ) - # Load party scores for the selected window + # Load party scores for the selected window (used for components 3-10) if svd_window == "current_parliament": party_scores = party_scores_default else: @@ -2567,6 +2570,55 @@ def build_svd_components_tab(db_path: str) -> None: {p: len(v) for p, v in party_mp_vectors.items()} if party_mp_vectors else {} ) + # For components 1-2, use aligned positions from load_positions (same as compass) + # for consistency. For components 3-10, use raw SVD scores. + def _get_aligned_party_coords(window: str) -> Dict[str, Tuple[float, float]]: + """Get party (x, y) coordinates from aligned PCA positions for a window.""" + positions_by_window, _ = load_positions(db_path, "annual") + window_pos = positions_by_window.get(window, {}) + if not window_pos: + return {} + + # Load party map to convert MP names to parties + _party_map = load_party_map(db_path) + + # Aggregate MP positions to party centroids + party_coords: Dict[str, List[Tuple[float, float]]] = {} + for mp_name, (x, y) in window_pos.items(): + party = _party_map.get( + mp_name, _party_map.get(mp_name.split("(")[0].strip(), None) + ) + if party: + party_coords.setdefault(party, []).append((x, y)) + + # Compute mean position per party + return { + party: ( + float(np.mean([c[0] for c in coords])), + float(np.mean([c[1] for c in coords])), + ) + for party, coords in party_coords.items() + if coords + } + + # Extract 1D scores for this component + party_1d_coords: dict = {} + + if comp_sel <= 2: + # Components 1-2: use aligned PCA positions from load_positions (consistent with compass) + aligned_coords = _get_aligned_party_coords(svd_window) + for party, (x, y) in aligned_coords.items(): + party_1d_coords[party] = (x,) if comp_sel == 1 else (y,) + else: + # Components 3-10: use raw SVD scores + idx = comp_sel - 1 # Convert to 0-indexed + for party, scores in party_scores.items(): + try: + if scores and len(scores) > idx: + party_1d_coords[party] = (float(scores[idx]),) + except Exception: + continue + # Auto-compute flip directions for ALL components 1-10 based on party centroids. # Each window's SVD has arbitrary sign orientation, so we compute flip per component # to ensure canonical right parties (PVV, FVD, JA21, SGP) appear on the RIGHT. @@ -2587,16 +2639,6 @@ def build_svd_components_tab(db_path: str) -> None: "flip": computed_flips.get(comp_sel, theme.get("flip", False)), } - # Extract 1D scores for this component (ALL components use raw SVD values) - party_1d_coords: dict = {} - idx = comp_sel - 1 # Convert to 0-indexed - for party, scores in party_scores.items(): - try: - if scores and len(scores) > idx: - party_1d_coords[party] = (float(scores[idx]),) - except Exception: - continue - # Filter parties by minimum MP count if min_mps > 1 and party_mp_counts: valid_parties = {p for p, count in party_mp_counts.items() if count >= min_mps} @@ -2615,10 +2657,9 @@ def build_svd_components_tab(db_path: str) -> None: has_current = "current_parliament" in available_windows all_windows = year_windows + (["current_parliament"] if has_current else []) - # ALL components use raw (non-aligned) SVD vectors. - # Procrustes alignment rotates the full vector space which makes scores - # incomparable with the single-window view. Per-window flip computation - # handles orientation alignment for the trajectory. + # For components 1-2, use aligned PCA positions for consistency with compass. + # For components 3-10, use raw SVD scores. + # Per-window flip computation handles orientation alignment for the trajectory. party_scores_by_window = load_party_scores_all_windows(db_path, all_windows) _render_svd_time_trajectory(