diff --git a/explorer.py b/explorer.py index 046b341..c676a21 100644 --- a/explorer.py +++ b/explorer.py @@ -265,8 +265,11 @@ def load_positions( "classify_axes failed; using generic axis labels" ) - if _should_swap_axes(axis_def): - positions_by_window, axis_def = _swap_axes(positions_by_window, axis_def) + # Axis orientation is guaranteed by compute_2d_axes via canonical party anchors + # (Procrustes alignment + sign-fixing). Lock labels to their known semantic meaning + # instead of relying on the keyword classifier which can fall back to generic labels. + axis_def["x_label"] = "Links\u2013Rechts" + axis_def["y_label"] = "Progressief\u2013Conservatief" # Filter displayed windows by window_size AFTER PCA computation. if window_size == "annual": @@ -654,6 +657,8 @@ def _build_party_axis_figure( theme: dict with keys label, explanation, positive_pole, negative_pole, flip. bootstrap_data: optional output from compute_party_bootstrap_cis — {party: {centroid, ci_lower, ci_upper, std, n_mps}}. + When provided, 95% CI is shown in hover text and N=1 parties get a diamond + marker. Error bars are intentionally not drawn — use hover to see the interval. Returns: go.Figure, or None if no data available. @@ -710,24 +715,31 @@ def _build_party_axis_figure( ) ) - # Build marker kwargs — bootstrap data adds error bars and diamond markers - marker_kwargs: dict = {"size": 18, "color": colours} - error_x_kwargs: Optional[dict] = None + # Build marker kwargs and hover text. + # When bootstrap data is available, 95% CI is embedded in the hover tooltip and + # N=1 parties get a diamond marker to signal low-reliability estimates. + # Error bars are intentionally omitted — they clutter the 1D chart. + marker_kwargs: dict = {"size": 14, "color": colours} if bootstrap_data: - error_array = [] + hover = [] symbols = [] - for p in parties: + for p, s in zip(parties, scores): bd = bootstrap_data.get(p) if bd: - err = (bd["ci_upper"][axis_idx] - bd["ci_lower"][axis_idx]) / 2 - error_array.append(abs(float(err))) - symbols.append("diamond" if bd["n_mps"] == 1 else "circle") + n_mps = bd["n_mps"] + ci_low = float(bd["ci_lower"][axis_idx]) + ci_high = float(bd["ci_upper"][axis_idx]) + hover.append( + f"{p}: {s:.3f} (N={n_mps}, 95%-BI: [{ci_low:.3f}, {ci_high:.3f}])" + ) + symbols.append("diamond" if n_mps == 1 else "circle") else: - error_array.append(0.0) + hover.append(f"{p}: {s:.3f}") symbols.append("circle") marker_kwargs["symbol"] = symbols - error_x_kwargs = {"type": "data", "array": error_array, "visible": True} + else: + hover = [f"{p}: {s:.3f}" for p, s in zip(parties, scores)] # Party markers scatter_kwargs: dict = { @@ -741,9 +753,6 @@ def _build_party_axis_figure( "hoverinfo": "text", "showlegend": False, } - if error_x_kwargs is not None: - scatter_kwargs["error_x"] = error_x_kwargs - fig.add_trace(go.Scatter(**scatter_kwargs)) fig.update_layout( @@ -1246,13 +1255,37 @@ def build_trajectories_tab(db_path: str, window_size: str) -> None: default=default_parties, ) + # Smoothing slider — EMA alpha controls noise reduction + smooth_alpha = st.slider( + "Glad maken (EMA-\u03b1)", + min_value=0.1, + max_value=1.0, + value=0.35, + step=0.05, + help=( + "\u03b1=1.0 toont de ruwe data; lagere waarden maken de lijn gladder. " + "Standaard 0.35 voor een goed evenwicht tussen detail en ruis." + ), + ) + + def _ema_smooth(values: List[float], alpha: float) -> List[float]: + """Apply exponential moving average; alpha=1.0 means no smoothing.""" + if not values or alpha >= 1.0: + return values + smoothed = [values[0]] + for v in values[1:]: + smoothed.append(alpha * v + (1 - alpha) * smoothed[-1]) + return smoothed + fig = go.Figure() for party in selected_parties: if party not in centroids: continue wids_sorted = sorted(centroids[party].keys()) - xs = [centroids[party][w][0] for w in wids_sorted] - ys = [centroids[party][w][1] for w in wids_sorted] + xs_raw = [centroids[party][w][0] for w in wids_sorted] + ys_raw = [centroids[party][w][1] for w in wids_sorted] + xs = _ema_smooth(xs_raw, smooth_alpha) + ys = _ema_smooth(ys_raw, smooth_alpha) colour = PARTY_COLOURS.get(party, "#9E9E9E") fig.add_trace( go.Scatter( diff --git a/tests/test_explorer_chart.py b/tests/test_explorer_chart.py index c1629b1..957ce93 100644 --- a/tests/test_explorer_chart.py +++ b/tests/test_explorer_chart.py @@ -88,15 +88,18 @@ class TestBuildPartyAxisFigure: fig = _build_party_axis_figure({}, comp_sel=1, theme=_make_theme()) assert fig is None - def test_with_bootstrap_has_error_x_and_diamonds(self): - """Call WITH bootstrap_data → error_x on marker trace, diamond for N=1.""" + def test_with_bootstrap_has_diamonds_for_single_mp(self): + """bootstrap_data present → N=1 party gets diamond, others get circle. No error bars.""" from explorer import _build_party_axis_figure party_scores = _make_party_scores() theme = _make_theme() bootstrap_data = _make_bootstrap_data(party_scores) fig = _build_party_axis_figure( - party_scores, comp_sel=1, theme=theme, bootstrap_data=bootstrap_data + party_scores, + comp_sel=1, + theme=theme, + bootstrap_data=bootstrap_data, ) assert isinstance(fig, go.Figure) @@ -104,38 +107,38 @@ class TestBuildPartyAxisFigure: marker_trace = fig.data[1] - # error_x should be present and visible - assert marker_trace.error_x is not None - assert marker_trace.error_x.visible is True - assert marker_trace.error_x.type == "data" - assert len(marker_trace.error_x.array) == 3 # 3 parties - - # All error bar values should be non-negative - for err in marker_trace.error_x.array: - assert err >= 0.0 + # No visual error bars — CIs are in hover text only + assert ( + marker_trace.error_x.array is None + or marker_trace.error_x.visible is not True + ) # Marker symbols: first party (N=1) → diamond, others → circle symbols = list(marker_trace.marker.symbol) assert symbols[0] == "diamond" assert all(s == "circle" for s in symbols[1:]) - def test_with_bootstrap_hover_includes_n(self): - """Hover text includes N= for each party.""" + def test_with_bootstrap_hover_includes_n_and_ci(self): + """Hover text includes N= and 95%-BI interval for each party.""" from explorer import _build_party_axis_figure party_scores = _make_party_scores() theme = _make_theme() bootstrap_data = _make_bootstrap_data(party_scores) fig = _build_party_axis_figure( - party_scores, comp_sel=1, theme=theme, bootstrap_data=bootstrap_data + party_scores, + comp_sel=1, + theme=theme, + bootstrap_data=bootstrap_data, ) marker_trace = fig.data[1] for ht in marker_trace.hovertext: assert "(N=" in ht + assert "95%-BI" in ht - def test_flip_negates_scores_but_error_bars_stay_positive(self): - """When flip=True, scores are negated but error bar magnitudes stay positive.""" + def test_flip_negates_scores(self): + """When flip=True, scores are negated relative to flip=False.""" from explorer import _build_party_axis_figure party_scores = _make_party_scores() @@ -144,10 +147,16 @@ class TestBuildPartyAxisFigure: bootstrap_data = _make_bootstrap_data(party_scores) fig_normal = _build_party_axis_figure( - party_scores, comp_sel=1, theme=theme_no_flip, bootstrap_data=bootstrap_data + party_scores, + comp_sel=1, + theme=theme_no_flip, + bootstrap_data=bootstrap_data, ) fig_flipped = _build_party_axis_figure( - party_scores, comp_sel=1, theme=theme_flip, bootstrap_data=bootstrap_data + party_scores, + comp_sel=1, + theme=theme_flip, + bootstrap_data=bootstrap_data, ) normal_scores = list(fig_normal.data[1].x) @@ -157,13 +166,17 @@ class TestBuildPartyAxisFigure: for ns, fs in zip(normal_scores, flipped_scores): assert pytest.approx(ns) == -fs - # Error bars should be the same (positive) in both cases - normal_errors = list(fig_normal.data[1].error_x.array) - flipped_errors = list(fig_flipped.data[1].error_x.array) - for ne, fe in zip(normal_errors, flipped_errors): - assert ne >= 0.0 - assert fe >= 0.0 - assert pytest.approx(ne) == fe + def test_without_bootstrap_hover_is_score_only(self): + """Without bootstrap data, hover text is just 'Party: score' with no CI.""" + from explorer import _build_party_axis_figure + + party_scores = _make_party_scores() + fig = _build_party_axis_figure(party_scores, comp_sel=1, theme=_make_theme()) + + marker_trace = fig.data[1] + for ht in marker_trace.hovertext: + assert "95%-BI" not in ht + assert "(N=" not in ht class TestLoadPartyMpVectorsImportable: