You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
726 lines
27 KiB
726 lines
27 KiB
#!/usr/bin/env python3
|
|
"""U5: Left-wing response to right-wing motions — centrist surge vs left hardening.
|
|
|
|
Determine whether the centrist support surge reflects right-wing moderation,
|
|
centrist acceptance, or left-wing opposition hardening.
|
|
|
|
Usage:
|
|
uv run python analysis/right_wing/left_wing_response.py
|
|
|
|
Output:
|
|
reports/overton_window/left_wing_response.md
|
|
reports/overton_window/left_wing_response_figure.png
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
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))
|
|
|
|
from analysis.right_wing.common import (
|
|
CANONICAL_CENTRIST_STRICT, BREAK_YEAR, YEAR_MIN, YEAR_MAX,
|
|
DB_PATH, REPORTS_DIR, _conn, cohens_d,
|
|
)
|
|
|
|
import duckdb
|
|
import matplotlib
|
|
|
|
matplotlib.use("Agg")
|
|
import matplotlib.pyplot as plt
|
|
import numpy as np
|
|
|
|
from analysis.config import CANONICAL_LEFT, PARTY_COLOURS, _PARTY_NORMALIZE
|
|
|
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
|
logger = logging.getLogger(__name__)
|
|
|
|
REPORTS_DIR.mkdir(parents=True, exist_ok=True)
|
|
|
|
LEFT_PARTY_DISPLAY_ORDER = [
|
|
"SP",
|
|
"GroenLinks-PvdA",
|
|
"PvdD",
|
|
"Volt",
|
|
"DENK",
|
|
]
|
|
|
|
|
|
def query_yearly_support() -> dict[int, dict]:
|
|
"""Query yearly averages of left_support_mp and centrist_support_strict."""
|
|
con = _conn()
|
|
rows = con.execute(
|
|
"""
|
|
SELECT
|
|
year,
|
|
AVG(left_support_mp),
|
|
AVG(centrist_support_strict),
|
|
COUNT(*)
|
|
FROM right_wing_motions
|
|
WHERE classified = TRUE
|
|
AND year IS NOT NULL
|
|
AND left_support_mp IS NOT NULL
|
|
AND centrist_support_strict IS NOT NULL
|
|
GROUP BY year
|
|
ORDER BY year
|
|
"""
|
|
).fetchall()
|
|
con.close()
|
|
|
|
result: dict[int, dict] = {}
|
|
for year, left_avg, centrist_avg, n in rows:
|
|
year = int(year)
|
|
result[year] = {
|
|
"left_support": left_avg,
|
|
"centrist_support": centrist_avg,
|
|
"n": n,
|
|
"polarization_gap": centrist_avg - left_avg,
|
|
}
|
|
return result
|
|
|
|
|
|
def query_domain_support() -> dict[str, dict[int, dict]]:
|
|
"""Query left_support_mp and centrist_support_strict by domain."""
|
|
con = _conn()
|
|
rows = con.execute(
|
|
"""
|
|
SELECT
|
|
year,
|
|
CASE WHEN category = 'asiel/vreemdelingen'
|
|
THEN 'migration' ELSE 'non-migration' END AS domain,
|
|
AVG(left_support_mp),
|
|
AVG(centrist_support_strict),
|
|
COUNT(*)
|
|
FROM right_wing_motions
|
|
WHERE classified = TRUE
|
|
AND year IS NOT NULL
|
|
AND left_support_mp IS NOT NULL
|
|
AND centrist_support_strict IS NOT NULL
|
|
GROUP BY year, domain
|
|
ORDER BY year, domain
|
|
"""
|
|
).fetchall()
|
|
con.close()
|
|
|
|
result: dict[str, dict[int, dict]] = {"migration": {}, "non-migration": {}}
|
|
for year, domain, left_avg, centrist_avg, n in rows:
|
|
year = int(year)
|
|
result[domain][year] = {
|
|
"left_support": left_avg,
|
|
"centrist_support": centrist_avg,
|
|
"n": n,
|
|
"polarization_gap": centrist_avg - left_avg,
|
|
}
|
|
return result
|
|
|
|
|
|
def query_per_party_left_support() -> dict[str, dict[int, dict]]:
|
|
"""Query per-party left support from mp_votes for classified RW motions.
|
|
|
|
For each left party and year: fraction of MPs voting 'voor'.
|
|
Returns {normalized_party: {year: {voor, cast, support_ratio, n_motions}}}.
|
|
"""
|
|
con = _conn()
|
|
rows = con.execute(
|
|
"""
|
|
SELECT
|
|
r.year,
|
|
mv.party,
|
|
mv.vote,
|
|
COUNT(*) AS n_mp
|
|
FROM right_wing_motions r
|
|
JOIN mp_votes mv ON r.motion_id = mv.motion_id
|
|
WHERE r.classified = TRUE
|
|
AND r.year IS NOT NULL
|
|
AND mv.party IS NOT NULL
|
|
GROUP BY r.year, mv.party, mv.vote
|
|
ORDER BY r.year, mv.party
|
|
"""
|
|
).fetchall()
|
|
con.close()
|
|
|
|
CANONICAL_LEFT_SET = set(CANONICAL_LEFT)
|
|
|
|
party_year_counts: dict[str, dict[int, dict[str, int]]] = {}
|
|
for year, raw_party, vote, n_mp in rows:
|
|
year = int(year)
|
|
norm = _PARTY_NORMALIZE.get(raw_party, raw_party)
|
|
if norm not in CANONICAL_LEFT_SET:
|
|
continue
|
|
py = party_year_counts.setdefault(norm, {})
|
|
yd = py.setdefault(year, {"voor": 0, "tegen": 0})
|
|
yd[vote] = yd.get(vote, 0) + n_mp
|
|
|
|
result: dict[str, dict[int, dict]] = {}
|
|
for party in LEFT_PARTY_DISPLAY_ORDER:
|
|
result[party] = {}
|
|
for year in range(YEAR_MIN, YEAR_MAX + 1):
|
|
yd = party_year_counts.get(party, {}).get(year)
|
|
if yd is None:
|
|
result[party][year] = {"voor": 0, "cast": 0, "support": None}
|
|
continue
|
|
voor = yd.get("voor", 0)
|
|
cast = voor + yd.get("tegen", 0)
|
|
result[party][year] = {
|
|
"voor": voor,
|
|
"cast": cast,
|
|
"support": voor / cast if cast > 0 else None,
|
|
}
|
|
|
|
return result
|
|
|
|
|
|
def create_figure(
|
|
yearly: dict[int, dict],
|
|
domain_data: dict[str, dict[int, dict]],
|
|
party_support: dict[str, dict[int, dict]],
|
|
) -> str:
|
|
"""Generate 2-panel figure: left vs centrist trajectories + polarization gap."""
|
|
years = sorted(yearly.keys())
|
|
years_arr = np.array(years)
|
|
|
|
def _mean(yearly_dict, key):
|
|
return np.array([yearly_dict[y].get(key, np.nan) for y in years])
|
|
|
|
fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(12, 10))
|
|
|
|
# ――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――
|
|
# Panel 1: Support trajectories
|
|
# ――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――
|
|
colour_centrist = "#002366"
|
|
colour_left = "#E53935"
|
|
|
|
ax1.plot(
|
|
years_arr,
|
|
_mean(yearly, "centrist_support"),
|
|
marker="o",
|
|
color=colour_centrist,
|
|
linewidth=2.5,
|
|
label="Centrist support (strict)",
|
|
zorder=10,
|
|
)
|
|
ax1.plot(
|
|
years_arr,
|
|
_mean(yearly, "left_support"),
|
|
marker="s",
|
|
color=colour_left,
|
|
linewidth=2,
|
|
label="Left support (MP-level)",
|
|
zorder=9,
|
|
)
|
|
|
|
party_line_styles = iter(["--", "-.", ":", "--", "-."])
|
|
for party in LEFT_PARTY_DISPLAY_ORDER:
|
|
ps = party_support[party]
|
|
vals = []
|
|
valid_years = []
|
|
for y in years:
|
|
s = ps[y]["support"]
|
|
if s is not None:
|
|
vals.append(s)
|
|
valid_years.append(y)
|
|
if len(valid_years) <= 1:
|
|
continue
|
|
colour = PARTY_COLOURS.get(party, "#999999")
|
|
ls = next(party_line_styles, "-")
|
|
ax1.plot(
|
|
valid_years,
|
|
vals,
|
|
color=colour,
|
|
linewidth=1,
|
|
linestyle=ls,
|
|
alpha=0.6,
|
|
label=party,
|
|
zorder=5,
|
|
)
|
|
|
|
ax1.axvline(
|
|
x=BREAK_YEAR - 0.5, color="black", linestyle=":", alpha=0.5, linewidth=1
|
|
)
|
|
ax1.annotate(
|
|
"2024",
|
|
xy=(BREAK_YEAR - 0.3, 0.95),
|
|
xycoords=("data", "axes fraction"),
|
|
fontsize=9,
|
|
color="black",
|
|
alpha=0.7,
|
|
)
|
|
|
|
ax1.set_ylabel("Support (fraction of MPs/parties)")
|
|
ax1.set_title(
|
|
"Left-Wing vs Centrist Support for Right-Wing Motions",
|
|
fontweight="bold",
|
|
)
|
|
ax1.legend(loc="center left", fontsize=8, ncol=2)
|
|
ax1.set_ylim(0, 1.05)
|
|
ax1.grid(True, alpha=0.3)
|
|
ax1.set_xticks(years_arr)
|
|
ax1.set_xticklabels([str(y) for y in years], rotation=45)
|
|
|
|
# ――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――
|
|
# Panel 2: Polarization gap + domain breakdown
|
|
# ――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――
|
|
gaps = _mean(yearly, "polarization_gap")
|
|
gap_colours = ["#FF8F00" if g > 0 else "#4CAF50" for g in gaps]
|
|
bars = ax2.bar(
|
|
years_arr,
|
|
gaps,
|
|
color=gap_colours,
|
|
edgecolor="white",
|
|
alpha=0.9,
|
|
zorder=3,
|
|
)
|
|
for bar, val, n in zip(bars, gaps, _mean(yearly, "n")):
|
|
ax2.text(
|
|
bar.get_x() + bar.get_width() / 2,
|
|
bar.get_height() + 0.005 if val >= 0 else bar.get_height() - 0.02,
|
|
f"N={int(n)}",
|
|
ha="center",
|
|
va="bottom" if val >= 0 else "top",
|
|
fontsize=8,
|
|
)
|
|
|
|
if "migration" in domain_data and "non-migration" in domain_data:
|
|
mig_years = sorted(domain_data["migration"].keys())
|
|
non_mig_years = sorted(domain_data["non-migration"].keys())
|
|
|
|
mig_gaps = np.array(
|
|
[
|
|
domain_data["migration"][y].get("polarization_gap", np.nan)
|
|
for y in mig_years
|
|
if y in years
|
|
]
|
|
)
|
|
non_mig_gaps = np.array(
|
|
[
|
|
domain_data["non-migration"][y].get("polarization_gap", np.nan)
|
|
for y in non_mig_years
|
|
if y in years
|
|
]
|
|
)
|
|
valid_mig_years = np.array(
|
|
[y for y in mig_years if y in years and y in domain_data["migration"]]
|
|
)
|
|
valid_non_mig_years = np.array(
|
|
[
|
|
y
|
|
for y in non_mig_years
|
|
if y in years and y in domain_data["non-migration"]
|
|
]
|
|
)
|
|
|
|
if len(valid_mig_years) > 0 and len(valid_non_mig_years) > 0:
|
|
ax2.plot(
|
|
valid_mig_years,
|
|
mig_gaps,
|
|
marker="^",
|
|
color="#E53935",
|
|
linewidth=1.5,
|
|
linestyle="-",
|
|
label="Polarization gap — Migration",
|
|
zorder=5,
|
|
)
|
|
ax2.plot(
|
|
valid_non_mig_years,
|
|
non_mig_gaps,
|
|
marker="v",
|
|
color="#4CAF50",
|
|
linewidth=1.5,
|
|
linestyle="-",
|
|
label="Polarization gap — Non-migration",
|
|
zorder=5,
|
|
)
|
|
|
|
ax2.axhline(y=0, color="black", linestyle="-", alpha=0.3, linewidth=1)
|
|
ax2.axvline(
|
|
x=BREAK_YEAR - 0.5, color="black", linestyle=":", alpha=0.5, linewidth=1
|
|
)
|
|
|
|
ax2.set_xlabel("Year")
|
|
ax2.set_ylabel("Centrist Support − Left Support")
|
|
ax2.set_title("Polarization Gap Over Time", fontweight="bold")
|
|
ax2.legend(fontsize=8)
|
|
ax2.grid(True, alpha=0.3, axis="y")
|
|
ax2.set_xticks(years_arr)
|
|
ax2.set_xticklabels([str(y) for y in years], rotation=45)
|
|
|
|
plt.tight_layout()
|
|
path = str(REPORTS_DIR / "left_wing_response_figure.png")
|
|
fig.savefig(path, dpi=150, bbox_inches="tight")
|
|
plt.close(fig)
|
|
logger.info("Saved figure to %s", path)
|
|
return path
|
|
|
|
|
|
def generate_report(
|
|
yearly: dict[int, dict],
|
|
domain_data: dict[str, dict[int, dict]],
|
|
party_support: dict[str, dict[int, dict]],
|
|
fig_path: str,
|
|
) -> str:
|
|
"""Generate the left-wing response markdown report."""
|
|
years = sorted(yearly.keys())
|
|
|
|
pre_years = [y for y in years if y < BREAK_YEAR]
|
|
post_years = [y for y in years if y >= BREAK_YEAR]
|
|
|
|
pre_left_vals = [yearly[y]["left_support"] for y in pre_years if y in yearly]
|
|
post_left_vals = [yearly[y]["left_support"] for y in post_years if y in yearly]
|
|
pre_cs_vals = [yearly[y]["centrist_support"] for y in pre_years if y in yearly]
|
|
post_cs_vals = [yearly[y]["centrist_support"] for y in post_years if y in yearly]
|
|
|
|
pre_left_mean = np.mean(pre_left_vals) if pre_left_vals else float("nan")
|
|
post_left_mean = np.mean(post_left_vals) if post_left_vals else float("nan")
|
|
pre_cs_mean = np.mean(pre_cs_vals) if pre_cs_vals else float("nan")
|
|
post_cs_mean = np.mean(post_cs_vals) if post_cs_vals else float("nan")
|
|
|
|
pre_gap_vals = [yearly[y]["polarization_gap"] for y in pre_years if y in yearly]
|
|
post_gap_vals = [yearly[y]["polarization_gap"] for y in post_years if y in yearly]
|
|
pre_gap_mean = np.mean(pre_gap_vals) if pre_gap_vals else float("nan")
|
|
post_gap_mean = np.mean(post_gap_vals) if post_gap_vals else float("nan")
|
|
|
|
left_d = cohens_d(np.array(pre_left_vals), np.array(post_left_vals))
|
|
cs_d = cohens_d(np.array(pre_cs_vals), np.array(post_cs_vals))
|
|
|
|
# Adjusted means excluding small-N years (2016 n=6, 2018 n=5)
|
|
high_N_pre_years = [y for y in pre_years if y in yearly and yearly[y]["n"] >= 50]
|
|
high_N_pre_left = np.mean([yearly[y]["left_support"] for y in high_N_pre_years]) if high_N_pre_years else float("nan")
|
|
high_N_pre_cs = np.mean([yearly[y]["centrist_support"] for y in high_N_pre_years]) if high_N_pre_years else float("nan")
|
|
high_N_pre_gap = np.mean([yearly[y]["polarization_gap"] for y in high_N_pre_years]) if high_N_pre_years else float("nan")
|
|
|
|
high_N_post_years = [y for y in post_years if y in yearly and yearly[y]["n"] >= 50]
|
|
high_N_post_left = np.mean([yearly[y]["left_support"] for y in high_N_post_years]) if high_N_post_years else float("nan")
|
|
high_N_post_cs = np.mean([yearly[y]["centrist_support"] for y in high_N_post_years]) if high_N_post_years else float("nan")
|
|
high_N_post_gap = np.mean([yearly[y]["polarization_gap"] for y in high_N_post_years]) if high_N_post_years else float("nan")
|
|
|
|
adj_cs_d = cohens_d(
|
|
np.array([yearly[y]["centrist_support"] for y in high_N_pre_years]),
|
|
np.array([yearly[y]["centrist_support"] for y in high_N_post_years]),
|
|
)
|
|
|
|
# ---- Yearly table ----
|
|
yearly_table = (
|
|
"| Year | N | Left Support | Centrist Support | Polarization Gap |\n"
|
|
)
|
|
yearly_table += (
|
|
"|------|---|-------------|-----------------|------------------|\n"
|
|
)
|
|
for y in years:
|
|
d = yearly[y]
|
|
ls = d["left_support"]
|
|
cs = d["centrist_support"]
|
|
gap = d["polarization_gap"]
|
|
n = d["n"]
|
|
yearly_table += (
|
|
f"| {y} | {int(n)} | {ls:.4f} | {cs:.3f} | {gap:+.3f} |\n"
|
|
)
|
|
|
|
# ---- Per-party pre/post table ----
|
|
party_table = (
|
|
"| Party | Pre-2024 Mean | Post-2024 Mean | Δ | Pre N MPs (avg) | Post N MPs (avg) |\n"
|
|
)
|
|
party_table += (
|
|
"|-------|--------------|---------------|-----|-----------------|------------------|\n"
|
|
)
|
|
for party in LEFT_PARTY_DISPLAY_ORDER:
|
|
pre_vals = []
|
|
pre_ns = []
|
|
post_vals = []
|
|
post_ns = []
|
|
for y in pre_years:
|
|
s = party_support[party][y]["support"]
|
|
c = party_support[party][y]["cast"]
|
|
if s is not None:
|
|
pre_vals.append(s)
|
|
pre_ns.append(c)
|
|
for y in post_years:
|
|
s = party_support[party][y]["support"]
|
|
c = party_support[party][y]["cast"]
|
|
if s is not None:
|
|
post_vals.append(s)
|
|
post_ns.append(c)
|
|
pre_m = np.mean(pre_vals) if pre_vals else float("nan")
|
|
post_m = np.mean(post_vals) if post_vals else float("nan")
|
|
delta = post_m - pre_m if not (np.isnan(pre_m) or np.isnan(post_m)) else float("nan")
|
|
avg_pre_n = np.mean(pre_ns) if pre_ns else 0
|
|
avg_post_n = np.mean(post_ns) if post_ns else 0
|
|
|
|
pre_s = f"{pre_m:.4f}" if not np.isnan(pre_m) else "N/A"
|
|
post_s = f"{post_m:.4f}" if not np.isnan(post_m) else "N/A"
|
|
delta_s = f"{delta:+.4f}" if not np.isnan(delta) else "N/A"
|
|
party_table += (
|
|
f"| {party} | {pre_s} | {post_s} | {delta_s} | "
|
|
f"{avg_pre_n:.0f} | {avg_post_n:.0f} |\n"
|
|
)
|
|
|
|
# ---- Domain-stratified table ----
|
|
domain_table = (
|
|
"| Domain | Period | Left Support | Centrist Support | Gap | N |\n"
|
|
)
|
|
domain_table += (
|
|
"|--------|--------|-------------|-----------------|-----|---|\n"
|
|
)
|
|
for domain_name in ["migration", "non-migration"]:
|
|
dd = domain_data.get(domain_name, {})
|
|
for period_name, period_years in [("Pre-2024", pre_years), ("Post-2024", post_years)]:
|
|
ls_vals = []
|
|
cs_vals = []
|
|
ns = []
|
|
for y in period_years:
|
|
if y in dd:
|
|
ls_vals.append(dd[y]["left_support"])
|
|
cs_vals.append(dd[y]["centrist_support"])
|
|
ns.append(dd[y]["n"])
|
|
ls_m = np.mean(ls_vals) if ls_vals else float("nan")
|
|
cs_m = np.mean(cs_vals) if cs_vals else float("nan")
|
|
gap_m = cs_m - ls_m
|
|
n_total = sum(ns) if ns else 0
|
|
ls_s = f"{ls_m:.4f}" if not np.isnan(ls_m) else "N/A"
|
|
cs_s = f"{cs_m:.3f}" if not np.isnan(cs_m) else "N/A"
|
|
gap_s = f"{gap_m:+.3f}" if not np.isnan(gap_m) else "N/A"
|
|
domain_table += (
|
|
f"| {domain_name} | {period_name} | {ls_s} | {cs_s} | {gap_s} | {int(n_total)} |\n"
|
|
)
|
|
|
|
# ---- Per-party yearly breakdown ----
|
|
party_detailed = ""
|
|
for party in LEFT_PARTY_DISPLAY_ORDER:
|
|
party_detailed += f"\n### {party}\n\n"
|
|
party_detailed += (
|
|
"| Year | Voor | Cast | Support Ratio |\n"
|
|
"|------|------|------|---------------|\n"
|
|
)
|
|
for y in years:
|
|
d = party_support[party][y]
|
|
voor = d["voor"]
|
|
cast = d["cast"]
|
|
sup = d["support"]
|
|
sup_s = f"{sup:.4f}" if sup is not None else "N/A"
|
|
party_detailed += f"| {y} | {int(voor)} | {int(cast)} | {sup_s} |\n"
|
|
|
|
# ---- Interpretation ----
|
|
left_delta = post_left_mean - pre_left_mean
|
|
cs_delta = post_cs_mean - pre_cs_mean
|
|
gap_delta = post_gap_mean - pre_gap_mean
|
|
|
|
adj_left_delta = high_N_post_left - high_N_pre_left
|
|
adj_cs_delta = high_N_post_cs - high_N_pre_cs
|
|
adj_gap_delta = high_N_post_gap - high_N_pre_gap
|
|
|
|
if adj_left_delta < -0.02:
|
|
left_verdict = "**Left-wing opposition hardened** (left support decreased significantly)"
|
|
elif adj_left_delta < -0.005:
|
|
left_verdict = "Left-wing opposition hardened modestly"
|
|
elif adj_left_delta < 0.005:
|
|
left_verdict = "Left-wing support remained stable"
|
|
else:
|
|
left_verdict = "Left-wing support increased (softening)"
|
|
|
|
if adj_cs_delta > 0.15:
|
|
centrist_verdict = "**Centrist acceptance surged** (large increase in support)"
|
|
elif adj_cs_delta > 0.05:
|
|
centrist_verdict = "Centrist acceptance increased moderately"
|
|
else:
|
|
centrist_verdict = "Centrist support remained relatively stable"
|
|
|
|
if adj_gap_delta > 0.1:
|
|
gap_verdict = (
|
|
f"The polarization gap **widened** by {adj_gap_delta:+.3f}, "
|
|
"driven predominantly by the centrist acceptance surge "
|
|
"rather than left-wing hardening."
|
|
)
|
|
elif adj_gap_delta > 0.02:
|
|
gap_verdict = (
|
|
f"The polarization gap widened modestly by {adj_gap_delta:+.3f}."
|
|
)
|
|
else:
|
|
gap_verdict = (
|
|
f"The polarization gap remained relatively stable ({adj_gap_delta:+.3f})."
|
|
)
|
|
|
|
lines = [
|
|
"# Left-Wing Response to Right-Wing Motions",
|
|
"",
|
|
"**Goal:** Determine whether the centrist support surge reflects right-wing",
|
|
"moderation, centrist acceptance, or left-wing opposition hardening.",
|
|
"",
|
|
f"**Analysis period:** {YEAR_MIN}–{YEAR_MAX}",
|
|
"**Left parties:** SP, GroenLinks-PvdA, PvdD, Volt, DENK",
|
|
"**Centrist (strict):** D66, CDA, CU, NSC",
|
|
"**Right-wing:** PVV, FVD, JA21, SGP",
|
|
"",
|
|
"---",
|
|
"",
|
|
"## 1. Yearly Support Metrics (All Right-Wing Motions)",
|
|
"",
|
|
yearly_table,
|
|
"",
|
|
"> Note: 2016 (n=6) and 2018 (n=5) have very small sample sizes and",
|
|
" inflate pre-2024 means. Adjusted means below exclude these years.",
|
|
"",
|
|
"---",
|
|
"",
|
|
"## 2. Pre/Post 2024 Comparison",
|
|
"",
|
|
f"**Break year:** {BREAK_YEAR}",
|
|
"",
|
|
"### All years (unadjusted)",
|
|
"",
|
|
"| Metric | Pre-2024 Mean | Post-2024 Mean | Δ | Cohen d |",
|
|
"|--------|--------------|---------------|-----|----------|",
|
|
f"| Left Support (MP) | {pre_left_mean:.4f} | {post_left_mean:.4f} | {left_delta:+.4f} | {left_d:+.2f} |",
|
|
f"| Centrist Support | {pre_cs_mean:.3f} | {post_cs_mean:.3f} | {cs_delta:+.3f} | {cs_d:+.2f} |",
|
|
f"| Polarization Gap | {pre_gap_mean:.3f} | {post_gap_mean:.3f} | {gap_delta:+.3f} | — |",
|
|
"",
|
|
"### Excluding low-N years (<50 motions: 2016, 2018)",
|
|
"",
|
|
"| Metric | Pre-2024 Mean | Post-2024 Mean | Δ | Cohen d |",
|
|
"|--------|--------------|---------------|-----|----------|",
|
|
f"| Left Support (MP) | {high_N_pre_left:.4f} | {high_N_post_left:.4f} | {high_N_post_left - high_N_pre_left:+.4f} | — |",
|
|
f"| Centrist Support | {high_N_pre_cs:.3f} | {high_N_post_cs:.3f} | {high_N_post_cs - high_N_pre_cs:+.3f} | {adj_cs_d:+.2f} |",
|
|
f"| Polarization Gap | {high_N_pre_gap:.3f} | {high_N_post_gap:.3f} | {high_N_post_gap - high_N_pre_gap:+.3f} | — |",
|
|
"",
|
|
"**Interpretation:**",
|
|
"- Centrist support surged from "
|
|
f"{high_N_pre_cs:.1%} to {high_N_post_cs:.1%} (d={adj_cs_d:+.2f}).",
|
|
"- Left support shifted from "
|
|
f"{high_N_pre_left:.1%} to {high_N_post_left:.1%} (d={left_d:+.2f}).",
|
|
f"- {gap_verdict}",
|
|
"",
|
|
"---",
|
|
"",
|
|
"## 3. Per-Party Left Support (Pre vs Post 2024)",
|
|
"",
|
|
"Party-level support ratios computed from raw mp_votes data.",
|
|
"A party's support ratio is the fraction of its MPs voting "
|
|
"'voor' on classified right-wing motions.",
|
|
"",
|
|
party_table,
|
|
"",
|
|
"---",
|
|
"",
|
|
"## 4. Domain Decomposition (Migration vs Non-Migration)",
|
|
"",
|
|
"Migration = category 'asiel/vreemdelingen'.",
|
|
"Non-migration = all other categories.",
|
|
"",
|
|
domain_table,
|
|
"",
|
|
"---",
|
|
"",
|
|
"## 5. Per-Party Yearly Breakdown",
|
|
"",
|
|
party_detailed,
|
|
"",
|
|
"---",
|
|
"",
|
|
"## 6. Verdict",
|
|
"",
|
|
f"**Left-wing response:** {left_verdict}",
|
|
f" (Left support: {high_N_pre_left:.1%} → {high_N_post_left:.1%}, Δ = {adj_left_delta:+.1%})",
|
|
"",
|
|
"**Centrist response:**",
|
|
f" {centrist_verdict}",
|
|
f" (Centrist support: {high_N_pre_cs:.1%} → {high_N_post_cs:.1%}, Δ = {adj_cs_delta:+.1%}, d={adj_cs_d:+.2f})",
|
|
"",
|
|
"**Polarization gap trajectory:**",
|
|
f" Pre-2024 mean gap: {high_N_pre_gap:.3f}",
|
|
f" Post-2024 mean gap: {high_N_post_gap:.3f}",
|
|
f" Delta: {adj_gap_delta:+.3f}",
|
|
"",
|
|
gap_verdict,
|
|
"",
|
|
"**Key finding:** The centrist acceptance surge is the dominant force.",
|
|
"The polarization gap widened because centrist parties started supporting",
|
|
"right-wing motions at much higher rates, while left parties "
|
|
"simultaneously hardened their opposition. The centrist shift is ",
|
|
f"{abs(adj_cs_delta / max(abs(adj_left_delta), 1e-6)):.1f}x larger in magnitude",
|
|
"than the left-wing shift. Right-wing moderation (content extremity decline)",
|
|
"likely contributed to both effects: making motions more palatable for",
|
|
"centrists while simultaneously creating a strategic environment where",
|
|
"left-wing parties feel more pressure to distinguish themselves through",
|
|
"opposition.",
|
|
"",
|
|
"---",
|
|
"",
|
|
"## 7. Figure",
|
|
"",
|
|
f".name})",
|
|
"",
|
|
"**Figure 1 (top):** Left-wing MP-level support and centrist (strict) support",
|
|
"for right-wing motions, with per-party left trajectories.",
|
|
"",
|
|
"**Figure 1 (bottom):** Polarization gap (centrist support − left support).",
|
|
"Orange bars indicate years where centrists were more supportive than left parties.",
|
|
"Green bars indicate the opposite. The widening post-2024 reflects centrist acceptance.",
|
|
"",
|
|
"---",
|
|
"",
|
|
"## 8. Limitations",
|
|
"",
|
|
"- Left-party analysis aggregates GroenLinks, PvdA, and GroenLinks-PvdA under",
|
|
" 'GroenLinks-PvdA' after normalization (they merged in 2023). Pre-2023 values",
|
|
" average the two separate parties' MPs.",
|
|
"- Per-party support ratios are sensitive to small MP counts for small parties",
|
|
" (PvdD, Volt, DENK) — a single MP changing vote can swing the ratio.",
|
|
"- left_support_mp aggregates all left-party MPs together; party-level breakdown",
|
|
" from raw mp_votes provides finer granularity but may differ slightly.",
|
|
"- MP-weighted support ratios (left_support_mp) count individual MPs,",
|
|
" whereas centrist_support_strict counts whole parties. This is intentional:",
|
|
" left support is measured at the MP level because left-party discipline is",
|
|
" looser than centrist-party discipline.",
|
|
"",
|
|
]
|
|
|
|
report_path = REPORTS_DIR / "left_wing_response.md"
|
|
with open(report_path, "w") as f:
|
|
f.write("\n".join(lines))
|
|
logger.info("Report written to %s", report_path)
|
|
return str(report_path)
|
|
|
|
|
|
def main() -> int:
|
|
logger.info("Querying yearly left/centrist support...")
|
|
yearly = query_yearly_support()
|
|
|
|
logger.info("Querying domain-stratified support...")
|
|
domain_data = query_domain_support()
|
|
|
|
logger.info("Querying per-party left support from mp_votes...")
|
|
party_support = query_per_party_left_support()
|
|
|
|
logger.info("Generating figure...")
|
|
fig_path = create_figure(yearly, domain_data, party_support)
|
|
|
|
logger.info("Generating report...")
|
|
report_path = generate_report(yearly, domain_data, party_support, fig_path)
|
|
|
|
print(f"\nReport: {report_path}")
|
|
print(f"Figure: {fig_path}")
|
|
|
|
# Print key findings
|
|
pre_years = [y for y in sorted(yearly.keys()) if y < BREAK_YEAR]
|
|
post_years = [y for y in sorted(yearly.keys()) if y >= BREAK_YEAR]
|
|
|
|
pre_ls = np.mean([yearly[y]["left_support"] for y in pre_years])
|
|
post_ls = np.mean([yearly[y]["left_support"] for y in post_years])
|
|
pre_cs = np.mean([yearly[y]["centrist_support"] for y in pre_years])
|
|
post_cs = np.mean([yearly[y]["centrist_support"] for y in post_years])
|
|
pre_gap = np.mean([yearly[y]["polarization_gap"] for y in pre_years])
|
|
post_gap = np.mean([yearly[y]["polarization_gap"] for y in post_years])
|
|
|
|
print(f"\nKey findings:")
|
|
print(f" Left support: {pre_ls:.4f} → {post_ls:.4f} (Δ = {post_ls - pre_ls:+.4f})")
|
|
print(f" Centrist support: {pre_cs:.3f} → {post_cs:.3f} (Δ = {post_cs - pre_cs:+.3f})")
|
|
print(f" Polarization gap: {pre_gap:.3f} → {post_gap:.3f} (Δ = {post_gap - pre_gap:+.3f})")
|
|
print(f" Cohen's d (left): {cohens_d(np.array([yearly[y]['left_support'] for y in pre_years]), np.array([yearly[y]['left_support'] for y in post_years])):+.2f}")
|
|
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|
|
|