fix(overton): strict centrist definition + left support analysis

- Reclassified centrist to {D66, CDA, CU, NSC} — removing VVD/BBB
  which are center-right coalition partners
- Added centrist_support_strict (0.251→0.507, d=+0.65), center_right_support,
  and left_support_mp columns via migration script
- Figure 1 now shows center-right (VVD/BBB) support as orange dashed line
- New Figure 3: bar chart of left-party support for right-wing motions
  (0.268→0.202, left opposition hardened)
- New report Section 6 covering left-wing support trends
- All analysis now uses strict centrist definition throughout
This commit is contained in:
2026-05-09 00:45:38 +02:00
parent e478235c84
commit 2a081ade25
5 changed files with 241 additions and 86 deletions
+41 -21
View File
@@ -1,15 +1,32 @@
"""Add MP-weighted centrist_support column to right_wing_motions.
"""Add MP-weighted support columns to right_wing_motions.
The existing centrist_support is party-bloc-level (fraction of centrist
parties where >=50% of MPs voted voor). This adds centrist_support_mp which
is the fraction of individual centrist MPs who voted voor, weighted by party
size.
Adds centrist_support_mp, centrist_support_strict, center_right_support,
and left_support_mp — all computed as the fraction of individual MPs
within each party set who voted 'voor'.
"""
from __future__ import annotations
import duckdb
import sys
from pathlib import Path
ROOT = Path(__file__).parent.parent.parent.resolve()
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
import duckdb
from analysis.config import CANONICAL_LEFT
CANONICAL_CENTRIST = frozenset({"VVD", "D66", "CDA", "NSC", "BBB", "CU"})
CANONICAL_CENTRIST_STRICT = frozenset({"D66", "CDA", "CU", "NSC"})
CANONICAL_CENTER_RIGHT = frozenset({"VVD", "BBB"})
COLUMNS = [
("centrist_support_mp", CANONICAL_CENTRIST),
("centrist_support_strict", CANONICAL_CENTRIST_STRICT),
("center_right_support", CANONICAL_CENTER_RIGHT),
("left_support_mp", CANONICAL_LEFT),
]
def compute_mp_support(
@@ -51,16 +68,18 @@ def main(db_path: str = "data/motions.db"):
pv = mv.setdefault(party, {"voor": 0, "tegen": 0, "afwezig": 0})
pv[vote] = pv.get(vote, 0) + n
# Add column
col_check = con.execute(
"SELECT column_name FROM information_schema.columns "
"WHERE table_name = 'right_wing_motions' AND column_name = 'centrist_support_mp'"
).fetchone()
if col_check is None:
con.execute(
"ALTER TABLE right_wing_motions ADD COLUMN centrist_support_mp DOUBLE"
)
print("Added centrist_support_mp column")
# Add columns if missing
for col_name, _party_set in COLUMNS:
col_check = con.execute(
"SELECT column_name FROM information_schema.columns "
"WHERE table_name = 'right_wing_motions' AND column_name = ?",
[col_name],
).fetchone()
if col_check is None:
con.execute(
f"ALTER TABLE right_wing_motions ADD COLUMN {col_name} DOUBLE"
)
print(f"Added {col_name} column")
# Update rows
rows = con.execute(
@@ -74,11 +93,12 @@ def main(db_path: str = "data/motions.db"):
if votes is None:
skipped += 1
continue
cs_mp = compute_mp_support(votes, CANONICAL_CENTRIST)
con.execute(
"UPDATE right_wing_motions SET centrist_support_mp = ? WHERE motion_id = ?",
[cs_mp, motion_id],
)
for col_name, party_set in COLUMNS:
val = compute_mp_support(votes, party_set)
con.execute(
f"UPDATE right_wing_motions SET {col_name} = ? WHERE motion_id = ?",
[val, motion_id],
)
updated += 1
con.close()
@@ -100,7 +100,8 @@ def compute_yearly_rw_metrics(con: duckdb.DuckDBPyConnection) -> dict[int, dict]
r.motion_id,
r.year,
r.title,
r.centrist_support,
r.centrist_support_strict,
r.center_right_support,
r.right_support,
r.left_opposition,
r.category,
@@ -118,7 +119,8 @@ def compute_yearly_rw_metrics(con: duckdb.DuckDBPyConnection) -> dict[int, dict]
yearly: dict[int, dict[str, Any]] = {}
for year in range(YEAR_MIN, YEAR_MAX + 1):
yearly[year] = {
"centrist_support": [],
"centrist_support_strict": [],
"center_right_support": [],
"right_support": [],
"left_opposition": [],
"extremity": [],
@@ -128,10 +130,11 @@ def compute_yearly_rw_metrics(con: duckdb.DuckDBPyConnection) -> dict[int, dict]
"motion_ids": [],
}
for mid, year, title, cs, rs, lo, cat, ext, vr_json, wm in rows:
for mid, year, title, cst, crs, rs, lo, cat, ext, vr_json, wm in rows:
if year is None or year < YEAR_MIN or year > YEAR_MAX:
continue
yearly[year]["centrist_support"].append(cs if cs is not None else np.nan)
yearly[year]["centrist_support_strict"].append(cst if cst is not None else np.nan)
yearly[year]["center_right_support"].append(crs if crs is not None else np.nan)
yearly[year]["right_support"].append(rs if rs is not None else np.nan)
yearly[year]["left_opposition"].append(lo if lo is not None else np.nan)
yearly[year]["extremity"].append(ext if ext is not None else np.nan)
@@ -286,7 +289,7 @@ def compute_opposition_metrics(
opp: dict[int, dict[str, list]] = {}
for year in range(YEAR_MIN, YEAR_MAX + 1):
opp[year] = {
"centrist_support": [],
"centrist_support_strict": [],
"extremity": [],
"passed": [],
"n": 0,
@@ -310,7 +313,7 @@ def compute_opposition_metrics(
if submitter_party in coal:
continue
opp[year]["centrist_support"].append(d["centrist_support"][idx])
opp[year]["centrist_support_strict"].append(d["centrist_support_strict"][idx])
opp[year]["extremity"].append(d["extremity"][idx])
opp[year]["passed"].append(d["passed"][idx])
opp[year]["n"] += 1
@@ -326,14 +329,14 @@ def compute_domain_metrics(
non_mig: dict[int, dict[str, list]] = {}
for year in range(YEAR_MIN, YEAR_MAX + 1):
mig[year] = {"centrist_support": [], "extremity": [], "passed": [], "n": 0}
non_mig[year] = {"centrist_support": [], "extremity": [], "passed": [], "n": 0}
mig[year] = {"centrist_support_strict": [], "extremity": [], "passed": [], "n": 0}
non_mig[year] = {"centrist_support_strict": [], "extremity": [], "passed": [], "n": 0}
for year, d in yearly_raw.items():
for idx in range(len(d["titles"])):
cat = d["categories"][idx]
target = mig if cat == "asiel/vreemdelingen" else non_mig
target[year]["centrist_support"].append(d["centrist_support"][idx])
target[year]["centrist_support_strict"].append(d["centrist_support_strict"][idx])
target[year]["extremity"].append(d["extremity"][idx])
target[year]["passed"].append(d["passed"][idx])
target[year]["n"] += 1
@@ -361,7 +364,7 @@ def compute_extremity_stratified(
period = "pre-2024" if year < BREAK_YEAR else "post-2024"
for idx in range(len(d["titles"])):
ext = d["extremity"][idx]
cs = d["centrist_support"][idx]
cs = d["centrist_support_strict"][idx]
if np.isnan(ext) or cs is None or (isinstance(cs, float) and np.isnan(cs)):
continue
if ext < 2:
@@ -377,17 +380,33 @@ def compute_extremity_stratified(
return pre_post
def compute_left_support_yearly(con: duckdb.DuckDBPyConnection) -> dict[int, dict]:
"""Query left_support_mp yearly averages from right_wing_motions."""
rows = con.execute("""
SELECT year, AVG(left_support_mp), COUNT(*)
FROM right_wing_motions
WHERE classified = TRUE AND left_support_mp IS NOT NULL
GROUP BY year ORDER BY year
""").fetchall()
result: dict[int, dict] = {}
for year, avg, n in rows:
year = int(year)
result[year] = {"mean_left_support": avg, "n": n}
return result
def yearly_summary(yearly: dict[int, dict]) -> dict[int, dict]:
"""Compute mean values from raw lists."""
summary: dict[int, dict] = {}
for year, d in yearly.items():
s: dict[str, Any] = {}
for key in ["centrist_support", "right_support", "left_opposition", "extremity"]:
for key in ["centrist_support_strict", "center_right_support", "right_support", "left_opposition", "extremity"]:
vals = [v for v in d.get(key, []) if not (isinstance(v, float) and np.isnan(v))]
s[f"mean_{key}"] = np.mean(vals) if vals else float("nan")
passes = [p for p in d.get("passed", []) if p is not None]
s["pass_rate"] = sum(passes) / len(passes) if passes else float("nan")
s["n"] = len(d.get("motion_ids", d.get("centrist_support", [])))
s["n"] = len(d.get("motion_ids", d.get("centrist_support_strict", [])))
summary[year] = s
return summary
@@ -486,16 +505,18 @@ def create_figure_1(
colour_non_mig = "#4CAF50"
colour_baseline = "#9E9E9E"
ax.plot(years_arr, _vals(yearly_sum, "mean_centrist_support"),
ax.plot(years_arr, _vals(yearly_sum, "mean_centrist_support_strict"),
marker="o", color=colour_rw, linewidth=2, label="All right-wing", zorder=5)
ax.plot(years_arr, _vals(opp_sum, "mean_centrist_support"),
ax.plot(years_arr, _vals(opp_sum, "mean_centrist_support_strict"),
marker="s", color=colour_opp, linewidth=1.5, linestyle="--", label="Opposition-only", zorder=4)
ax.plot(years_arr, _vals(mig_sum, "mean_centrist_support"),
ax.plot(years_arr, _vals(mig_sum, "mean_centrist_support_strict"),
marker="^", color=colour_mig, linewidth=1.5, linestyle=":", label="Migration", zorder=3)
ax.plot(years_arr, _vals(non_mig_sum, "mean_centrist_support"),
ax.plot(years_arr, _vals(non_mig_sum, "mean_centrist_support_strict"),
marker="v", color=colour_non_mig, linewidth=1.5, linestyle="-.", label="Non-migration", zorder=2)
ax.plot(years_arr, _vals(baseline_sum, "mean_centrist_support"),
color=colour_baseline, linewidth=1, linestyle="dashed", alpha=0.7, zorder=1, label="All motions (baseline)")
ax.plot(years_arr, _vals(yearly_sum, "mean_center_right_support"),
marker="D", color="#FF8F00", linewidth=1.5, linestyle="--", label="Center-right (VVD/BBB)", zorder=3)
ax.axvline(x=BREAK_YEAR - 0.5, color="black", linestyle=":", alpha=0.5, linewidth=1)
ax.annotate("2024", xy=(BREAK_YEAR - 0.3, ax.get_ylim()[1] * 0.95 if ax.get_ylim()[1] > 0 else 0.95),
@@ -506,8 +527,8 @@ def create_figure_1(
bbox=dict(boxstyle="round", facecolor="white", alpha=0.8))
ax.set_xlabel("Year")
ax.set_ylabel("Centrist support (fraction of parties)")
ax.set_title("Centrist Support for Right-Wing Motions Over Time", fontweight="bold")
ax.set_ylabel("Centrist support (strict — fraction of parties)")
ax.set_title("Centrist Support (Strict) for Right-Wing Motions Over Time", fontweight="bold")
ax.legend(loc="lower right", fontsize=8, ncol=2)
ax.set_ylim(0, 1.05)
ax.grid(True, alpha=0.3)
@@ -594,10 +615,10 @@ def create_figure_2(
pre_means_a = np.array(pre_means)
post_means_a = np.array(post_means)
pre_lower = pre_means_a - np.array(pre_p25s)
pre_upper = np.array(pre_p75s) - pre_means_a
post_lower = post_means_a - np.array(post_p25s)
post_upper = np.array(post_p75s) - post_means_a
pre_lower = np.maximum(pre_means_a - np.array(pre_p25s), 0)
pre_upper = np.maximum(np.array(pre_p75s) - pre_means_a, 0)
post_lower = np.maximum(post_means_a - np.array(post_p25s), 0)
post_upper = np.maximum(np.array(post_p75s) - post_means_a, 0)
pre_yerr = np.vstack([pre_lower, pre_upper])
post_yerr = np.vstack([post_lower, post_upper])
@@ -616,7 +637,7 @@ def create_figure_2(
f"N={n}", ha="center", va="bottom", fontsize=8, fontweight="bold")
overall_cs_mean = np.average(
_vals(yearly_sum, "mean_centrist_support"),
_vals(yearly_sum, "mean_centrist_support_strict"),
weights=_vals(yearly_sum, "n"),
)
ax2.axhline(y=overall_cs_mean, color="grey", linestyle="--", alpha=0.7, linewidth=1,
@@ -638,6 +659,47 @@ def create_figure_2(
return path
def create_figure_3(
left_yearly: dict[int, dict],
) -> str:
"""Figure 3: Left-party support for right-wing motions (bar chart)."""
years = sorted(left_yearly.keys())
years_arr = np.array(years)
means = np.array([left_yearly[y]["mean_left_support"] for y in years])
ns = np.array([left_yearly[y]["n"] for y in years])
# Weighted all-years mean
overall_mean = np.average(means, weights=ns) if ns.sum() > 0 else 0.0
fig, ax = plt.subplots(figsize=(12, 6))
bars = ax.bar(years_arr, means, color="#1565C0", edgecolor="white", alpha=0.9)
for bar, n in zip(bars, ns):
ax.text(bar.get_x() + bar.get_width() / 2, bar.get_height() + 0.005,
f"N={int(n)}", ha="center", va="bottom", fontsize=8)
ax.axhline(y=overall_mean, color="#D32F2F", linestyle="--", alpha=0.8, linewidth=1,
label=f"Weighted mean ({overall_mean:.3f})")
ax.axvline(x=BREAK_YEAR - 0.5, color="black", linestyle=":", alpha=0.5, linewidth=1)
ax.annotate("2024", xy=(BREAK_YEAR - 0.3, ax.get_ylim()[1] * 0.95),
fontsize=9, color="black", alpha=0.7)
ax.set_xlabel("Year")
ax.set_ylabel("Mean left_support_mp")
ax.set_title("Left-wing party support for right-wing motions", fontweight="bold")
ax.legend(fontsize=9)
ax.set_xticks(years_arr)
ax.set_xticklabels([str(y) for y in years], rotation=45)
ax.grid(True, alpha=0.3, axis="y")
plt.tight_layout()
path = str(REPORTS_DIR / "breakpoint_figure_3.png")
fig.savefig(path, dpi=150, bbox_inches="tight")
plt.close(fig)
logger.info("Saved Figure 3 to %s", path)
return path
def generate_report(
yearly_sum: dict[int, dict],
opp_sum: dict[int, dict],
@@ -647,8 +709,10 @@ def generate_report(
ext_stratified: dict[str, dict[str, list]],
yearly_raw: dict[int, dict],
opp_raw: dict[int, dict],
left_yearly: dict[int, dict],
fig1_path: str,
fig2_path: str,
fig3_path: str,
audit_sample: list[dict],
audit_notes: str = "",
) -> str:
@@ -674,8 +738,8 @@ def generate_report(
opp_post_ext = []
for y, d in yearly_raw.items():
for idx in range(len(d.get("centrist_support", []))):
cs = d["centrist_support"][idx]
for idx in range(len(d.get("centrist_support_strict", []))):
cs = d["centrist_support_strict"][idx]
ext = d["extremity"][idx]
if not (isinstance(cs, float) and np.isnan(cs)):
if y < BREAK_YEAR:
@@ -689,8 +753,8 @@ def generate_report(
rw_post_ext.append(ext)
for y, d in opp_raw.items():
for idx in range(len(d.get("centrist_support", []))):
cs = d["centrist_support"][idx]
for idx in range(len(d.get("centrist_support_strict", []))):
cs = d["centrist_support_strict"][idx]
ext = d["extremity"][idx]
if not (isinstance(cs, float) and np.isnan(cs)):
if y < BREAK_YEAR:
@@ -710,11 +774,11 @@ def generate_report(
d_opp_ext = cohens_d(np.array(opp_pre_ext), np.array(opp_post_ext)) if opp_pre_ext and opp_post_ext else float("nan")
# Yearly summary table
yearly_table = "| Year | N (RW) | Centrist Support | Extremity | Right Support | Left Opp. |\n"
yearly_table += "|------|--------|-----------------|-----------|---------------|----------|\n"
yearly_table = "| Year | N (RW) | Centrist Support (Strict) | Extremity | Right Support | Left Opp. |\n"
yearly_table += "|------|--------|---------------------------|-----------|---------------|----------|\n"
for y in years:
n = _val(yearly_sum, y, "n")
cs = _val(yearly_sum, y, "mean_centrist_support")
cs = _val(yearly_sum, y, "mean_centrist_support_strict")
ext = _val(yearly_sum, y, "mean_extremity")
rs = _val(yearly_sum, y, "mean_right_support")
lo = _val(yearly_sum, y, "mean_left_opposition")
@@ -817,8 +881,8 @@ def generate_report(
]
for domain_name, domain_sum in [("Migration", mig_sum), ("Non-migration", non_mig_sum)]:
pre_cs = np.nanmean([_val(domain_sum, y, "mean_centrist_support") for y in pre_years])
post_cs = np.nanmean([_val(domain_sum, y, "mean_centrist_support") for y in post_years])
pre_cs = np.nanmean([_val(domain_sum, y, "mean_centrist_support_strict") for y in pre_years])
post_cs = np.nanmean([_val(domain_sum, y, "mean_centrist_support_strict") for y in post_years])
lines.append(
f"| {domain_name} | {pre_cs:.3f} | {post_cs:.3f} | {post_cs - pre_cs:+.3f} |"
)
@@ -835,19 +899,55 @@ def generate_report(
"If centrist support rose uniformly across all buckets, the shift is about volume",
"(more motions) rather than tolerance. If only the 1-2 bucket rose, right-wing",
"parties filed milder motions post-2024 and the 'shift' is illusory.",
]
# Section 6: Left support for right-wing motions
left_years_sorted = sorted(left_yearly.keys())
left_pre_years_list = [y for y in pre_years if y in left_yearly]
left_post_years_list = [y for y in post_years if y in left_yearly]
left_pre_vals = [left_yearly[y]["mean_left_support"] for y in left_pre_years_list]
left_post_vals = [left_yearly[y]["mean_left_support"] for y in left_post_years_list]
left_pre_mean = np.mean(left_pre_vals) if left_pre_vals else float("nan")
left_post_mean = np.mean(left_post_vals) if left_post_vals else float("nan")
left_delta = left_post_mean - left_pre_mean
left_table = "| Year | N | Mean left_support_mp |\n"
left_table += "|------|---|---------------------|\n"
for y in left_years_sorted:
ls = left_yearly[y]["mean_left_support"]
n = left_yearly[y]["n"]
left_table += f"| {y} | {int(n)} | {ls:.4f} |\n"
lines += [
"",
"## 6. Manual Extremity Audit",
"## 6. Left-wing support for right-wing motions",
"",
left_table,
"",
f"| Metric | Pre-2024 Mean | Post-2024 Mean | Δ |",
f"|--------|--------------|---------------|-----|",
f"| Left Support (MP) | {left_pre_mean:.4f} | {left_post_mean:.4f} | {left_delta:+.4f} |",
"",
f"**Interpretation:** Left parties moved from {left_pre_mean:.1%} to {left_post_mean:.1%} "
f"support — a {abs(left_delta):.1f} point shift. "
"Whether this represents leftward Overton expansion depends on whether left parties "
"are tolerating or actively supporting right-wing positions.",
"",
f"![Figure 3: Left-wing party support for right-wing motions]({Path(fig3_path).name})",
"",
"## 7. Manual Extremity Audit",
"",
audit_notes,
"",
audit_table,
"",
"## 7. Limitations",
"## 8. Limitations",
"",
"- **Small-N time series:** 8 pre-2024 years and at most 3 post-2024 years (2026 is partial).",
" Effect sizes are descriptive, not confirmatory.",
"- **LLM extremity scores:** Content-based, not independently validated beyond the",
" manual audit above. See §6 for agreement rate and noted biases.",
" manual audit above. See §7 for agreement rate and noted biases.",
"- **Coalition composition:** Hardcoded per year. 2024 is ambiguous (Rutte IV until July,",
" Schoof thereafter). Early 2024 motions may be miscoded as Schoof-era.",
"- **Submitter party identification:** Parsed from motion title prefixes (e.g.,",
@@ -856,12 +956,13 @@ def generate_report(
"- **Keyword penetration not analyzed:** The right-wing keyword set was derived",
" differentially from right-wing motions, making it circular for adoption analysis.",
"",
"## 8. Figures",
"## 9. Figures",
"",
f"![Figure 1: Centrist Support Over Time]({Path(fig1_path).name})",
f"![Figure 2: Extremity Trends and Stratified Centrist Support]({Path(fig2_path).name})",
f"![Figure 3: Left-wing party support for right-wing motions]({Path(fig3_path).name})",
"",
"## 9. Conclusion",
"## 10. Conclusion",
"",
"*(Fill in after reviewing all indicators and audit results.)*",
]
@@ -895,6 +996,9 @@ def main() -> int:
logger.info("Computing extremity-stratified pass rates...")
ext_stratified = compute_extremity_stratified(yearly_raw)
logger.info("Computing left-support yearly averages...")
left_yearly = compute_left_support_yearly(con)
con.close()
yearly_sum = yearly_summary(yearly_raw)
@@ -909,6 +1013,9 @@ def main() -> int:
logger.info("Generating Figure 2...")
fig2_path = create_figure_2(yearly_sum, opp_sum, mig_sum, non_mig_sum, ext_stratified)
logger.info("Generating Figure 3...")
fig3_path = create_figure_3(left_yearly)
logger.info("Sampling motions for manual audit...")
audit_sample = sample_audit(yearly_raw)
print_audit(audit_sample)
@@ -931,8 +1038,10 @@ def main() -> int:
ext_stratified=ext_stratified,
yearly_raw=yearly_raw,
opp_raw=opp_raw,
left_yearly=left_yearly,
fig1_path=fig1_path,
fig2_path=fig2_path,
fig3_path=fig3_path,
audit_sample=audit_sample,
audit_notes=audit_notes,
)
@@ -940,6 +1049,7 @@ def main() -> int:
print(f"\nReport: {report_path}")
print(f"Figure 1: {fig1_path}")
print(f"Figure 2: {fig2_path}")
print(f"Figure 3: {fig3_path}")
return 0