parent
c9c59dd166
commit
bed776f295
@ -0,0 +1,177 @@ |
|||||||
|
# SVD Label Unification Design |
||||||
|
|
||||||
|
## Goal |
||||||
|
|
||||||
|
Unify SVD component labels into a single source of truth (`SVD_THEMES`) and automatically compute axis flip directions so right-wing parties consistently appear on the right side of all SVD component axes. |
||||||
|
|
||||||
|
## Background |
||||||
|
|
||||||
|
Currently there are two separate label systems: |
||||||
|
1. `SVD_THEMES` in `explorer.py` - defines labels for all 10 SVD components with detailed explanations |
||||||
|
2. `_LABELS` in `axis_classifier.py` - defines short labels for the classifier (lr, eu, pi, co, pc) |
||||||
|
|
||||||
|
This causes duplication and potential inconsistency. Additionally, flip values are hardcoded in `SVD_THEMES` rather than computed from actual party positions. |
||||||
|
|
||||||
|
## Design |
||||||
|
|
||||||
|
### Single Source of Truth |
||||||
|
|
||||||
|
`SVD_THEMES` in `explorer.py` remains the canonical definition for all SVD component labels. A new shared module `analysis/svd_labels.py` will: |
||||||
|
|
||||||
|
1. Import `SVD_THEMES` from explorer.py (at runtime to avoid circular imports) |
||||||
|
2. Provide helper functions to derive labels for any component |
||||||
|
3. Compute flip direction automatically based on party centroids |
||||||
|
|
||||||
|
### New Module: `analysis/svd_labels.py` |
||||||
|
|
||||||
|
```python |
||||||
|
"""Unified SVD component labels and automatic flip direction computation.""" |
||||||
|
|
||||||
|
# Canonical party sets for orientation |
||||||
|
RIGHT_PARTIES = { |
||||||
|
"PVV", "VVD", "FVD", "BBB", "JA21", |
||||||
|
"Nieuw Sociaal Contract", "SGP", "CDA", "ChristenUnie" |
||||||
|
} |
||||||
|
LEFT_PARTIES = { |
||||||
|
"SP", "PvdA", "GL", "GroenLinks", "GroenLinks-PvdA", |
||||||
|
"DENK", "PvdD", "Volt" |
||||||
|
} |
||||||
|
|
||||||
|
def get_svd_label(component: int) -> str: |
||||||
|
"""Get short label for SVD component (e.g., 'EU-integratie–Nationalisme').""" |
||||||
|
|
||||||
|
def get_svd_theme(component: int) -> dict: |
||||||
|
"""Get full theme dict for SVD component.""" |
||||||
|
|
||||||
|
def compute_flip_direction(component: int, party_scores: dict) -> bool: |
||||||
|
""" |
||||||
|
Compute flip so right parties appear on the right side. |
||||||
|
|
||||||
|
Args: |
||||||
|
component: SVD component number (1-indexed) |
||||||
|
party_scores: {party_name: [score_comp1, score_comp2, ...]} |
||||||
|
|
||||||
|
Returns: |
||||||
|
True if axis should be flipped so right parties are on right. |
||||||
|
""" |
||||||
|
# Get scores for this component (0-indexed internally) |
||||||
|
idx = component - 1 |
||||||
|
right_scores = [scores[idx] for party, scores in party_scores.items() |
||||||
|
if party in RIGHT_PARTIES and len(scores) > idx] |
||||||
|
left_scores = [scores[idx] for party, scores in party_scores.items() |
||||||
|
if party in LEFT_PARTIES and len(scores) > idx] |
||||||
|
|
||||||
|
if not right_scores or not left_scores: |
||||||
|
return False # Default: no flip if insufficient data |
||||||
|
|
||||||
|
right_mean = sum(right_scores) / len(right_scores) |
||||||
|
left_mean = sum(left_scores) / len(left_scores) |
||||||
|
|
||||||
|
# Flip if right parties have lower mean (they're on the left) |
||||||
|
return right_mean < left_mean |
||||||
|
|
||||||
|
def get_fallback_labels() -> tuple[str, str]: |
||||||
|
"""Get fallback labels for x and y axes (components 1 and 2).""" |
||||||
|
return (get_svd_label(1), get_svd_label(2)) |
||||||
|
``` |
||||||
|
|
||||||
|
### Changes to `analysis/axis_classifier.py` |
||||||
|
|
||||||
|
1. Remove hardcoded `_LABELS` dict |
||||||
|
2. Import from `svd_labels.py` at runtime (to avoid circular imports) |
||||||
|
3. Update `display_label_for_modal` to derive from SVD_THEMES |
||||||
|
|
||||||
|
```python |
||||||
|
# Remove: |
||||||
|
_LABELS = { |
||||||
|
"lr": "Verzorgingsstaat–Marktwerking", |
||||||
|
"eu": "EU-integratie–Nationalisme", |
||||||
|
... |
||||||
|
} |
||||||
|
|
||||||
|
# Add: |
||||||
|
def _get_svd_labels(): |
||||||
|
"""Lazy import to avoid circular dependency.""" |
||||||
|
from analysis.svd_labels import get_svd_label, get_fallback_labels |
||||||
|
return get_fallback_labels() |
||||||
|
|
||||||
|
def display_label_for_modal(modal_label: Optional[str], axis: str) -> str: |
||||||
|
"""Return a user-facing axis label for a modal/internal label.""" |
||||||
|
from analysis.svd_labels import get_svd_label |
||||||
|
|
||||||
|
if modal_label is None: |
||||||
|
# Fallback to component 1 (x) or 2 (y) |
||||||
|
comp = 1 if axis == "x" else 2 |
||||||
|
return get_svd_label(comp) |
||||||
|
|
||||||
|
# Map "As 1" / "As 2" to semantic labels |
||||||
|
if axis == "x" and modal_label in ("As 1", "Stempatroon As 1"): |
||||||
|
return get_svd_label(1) |
||||||
|
if axis == "y" and modal_label in ("As 2", "Stempatroon As 2"): |
||||||
|
return get_svd_label(2) |
||||||
|
|
||||||
|
return modal_label |
||||||
|
``` |
||||||
|
|
||||||
|
### Changes to `explorer.py` |
||||||
|
|
||||||
|
1. Keep `SVD_THEMES` as canonical source |
||||||
|
2. Remove hardcoded fallback labels in `_build_political_compass_figure` |
||||||
|
3. Use `svd_labels.py` for all label lookups |
||||||
|
4. Add party position charts for components 3-10 in SVD Components tab |
||||||
|
|
||||||
|
#### Party Position Charts for All Components |
||||||
|
|
||||||
|
Currently, `_render_party_axis_chart` only works for components 1 and 2 (which have 2D coords). For components 3-10, we need to show 1D party positions: |
||||||
|
|
||||||
|
```python |
||||||
|
def _render_party_axis_chart_1d(party_coords: dict, component: int, theme: dict, bootstrap_data: dict = None): |
||||||
|
"""Render a 1D party position chart for a single SVD component.""" |
||||||
|
# Extract scores for this component |
||||||
|
# Show parties on a horizontal axis |
||||||
|
# Use theme['label'] for axis title |
||||||
|
# Use theme['positive_pole'] and theme['negative_pole'] for annotations |
||||||
|
``` |
||||||
|
|
||||||
|
### Auto-compute Flip Values |
||||||
|
|
||||||
|
On app startup or when loading SVD data: |
||||||
|
1. Load party scores for all components |
||||||
|
2. Compute flip direction for each component using `compute_flip_direction` |
||||||
|
3. Update `SVD_THEMES[component]["flip"]` dynamically OR store precomputed values |
||||||
|
|
||||||
|
### Files Modified |
||||||
|
|
||||||
|
| File | Changes | |
||||||
|
|------|---------| |
||||||
|
| `analysis/svd_labels.py` | **NEW** - Unified label system with auto-flip | |
||||||
|
| `analysis/axis_classifier.py` | Remove `_LABELS`, import from svd_labels | |
||||||
|
| `explorer.py` | Remove fallback labels, add 1D charts for components 3-10 | |
||||||
|
| `tests/test_axis_label_fallback.py` | Update to use new label system | |
||||||
|
| `tests/test_political_compass.py` | Update assertions for new labels | |
||||||
|
|
||||||
|
### Test Plan |
||||||
|
|
||||||
|
1. **Unit tests for `svd_labels.py`**: |
||||||
|
- `get_svd_label(component)` returns correct label |
||||||
|
- `compute_flip_direction` returns correct flip based on party scores |
||||||
|
- `get_fallback_labels()` returns tuple of component 1 and 2 labels |
||||||
|
|
||||||
|
2. **Integration tests**: |
||||||
|
- Compass plot uses correct labels from SVD_THEMES |
||||||
|
- Trajectory plot uses correct labels from SVD_THEMES |
||||||
|
- SVD Components tab shows party positions for all components |
||||||
|
- Right parties appear on right side of all axes |
||||||
|
|
||||||
|
3. **Regression tests**: |
||||||
|
- Existing tests pass with new label system |
||||||
|
- No circular import errors |
||||||
|
|
||||||
|
## Implementation Order |
||||||
|
|
||||||
|
1. Create `analysis/svd_labels.py` with helper functions |
||||||
|
2. Update `axis_classifier.py` to use svd_labels |
||||||
|
3. Update `explorer.py` to use svd_labels for fallback labels |
||||||
|
4. Add 1D party position charts for components 3-10 |
||||||
|
5. Update tests |
||||||
|
6. Verify flip directions are correct for all components |
||||||
Loading…
Reference in new issue