fix: per-window Y-axis correction for political compass
The global orientation check using party centroids averaged across all windows was insufficient — individual windows (notably 2023) could still have conservative parties above progressive ones on the Y-axis. Added a per-window flip in compute_2d_axes (PCA branch) that checks prog_avg_y vs cons_avg_y for each window independently and negates all Y values in that window when cons > prog. Flipped window IDs are stored in axis_def['y_flipped_windows'] for diagnostics. Moved the canonical party set definitions outside the orientation try- block so they are always in scope for the per-window correction. Added test_per_window_y_orientation to cover the case where one window is globally fine but locally inverted.
This commit is contained in:
+94
-29
@@ -257,38 +257,40 @@ def compute_2d_axes(
|
||||
"pca_residual_used": bool(pca_residual or evr1 > 0.85),
|
||||
}
|
||||
|
||||
# Canonical party sets used for axis orientation (global and per-window).
|
||||
# Defined outside the try-block so they're always in scope.
|
||||
right_parties = {
|
||||
"PVV",
|
||||
"VVD",
|
||||
"FVD",
|
||||
"BBB",
|
||||
"JA21",
|
||||
"Nieuw Sociaal Contract",
|
||||
}
|
||||
left_parties = {"SP", "PvdA", "GL", "GroenLinks", "GroenLinks-PvdA", "DENK"}
|
||||
cons_parties = {
|
||||
"PVV",
|
||||
"VVD",
|
||||
"FVD",
|
||||
"CDA",
|
||||
"SGP",
|
||||
"BBB",
|
||||
"JA21",
|
||||
"Nieuw Sociaal Contract",
|
||||
}
|
||||
prog_parties = {
|
||||
"GL",
|
||||
"GroenLinks",
|
||||
"PvdA",
|
||||
"PvdD",
|
||||
"SP",
|
||||
"GroenLinks-PvdA",
|
||||
"DENK",
|
||||
}
|
||||
|
||||
# Ensure consistent left/right and progressive/conservative orientation
|
||||
# by checking canonical party centroids and flipping axis signs if needed.
|
||||
try:
|
||||
right_parties = {
|
||||
"PVV",
|
||||
"VVD",
|
||||
"FVD",
|
||||
"BBB",
|
||||
"JA21",
|
||||
"Nieuw Sociaal Contract",
|
||||
}
|
||||
left_parties = {"SP", "PvdA", "GL", "GroenLinks", "GroenLinks-PvdA", "DENK"}
|
||||
cons_parties = {
|
||||
"PVV",
|
||||
"VVD",
|
||||
"FVD",
|
||||
"CDA",
|
||||
"SGP",
|
||||
"BBB",
|
||||
"JA21",
|
||||
"Nieuw Sociaal Contract",
|
||||
}
|
||||
prog_parties = {
|
||||
"GL",
|
||||
"GroenLinks",
|
||||
"PvdA",
|
||||
"PvdD",
|
||||
"SP",
|
||||
"GroenLinks-PvdA",
|
||||
"DENK",
|
||||
}
|
||||
|
||||
# Build mapping of entity -> vector from stacked matrix M
|
||||
ent_to_vec = {ent: vec for (wid, ent), vec in zip(entity_index, M)}
|
||||
|
||||
@@ -367,6 +369,69 @@ def compute_2d_axes(
|
||||
y = float(np.dot(v_centered, axes["y_axis"]))
|
||||
positions_by_window[wid][ent] = (x, y)
|
||||
|
||||
# Per-window Y-axis correction: ensure "positive Y = progressive" holds
|
||||
# for EACH window individually. The global orientation check above uses
|
||||
# centroids averaged across all windows, so individual windows (e.g. an
|
||||
# election year with few returning MPs) can still be inverted. We check
|
||||
# each window and flip its Y values if conservative parties sit above
|
||||
# progressive ones.
|
||||
try:
|
||||
# Fetch mp_metadata once for the per-window check
|
||||
_mp_meta_rows: List[Tuple[str, str]] = []
|
||||
try:
|
||||
conn = duckdb.connect(db_path)
|
||||
_mp_meta_rows = conn.execute(
|
||||
"SELECT mp_name, party FROM mp_metadata"
|
||||
).fetchall()
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass # no DB available (e.g. unit tests without metadata)
|
||||
|
||||
# Map mp_name -> party
|
||||
_mp_party: Dict[str, str] = {r[0]: r[1] for r in _mp_meta_rows}
|
||||
|
||||
y_flipped_windows: set = set()
|
||||
for wid, pos_dict in positions_by_window.items():
|
||||
prog_ys = []
|
||||
cons_ys = []
|
||||
for ent, (x_val, y_val) in pos_dict.items():
|
||||
# direct party entity
|
||||
if ent in prog_parties:
|
||||
prog_ys.append(y_val)
|
||||
elif ent in cons_parties:
|
||||
cons_ys.append(y_val)
|
||||
# individual MP via metadata lookup
|
||||
party = _mp_party.get(ent)
|
||||
if party is not None:
|
||||
if party in prog_parties:
|
||||
prog_ys.append(y_val)
|
||||
elif party in cons_parties:
|
||||
cons_ys.append(y_val)
|
||||
|
||||
if prog_ys and cons_ys:
|
||||
prog_avg = float(np.mean(prog_ys))
|
||||
cons_avg = float(np.mean(cons_ys))
|
||||
if cons_avg > prog_avg:
|
||||
_logger.info(
|
||||
"Per-window Y flip for window %s: "
|
||||
"prog_avg_y=%.3f cons_avg_y=%.3f — negating Y",
|
||||
wid,
|
||||
prog_avg,
|
||||
cons_avg,
|
||||
)
|
||||
positions_by_window[wid] = {
|
||||
ent: (x_val, -y_val)
|
||||
for ent, (x_val, y_val) in pos_dict.items()
|
||||
}
|
||||
y_flipped_windows.add(wid)
|
||||
|
||||
axes["y_flipped_windows"] = y_flipped_windows
|
||||
|
||||
except Exception:
|
||||
_logger.debug(
|
||||
"Per-window Y orientation check failed; leaving per-window Y as-is"
|
||||
)
|
||||
|
||||
return positions_by_window, axes
|
||||
|
||||
elif method == "anchor":
|
||||
|
||||
Reference in New Issue
Block a user