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
This commit is contained in:
@@ -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."
|
||||
|
||||
+11
-26
@@ -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,8 +880,9 @@ 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: 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"))
|
||||
|
||||
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,
|
||||
key=f"mp_quiz_choice_{next_mid}",
|
||||
)
|
||||
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)
|
||||
|
||||
|
||||
+11
-3
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user