UI improvements + add axis orientation test

- Rename app to 'Motief: de stematlas' in Home.py
- Remove PCA variance caption from compass tab
- Hardcode db_path and window_size; remove sidebar inputs
- Change trajectories default to [CDA, D66, VVD]
- Move quiz to pages/1_Stemwijzer.py; wrap in st.form
- Remove quiz tab from main explorer
- Add pytest dev dep + fix test fixtures (_load_mp_vectors_for_window)
- Add test_pca_axis_orientation with proper PCA variance dominance
main
Sven Geboers 1 month ago
parent 72fbe0008e
commit 6329d6a256
  1. 6
      Home.py
  2. 49
      explorer.py
  3. 14
      pages/1_Stemwijzer.py
  4. 5
      pyproject.toml
  5. 111
      tests/test_political_compass.py
  6. 42
      uv.lock

@ -7,7 +7,7 @@ brief descriptions of and links to the two sub-pages.
import streamlit as st
st.set_page_config(
page_title="StemAtlas",
page_title="Motief: de stematlas",
page_icon="🗺",
layout="centered",
initial_sidebar_state="expanded",
@ -15,9 +15,9 @@ st.set_page_config(
def main() -> None:
st.title("🗺 StemAtlas")
st.title("🗺 Motief: de stematlas")
st.markdown(
"**StemAtlas** brengt de Nederlandse Tweede Kamer in kaart op basis van "
"**Motief** brengt de Nederlandse Tweede Kamer in kaart op basis van "
"echte stemmingen over moties. Gebruik de Stemwijzer om te ontdekken welke "
"partij het beste bij jouw standpunten past, of verken de politieke ruimte "
"zelf in de Explorer."

@ -840,14 +840,6 @@ def build_compass_tab(db_path: str, window_size: str) -> None:
with col1:
st.plotly_chart(fig, use_container_width=True)
# Axis info
if axis_def:
evr = axis_def.get("explained_variance_ratio", [])
if evr:
st.caption(
f"PCA verklaarde variantie: as 1 = {evr[0] * 100:.1f}%, as 2 = {evr[1] * 100:.1f}%"
)
# ---------------------------------------------------------------------------
# Tab 2: Partij Trajectories
@ -888,9 +880,10 @@ def build_trajectories_tab(db_path: str, window_size: str) -> None:
all_parties_sorted = sorted(all_parties)
# Default: prefer known major parties over the automatic "appeared in most windows"
# heuristic, which would exclude newer parties like NSC that only have 4 windows.
default_parties = [p for p in KNOWN_MAJOR_PARTIES if p in all_parties]
# Default: show CDA, D66, VVD — the three parties that span the political centre
default_parties = [p for p in ["CDA", "D66", "VVD"] if p in all_parties]
if not default_parties:
default_parties = [p for p in KNOWN_MAJOR_PARTIES if p in all_parties]
if not default_parties:
default_parties = all_parties_sorted[:6]
@ -1541,7 +1534,8 @@ def build_mp_quiz_tab(db_path: str) -> None:
st.session_state["mp_quiz_asked"] = []
st.rerun()
# main question loop (single question per render)
# main question loop (single question per render, wrapped in a form to avoid
# premature reruns when the user changes the radio selection)
next_mid = _next_motion_id()
if next_mid is None:
st.info("Geen nieuwe vragen beschikbaar om kandidaten te scheiden.")
@ -1557,14 +1551,15 @@ def build_mp_quiz_tab(db_path: str) -> None:
if motion_row.get("layman_explanation"):
st.info(motion_row.get("layman_explanation"))
choice = st.radio(
"Wat zou jij stemmen?",
options=["Voor", "Tegen", "Onthouden", "Geen stem"],
index=3,
key=f"mp_quiz_choice_{next_mid}",
)
with st.form(key=f"mp_quiz_form_{next_mid}"):
choice = st.radio(
"Wat zou jij stemmen?",
options=["Voor", "Tegen", "Onthouden", "Geen stem"],
index=3,
)
submitted = st.form_submit_button("Beantwoord en verder")
if st.button("Beantwoord en verder", key=f"mp_quiz_submit_{next_mid}"):
if submitted:
st.session_state["mp_quiz_votes"][str(next_mid)] = choice
st.session_state["mp_quiz_asked"].append(next_mid)
st.rerun()
@ -1617,13 +1612,8 @@ def run_app() -> None:
# Sidebar
st.sidebar.title("Instellingen")
db_path = st.sidebar.text_input("DuckDB pad", value="data/motions.db")
window_size = st.sidebar.radio(
"Venstergrootte",
options=["annual", "quarterly"],
format_func=lambda x: "Per jaar" if x == "annual" else "Per kwartaal",
index=0,
)
db_path = "data/motions.db"
window_size = "annual"
show_rejected = st.sidebar.checkbox("Toon verworpen moties", value=False)
# About section
@ -1649,12 +1639,11 @@ def run_app() -> None:
"📈 Trajectories",
"🔍 Motie Zoeken",
"📋 Motie Browser",
"🧑 Welk tweede kamerlid ben jij?",
"🔬 SVD Components",
]
if hasattr(st, "tabs") and callable(getattr(st, "tabs")):
tab1, tab2, tab3, tab4, tab5, tab6 = st.tabs(tab_labels)
tab1, tab2, tab3, tab4, tab5 = st.tabs(tab_labels)
with tab1:
build_compass_tab(db_path, window_size)
with tab2:
@ -1664,8 +1653,6 @@ def run_app() -> None:
with tab4:
build_browser_tab(db_path, show_rejected)
with tab5:
build_mp_quiz_tab(db_path)
with tab6:
build_svd_components_tab(db_path)
else:
# Fallback for environments where `st.tabs` is not available: use a radio selector
@ -1678,8 +1665,6 @@ def run_app() -> None:
build_search_tab(db_path, show_rejected)
elif selection == tab_labels[3]:
build_browser_tab(db_path, show_rejected)
elif selection == tab_labels[4]:
build_mp_quiz_tab(db_path)
else:
build_svd_components_tab(db_path)

@ -1,5 +1,13 @@
"""Stemwijzer page — thin wrapper around the existing app module."""
"""Stemwijzer page — quiz to find your matching MP."""
from app import main # noqa: F401 (module-level set_page_config runs on import)
import streamlit as st
main()
st.set_page_config(
page_title="Stemwijzer",
page_icon="🗳",
layout="centered",
)
from explorer import build_mp_quiz_tab
build_mp_quiz_tab("data/motions.db")

@ -19,3 +19,8 @@ dependencies = [
"beautifulsoup4>=4.14.3",
"lxml>=6.0.2",
]
[dependency-groups]
dev = [
"pytest>=9.0.2",
]

@ -5,6 +5,19 @@ import sys
import pytest
# ---------------------------------------------------------------------------
# Helpers shared by orientation tests
# ---------------------------------------------------------------------------
def _make_fake_traj(aligned):
fake = types.SimpleNamespace()
fake._load_window_ids = lambda db: list(aligned.keys())
fake._load_mp_vectors_for_window = lambda db, w: aligned.get(w, {})
fake._procrustes_align_windows = lambda x: aligned
return fake
def test_compute_2d_axes_pca_synthetic(monkeypatch):
"""Synthetic test for compute_2d_axes using patched alignment helper."""
@ -14,15 +27,15 @@ def test_compute_2d_axes_pca_synthetic(monkeypatch):
# _load_window_ids should return ordered windows
fake_traj._load_window_ids = lambda db: ["w1", "w2"]
# _load_mp_vectors_for_window is not used because we patch _procrustes_align_windows
fake_traj._load_mp_vectors_for_window = lambda db, w: {}
# Provide aligned vectors directly
aligned = {
"w1": {"Alice": np.array([1.0, 0.0, 0.0]), "Bob": np.array([0.0, 1.0, 0.0])},
"w2": {"Alice": np.array([0.8, 0.2, 0.0]), "Bob": np.array([0.1, 0.9, 0.0])},
}
# _load_mp_vectors_for_window returns the pre-aligned vectors (needed for padding step)
fake_traj._load_mp_vectors_for_window = lambda db, w: aligned.get(w, {})
fake_traj._procrustes_align_windows = lambda x: aligned
# Insert fake module into sys.modules for import by analysis.political_axis
@ -42,3 +55,95 @@ def test_compute_2d_axes_pca_synthetic(monkeypatch):
assert np.isfinite(coord[0]) and np.isfinite(coord[1])
assert axis_def.get("method") == "pca"
def test_pca_axis_orientation(monkeypatch):
"""PCA axes must be oriented so right parties score higher on X and
progressive parties score higher on Y than their respective opposites.
We construct a minimal vote-matrix world where:
- Right MPs (PVV, VVD members) cluster in one direction on dim-0.
- Left MPs (SP, GroenLinks-PvdA members) cluster in the opposite direction.
- Progressive MPs cluster on dim-1; conservative MPs on the opposite side.
The orientation logic in compute_2d_axes should flip axis signs so that
right_x > left_x and prog_y > cons_y regardless of the raw SVD sign.
"""
# Build vectors so that right parties are at +1 on dim-0 and
# progressive parties are at +1 on dim-1.
# We deliberately negate them to test that auto-orient flips them back.
# Right/left use magnitude 3, prog/cons use magnitude 1 so that dim-0
# dominates PCA variance — ensuring PC1 = left-right axis, PC2 = prog-cons.
right_vec = np.array([-3.0, 0.0, 0.0]) # intentionally negative on dim-0
left_vec = np.array([3.0, 0.0, 0.0]) # intentionally positive on dim-0
prog_vec = np.array([0.0, -1.0, 0.0]) # intentionally negative on dim-1
cons_vec = np.array([0.0, 1.0, 0.0]) # intentionally positive on dim-1
aligned = {
"w1": {
# Right-leaning MPs
"Wilders, G.": right_vec,
"Rutte, M.": right_vec + np.array([0.0, 0.0, 0.05]),
# Left-leaning MPs
"Marijnissen, L.": left_vec,
"Klever, A.": left_vec + np.array([0.0, 0.0, 0.05]),
# Progressive MPs
"Bromet, L.": prog_vec,
"Nijboer, H.": prog_vec + np.array([0.0, 0.0, -0.05]),
# Conservative MPs
"Segers, G.": cons_vec,
"Omtzigt, P.": cons_vec + np.array([0.0, 0.0, -0.05]),
}
}
# mp_metadata rows used by the orientation code (party affiliation)
mp_metadata = [
("Wilders, G.", "PVV"),
("Rutte, M.", "VVD"),
("Marijnissen, L.", "SP"),
("Klever, A.", "GroenLinks-PvdA"),
("Bromet, L.", "GroenLinks-PvdA"),
("Nijboer, H.", "SP"),
("Segers, G.", "CDA"),
("Omtzigt, P.", "Nieuw Sociaal Contract"),
]
fake_traj = _make_fake_traj(aligned)
monkeypatch.setitem(sys.modules, "analysis.trajectory", fake_traj)
# Patch duckdb so the orientation helper can fetch mp_metadata
import types as _types
fake_conn = _types.SimpleNamespace(
execute=lambda q: _types.SimpleNamespace(fetchall=lambda: mp_metadata),
close=lambda: None,
)
import duckdb as _duckdb
monkeypatch.setattr(_duckdb, "connect", lambda db_path, **kw: fake_conn)
# Need to reload the module so monkeypatched sys.modules takes effect
import importlib, analysis.political_axis as _ax
importlib.reload(_ax)
from analysis.political_axis import compute_2d_axes
positions_by_window, axis_def = compute_2d_axes(
db_path="dummy", window_ids=["w1"], method="pca"
)
pos = positions_by_window["w1"]
# X-axis: right parties should score higher than left parties
right_x = np.mean([pos["Wilders, G."][0], pos["Rutte, M."][0]])
left_x = np.mean([pos["Marijnissen, L."][0], pos["Klever, A."][0]])
assert right_x > left_x, (
f"Expected right parties (x={right_x:.3f}) > left parties (x={left_x:.3f}) on X-axis"
)
# Y-axis: progressive parties should score higher than conservative parties
prog_y = np.mean([pos["Bromet, L."][1], pos["Nijboer, H."][1]])
cons_y = np.mean([pos["Segers, G."][1], pos["Omtzigt, P."][1]])
assert prog_y > cons_y, (
f"Expected progressive parties (y={prog_y:.3f}) > conservative parties (y={cons_y:.3f}) on Y-axis"
)

@ -273,6 +273,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "jinja2"
version = "3.1.6"
@ -697,6 +706,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/52/d2/c6e44dba74f17c6216ce1b56044a9b93a929f1c2d5bdaff892512b260f5e/plotly-6.6.0-py3-none-any.whl", hash = "sha256:8d6daf0f87412e0c0bfe72e809d615217ab57cc715899a1e5145135a7800d1d0", size = 9910315, upload-time = "2026-03-02T21:10:18.131Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "protobuf"
version = "6.31.1"
@ -823,6 +841,22 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b2/e6/94145d714402fd5ade00b5661f2d0ab981219e07f7db9bfa16786cdb9c04/pynndescent-0.6.0-py3-none-any.whl", hash = "sha256:dc8c74844e4c7f5cbd1e0cd6909da86fdc789e6ff4997336e344779c3d5538ef", size = 73511, upload-time = "2026-01-08T21:29:57.306Z" },
]
[[package]]
name = "pytest"
version = "9.0.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
]
[[package]]
name = "python-dateutil"
version = "2.9.0.post0"
@ -1113,6 +1147,11 @@ dependencies = [
{ name = "umap-learn" },
]
[package.dev-dependencies]
dev = [
{ name = "pytest" },
]
[package.metadata]
requires-dist = [
{ name = "beautifulsoup4", specifier = ">=4.14.3" },
@ -1129,6 +1168,9 @@ requires-dist = [
{ name = "umap-learn", specifier = ">=0.5" },
]
[package.metadata.requires-dev]
dev = [{ name = "pytest", specifier = ">=9.0.2" }]
[[package]]
name = "streamlit"
version = "1.48.0"

Loading…
Cancel
Save