From 6329d6a2563e329aeb55a74923fb4aaf13844803 Mon Sep 17 00:00:00 2001 From: Sven Geboers Date: Sat, 28 Mar 2026 22:27:39 +0100 Subject: [PATCH] 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 --- Home.py | 6 +- explorer.py | 49 +++++--------- pages/1_Stemwijzer.py | 14 +++- pyproject.toml | 5 ++ tests/test_political_compass.py | 111 +++++++++++++++++++++++++++++++- uv.lock | 42 ++++++++++++ 6 files changed, 186 insertions(+), 41 deletions(-) diff --git a/Home.py b/Home.py index 3cb3413..6b97412 100644 --- a/Home.py +++ b/Home.py @@ -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." diff --git a/explorer.py b/explorer.py index 05aefb4..a9516b8 100644 --- a/explorer.py +++ b/explorer.py @@ -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) diff --git a/pages/1_Stemwijzer.py b/pages/1_Stemwijzer.py index 94ea0fd..4dc1781 100644 --- a/pages/1_Stemwijzer.py +++ b/pages/1_Stemwijzer.py @@ -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") diff --git a/pyproject.toml b/pyproject.toml index 53ab241..b9d8c0d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,3 +19,8 @@ dependencies = [ "beautifulsoup4>=4.14.3", "lxml>=6.0.2", ] + +[dependency-groups] +dev = [ + "pytest>=9.0.2", +] diff --git a/tests/test_political_compass.py b/tests/test_political_compass.py index fa71908..c67f6db 100644 --- a/tests/test_political_compass.py +++ b/tests/test_political_compass.py @@ -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" + ) diff --git a/uv.lock b/uv.lock index d29b319..b4080ba 100644 --- a/uv.lock +++ b/uv.lock @@ -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"