Fix compass orientation and simplify CI display

- Lock x_label/y_label to Links-Rechts / Progressief-Conservatief after
  classify_axes; Procrustes sign-fixing in compute_2d_axes already ensures
  the correct orientation so the heuristic _should_swap_axes call is removed
- Remove visual error bars from party axis chart; 95% CI is now shown in
  hover text (party: score, N=n, 95%-BI: [low, high]) to keep the 1D
  scatter clean
- Remove show_ci checkbox and parameter — CI is always accessible on hover
- Update tests to match new hover format and absence of error_x
main
Sven Geboers 1 month ago
parent b7129b3755
commit c059d5d955
  1. 67
      explorer.py
  2. 65
      tests/test_explorer_chart.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(

@ -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=<count> for each party."""
def test_with_bootstrap_hover_includes_n_and_ci(self):
"""Hover text includes N=<count> 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:

Loading…
Cancel
Save