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"
|
"classify_axes failed; using generic axis labels"
|
||||||
)
|
)
|
||||||
|
|
||||||
if _should_swap_axes(axis_def):
|
# Axis orientation is guaranteed by compute_2d_axes via canonical party anchors
|
||||||
positions_by_window, axis_def = _swap_axes(positions_by_window, axis_def)
|
# (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.
|
# Filter displayed windows by window_size AFTER PCA computation.
|
||||||
if window_size == "annual":
|
if window_size == "annual":
|
||||||
@@ -654,6 +657,8 @@ def _build_party_axis_figure(
|
|||||||
theme: dict with keys label, explanation, positive_pole, negative_pole, flip.
|
theme: dict with keys label, explanation, positive_pole, negative_pole, flip.
|
||||||
bootstrap_data: optional output from compute_party_bootstrap_cis —
|
bootstrap_data: optional output from compute_party_bootstrap_cis —
|
||||||
{party: {centroid, ci_lower, ci_upper, std, n_mps}}.
|
{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:
|
Returns:
|
||||||
go.Figure, or None if no data available.
|
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
|
# Build marker kwargs and hover text.
|
||||||
marker_kwargs: dict = {"size": 18, "color": colours}
|
# When bootstrap data is available, 95% CI is embedded in the hover tooltip and
|
||||||
error_x_kwargs: Optional[dict] = None
|
# 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:
|
if bootstrap_data:
|
||||||
error_array = []
|
hover = []
|
||||||
symbols = []
|
symbols = []
|
||||||
for p in parties:
|
for p, s in zip(parties, scores):
|
||||||
bd = bootstrap_data.get(p)
|
bd = bootstrap_data.get(p)
|
||||||
if bd:
|
if bd:
|
||||||
err = (bd["ci_upper"][axis_idx] - bd["ci_lower"][axis_idx]) / 2
|
n_mps = bd["n_mps"]
|
||||||
error_array.append(abs(float(err)))
|
ci_low = float(bd["ci_lower"][axis_idx])
|
||||||
symbols.append("diamond" if bd["n_mps"] == 1 else "circle")
|
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:
|
else:
|
||||||
error_array.append(0.0)
|
hover.append(f"{p}: {s:.3f}")
|
||||||
symbols.append("circle")
|
symbols.append("circle")
|
||||||
marker_kwargs["symbol"] = symbols
|
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
|
# Party markers
|
||||||
scatter_kwargs: dict = {
|
scatter_kwargs: dict = {
|
||||||
@@ -741,9 +753,6 @@ def _build_party_axis_figure(
|
|||||||
"hoverinfo": "text",
|
"hoverinfo": "text",
|
||||||
"showlegend": False,
|
"showlegend": False,
|
||||||
}
|
}
|
||||||
if error_x_kwargs is not None:
|
|
||||||
scatter_kwargs["error_x"] = error_x_kwargs
|
|
||||||
|
|
||||||
fig.add_trace(go.Scatter(**scatter_kwargs))
|
fig.add_trace(go.Scatter(**scatter_kwargs))
|
||||||
|
|
||||||
fig.update_layout(
|
fig.update_layout(
|
||||||
@@ -1246,13 +1255,37 @@ def build_trajectories_tab(db_path: str, window_size: str) -> None:
|
|||||||
default=default_parties,
|
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()
|
fig = go.Figure()
|
||||||
for party in selected_parties:
|
for party in selected_parties:
|
||||||
if party not in centroids:
|
if party not in centroids:
|
||||||
continue
|
continue
|
||||||
wids_sorted = sorted(centroids[party].keys())
|
wids_sorted = sorted(centroids[party].keys())
|
||||||
xs = [centroids[party][w][0] for w in wids_sorted]
|
xs_raw = [centroids[party][w][0] for w in wids_sorted]
|
||||||
ys = [centroids[party][w][1] 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")
|
colour = PARTY_COLOURS.get(party, "#9E9E9E")
|
||||||
fig.add_trace(
|
fig.add_trace(
|
||||||
go.Scatter(
|
go.Scatter(
|
||||||
|
|||||||
@@ -88,15 +88,18 @@ class TestBuildPartyAxisFigure:
|
|||||||
fig = _build_party_axis_figure({}, comp_sel=1, theme=_make_theme())
|
fig = _build_party_axis_figure({}, comp_sel=1, theme=_make_theme())
|
||||||
assert fig is None
|
assert fig is None
|
||||||
|
|
||||||
def test_with_bootstrap_has_error_x_and_diamonds(self):
|
def test_with_bootstrap_has_diamonds_for_single_mp(self):
|
||||||
"""Call WITH bootstrap_data → error_x on marker trace, diamond for N=1."""
|
"""bootstrap_data present → N=1 party gets diamond, others get circle. No error bars."""
|
||||||
from explorer import _build_party_axis_figure
|
from explorer import _build_party_axis_figure
|
||||||
|
|
||||||
party_scores = _make_party_scores()
|
party_scores = _make_party_scores()
|
||||||
theme = _make_theme()
|
theme = _make_theme()
|
||||||
bootstrap_data = _make_bootstrap_data(party_scores)
|
bootstrap_data = _make_bootstrap_data(party_scores)
|
||||||
fig = _build_party_axis_figure(
|
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)
|
assert isinstance(fig, go.Figure)
|
||||||
@@ -104,38 +107,38 @@ class TestBuildPartyAxisFigure:
|
|||||||
|
|
||||||
marker_trace = fig.data[1]
|
marker_trace = fig.data[1]
|
||||||
|
|
||||||
# error_x should be present and visible
|
# No visual error bars — CIs are in hover text only
|
||||||
assert marker_trace.error_x is not None
|
assert (
|
||||||
assert marker_trace.error_x.visible is True
|
marker_trace.error_x.array is None
|
||||||
assert marker_trace.error_x.type == "data"
|
or marker_trace.error_x.visible is not True
|
||||||
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
|
|
||||||
|
|
||||||
# Marker symbols: first party (N=1) → diamond, others → circle
|
# Marker symbols: first party (N=1) → diamond, others → circle
|
||||||
symbols = list(marker_trace.marker.symbol)
|
symbols = list(marker_trace.marker.symbol)
|
||||||
assert symbols[0] == "diamond"
|
assert symbols[0] == "diamond"
|
||||||
assert all(s == "circle" for s in symbols[1:])
|
assert all(s == "circle" for s in symbols[1:])
|
||||||
|
|
||||||
def test_with_bootstrap_hover_includes_n(self):
|
def test_with_bootstrap_hover_includes_n_and_ci(self):
|
||||||
"""Hover text includes N=<count> for each party."""
|
"""Hover text includes N=<count> and 95%-BI interval for each party."""
|
||||||
from explorer import _build_party_axis_figure
|
from explorer import _build_party_axis_figure
|
||||||
|
|
||||||
party_scores = _make_party_scores()
|
party_scores = _make_party_scores()
|
||||||
theme = _make_theme()
|
theme = _make_theme()
|
||||||
bootstrap_data = _make_bootstrap_data(party_scores)
|
bootstrap_data = _make_bootstrap_data(party_scores)
|
||||||
fig = _build_party_axis_figure(
|
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]
|
marker_trace = fig.data[1]
|
||||||
for ht in marker_trace.hovertext:
|
for ht in marker_trace.hovertext:
|
||||||
assert "(N=" in ht
|
assert "(N=" in ht
|
||||||
|
assert "95%-BI" in ht
|
||||||
|
|
||||||
def test_flip_negates_scores_but_error_bars_stay_positive(self):
|
def test_flip_negates_scores(self):
|
||||||
"""When flip=True, scores are negated but error bar magnitudes stay positive."""
|
"""When flip=True, scores are negated relative to flip=False."""
|
||||||
from explorer import _build_party_axis_figure
|
from explorer import _build_party_axis_figure
|
||||||
|
|
||||||
party_scores = _make_party_scores()
|
party_scores = _make_party_scores()
|
||||||
@@ -144,10 +147,16 @@ class TestBuildPartyAxisFigure:
|
|||||||
bootstrap_data = _make_bootstrap_data(party_scores)
|
bootstrap_data = _make_bootstrap_data(party_scores)
|
||||||
|
|
||||||
fig_normal = _build_party_axis_figure(
|
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(
|
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)
|
normal_scores = list(fig_normal.data[1].x)
|
||||||
@@ -157,13 +166,17 @@ class TestBuildPartyAxisFigure:
|
|||||||
for ns, fs in zip(normal_scores, flipped_scores):
|
for ns, fs in zip(normal_scores, flipped_scores):
|
||||||
assert pytest.approx(ns) == -fs
|
assert pytest.approx(ns) == -fs
|
||||||
|
|
||||||
# Error bars should be the same (positive) in both cases
|
def test_without_bootstrap_hover_is_score_only(self):
|
||||||
normal_errors = list(fig_normal.data[1].error_x.array)
|
"""Without bootstrap data, hover text is just 'Party: score' with no CI."""
|
||||||
flipped_errors = list(fig_flipped.data[1].error_x.array)
|
from explorer import _build_party_axis_figure
|
||||||
for ne, fe in zip(normal_errors, flipped_errors):
|
|
||||||
assert ne >= 0.0
|
party_scores = _make_party_scores()
|
||||||
assert fe >= 0.0
|
fig = _build_party_axis_figure(party_scores, comp_sel=1, theme=_make_theme())
|
||||||
assert pytest.approx(ne) == fe
|
|
||||||
|
marker_trace = fig.data[1]
|
||||||
|
for ht in marker_trace.hovertext:
|
||||||
|
assert "95%-BI" not in ht
|
||||||
|
assert "(N=" not in ht
|
||||||
|
|
||||||
|
|
||||||
class TestLoadPartyMpVectorsImportable:
|
class TestLoadPartyMpVectorsImportable:
|
||||||
|
|||||||
Reference in New Issue
Block a user