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
This commit is contained in:
+50
-17
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user