Add MFQ-2 per-zone p90 respondent ellipses to ipsative map

respondent_profiles now returns countries alongside profiles; plot_ipsative_pca
gains respondent_zones -> a p90 Gaussian ellipse per IW zone of the projected
Atari respondent cloud (edge-only, no scipy). mfq2 uses these real-respondent
ellipses; the other instruments keep country-mean hulls. Journal notes the
finding: individual profiles overlap across cultures (within >> between variance),
so only the country-mean hull reproduces the Economist's clean zone blobs.

Co-Authored-By: Claudypoo <288921227+claudypoo@users.noreply.github.com>
This commit is contained in:
wassname
2026-07-04 19:25:52 +08:00
co-authored by Claudypoo
parent 0e7178dfbd
commit 2c2dd9b9bf
3 changed files with 78 additions and 14 deletions
+24
View File
@@ -725,3 +725,27 @@ stronger steering. The binding constraint on a joint-coherent C across all five
instruments is the side instruments' -C neutral-degeneracy (profile pins to 3.0,
already at C=1; pmass stays ~1.0), a model property, not an ordinal coherence
break. C=1 stands as a valid, coherent real coefficient.
## 2026-07-04 — Inglehart-Welzel zone overlays on the ipsative maps (Economist comparison)
Prompted by the Economist's "Godless hippies" chart placing AI models on the WVS
Inglehart-Welzel cultural map. Added two zone overlays to plot_ipsative_pca:
country-mean convex HULLS (all instruments) and, for mfq2 only, per-zone p90
respondent ELLIPSES (real Atari individuals). Caller owns the IW taxonomy +
name/ISO2 normalizer (scripts/plot_steer_showcase.py), fails loud on unmapped
countries; the corrupt big5 "(nu" row (n=369, country unidentifiable from the
aggregate CSV) is excluded with a warning.
Finding: the two methods answer different questions and only the hull matches the
Economist look. The country-mean hull separates zones cleanly (between-country
signal), because society means are low-variance. The individual-respondent p90
ellipse is HUGE and overlaps every neighbour -- within-culture variance dominates
between-culture variance in MFQ-2 foundations (the standard Atari/Graham result),
so at the person level the zones are not separable. What still separates at the
individual level is the zone CENTROID (labelled): African-Islamic top, Confucian
by Japan, Latin America left, the Europes right, English-Speaking lower-centre.
So the Economist's clean "zone blobs" are hulls over country dots, not contours
over people; a person-level contour honestly shows the overlap the country-mean
view hides. Secondary caveat: several mfq2 zones are single-country in the Atari
19 (Confucian=Japan, Orthodox=Russia, Protestant=Switzerland), so those ellipses
are one country's spread wearing a zone label.
+21 -7
View File
@@ -104,6 +104,13 @@ ECONOMIST_OUTLIERS = {"China", "South Korea", "United States", "Great Britain",
"Nigeria", "Pakistan", "Sweden"}
def _zone_of(country: str) -> str | None:
"""IW zone of a verbatim country string, or None for a known-corrupt row. KeyErrors (fail loud)
on an unrecognised country so a normalization bug can't silently drop it."""
canon = _COUNTRY_CANON.get(country, country)
return None if canon is None else IW_ZONE[canon]
def zones_for(countries: list[str]) -> tuple[dict[str, list[str]], set[str]]:
"""Group verbatim country strings by IW zone + the subset to emphasize. Fails loud (KeyError)
on a country absent from the taxonomy so a name-normalization bug can't silently drop a dot from
@@ -112,12 +119,12 @@ def zones_for(countries: list[str]) -> tuple[dict[str, list[str]], set[str]]:
dropped: list[str] = []
emph: set[str] = set()
for c in countries:
canon = _COUNTRY_CANON.get(c, c)
if canon is None:
z = _zone_of(c)
if z is None:
dropped.append(c)
continue
groups.setdefault(IW_ZONE[canon], []).append(c)
if canon in ECONOMIST_OUTLIERS:
groups.setdefault(z, []).append(c)
if _COUNTRY_CANON.get(c, c) in ECONOMIST_OUTLIERS:
emph.add(c)
if dropped:
logger.warning(f"excluded known-unmapped countries from zone hulls: {dropped}")
@@ -269,16 +276,23 @@ def plot_ordinal(run_dir: Path, out: Path, name: str, vec_label: str, C: float,
# fit the ipsative PCA on it (better-conditioned, the true envelope). Other instruments have no raw
# per-person data, so scatter a marginal resample from each country's published mean+sd as the haze
# while keeping the PCA basis on the society means M.
# mfq2 has real per-respondent data -> draw a p90 respondent ELLIPSE per zone (grounded in
# people) and drop the country-mean hull. Other instruments have only society means -> the
# country-mean convex HULL is the best-available zone blob.
_, emph = zones_for(countries)
if name == "mfq2":
respondents, haze = T.maps.respondent_profiles(dims, instr.scale_max), None
resp_countries, respondents = T.maps.respondent_profiles(dims, instr.scale_max)
haze, zones = None, None
respondent_zones = [_zone_of(c) for c in resp_countries]
else:
respondents, haze = None, human_haze(instr)
zones, respondent_zones = zones_for(countries)[0], None
traj = {c: _frac(prof_c[c], instr.scale_max) for c in coh_cs}
zones, emph = zones_for(countries)
figm = T.maps.plot_ipsative_pca(instr, dims, countries, Mfrac,
_frac(base, instr.scale_max), _frac(pos, instr.scale_max),
_frac(neg, instr.scale_max), respondents=respondents, haze=haze,
traj=traj, zones=zones, emphasize=emph, labels=labels)
traj=traj, zones=zones, emphasize=emph,
respondent_zones=respondent_zones, labels=labels)
figm.axes[0].set_title(f"{instr.display}: humans vs LLMs steered for {vec_label}", fontsize=10)
paths = [T.maps.save_both(figm, out / name, "map_pca_ipsative")]
plt.close(figm)
+33 -7
View File
@@ -25,6 +25,7 @@ from pathlib import Path
import numpy as np
import matplotlib.pyplot as plt
from matplotlib.patches import Ellipse
from .instrument import Instrument
@@ -42,11 +43,12 @@ MFQ2_FOUNDATION_ITEMS = {
}
def respondent_profiles(foundations: list[str], scale_max: int = 5) -> np.ndarray:
def respondent_profiles(foundations: list[str], scale_max: int = 5) -> tuple[list[str], np.ndarray]:
"""Per-respondent MFQ-2 6-foundation profiles (each foundation = mean of its 6 raw 1-5 items),
returned as 0-1 FRACTION (same scale as `M` in plot_ipsative_pca) in `foundations` order.
Rows with any NA in the keyed items are dropped (fail-fast, no imputation). MFQ-2 only --
keying is MFQ2_FOUNDATION_ITEMS. Source: data/atari_study2_raw.csv (Atari et al. 2023 Study 2)."""
returned as (countries, X) where X is a 0-1 FRACTION matrix (same scale as `M` in
plot_ipsative_pca) in `foundations` order and `countries[i]` is respondent i's country. Rows with
any NA in the keyed items are dropped (fail-fast, no imputation). MFQ-2 only -- keying is
MFQ2_FOUNDATION_ITEMS. Source: data/atari_study2_raw.csv (Atari et al. 2023 Study 2)."""
raw_path = DATA / "atari_study2_raw.csv"
rows = list(csv.DictReader(raw_path.open(newline="")))
cols = rows[0].keys()
@@ -55,13 +57,14 @@ def respondent_profiles(foundations: list[str], scale_max: int = 5) -> np.ndarra
raise KeyError(f"keying columns absent from {raw_path.name}: {missing}")
def cell(v: str) -> float:
return np.nan if v in ("NA", "", "-99") else float(v)
out = []
out, countries = [], []
for r in rows:
prof = [np.mean([cell(r[c]) for c in MFQ2_FOUNDATION_ITEMS[f]]) for f in foundations]
if not np.isnan(prof).any():
out.append(prof)
countries.append(r["country"])
X = np.array(out) # (n_resp x K), raw 1-5
return (X - 1) / (scale_max - 1) # -> 0-1 fraction
return countries, (X - 1) / (scale_max - 1) # -> 0-1 fraction
# range-plot palette + geometry (shared with the experiment's prior fig_profile_sweeps look)
CLOUD_GREY = "0.78" # individual respondents (subtle backdrop)
@@ -216,6 +219,7 @@ def plot_ipsative_pca(instr: Instrument, dims: list[str], countries: list[str],
traj: dict[float, np.ndarray] | None = None, traj_incoherent: set | None = None,
boots: dict | None = None,
zones: dict[str, list[str]] | None = None, emphasize: set[str] | None = None,
respondent_zones: list[str] | None = None,
labels: tuple[str, str, str] = ("baseline (c=0)", "honest (c=+2)", "dishonest (c=-2)")):
"""Ipsative culture map. M is societies x K (0-1 fraction); base / pos / neg are the length-K
fraction vectors for the base model and its two steer poles (or None). `labels` is the legend
@@ -235,7 +239,10 @@ def plot_ipsative_pca(instr: Instrument, dims: list[str], countries: list[str],
each zone with >=3 members gets a shaded convex hull (echoes the Economist WVS map's zone blobs),
testing whether moral-foundation space recovers the WVS clusters. `emphasize` is a subset of
`countries` labelled bold-first so named outliers (China, US, Sweden...) always survive the
label-collision drop. Returns the Figure."""
label-collision drop. `respondent_zones` (length = rows of the projected cloud, i.e.
`respondents` when `haze` is None) is each respondent's IW zone; each zone with enough respondents
gets a p90 Gaussian ellipse of its cloud (the real-respondent analogue of a zone hull, mfq2 only).
Returns the Figure."""
try:
import textalloc as ta
except ImportError:
@@ -256,6 +263,25 @@ def plot_ipsative_pca(instr: Instrument, dims: list[str], countries: list[str],
Pi = (cloud @ Pc - mu) @ Vt[:2].T
ax.scatter(Pi[:, 0], Pi[:, 1], s=4, c="#8f8a7e", alpha=0.14, edgecolors="none",
zorder=1, rasterized=True)
# Per-zone p90 respondent ellipse (mfq2 only): the real-respondent analogue of a zone hull.
# A 90%-mass contour of the bivariate-Gaussian fit to each zone's projected cloud; the radius
# is the chi-square(2 dof) 0.90 quantile, sqrt(4.605)=2.1459 (hardcoded to avoid a scipy dep).
if respondent_zones is not None:
assert len(respondent_zones) == Pi.shape[0], "respondent_zones must align with the cloud rows"
zr = np.asarray(respondent_zones)
for zname in dict.fromkeys(respondent_zones): # stable order, unique
zp = Pi[zr == zname]
if len(zp) < 30: # too few respondents -> unreliable contour
continue
cen = zp.mean(0)
evals, evecs = np.linalg.eigh(np.cov(zp.T))
ang = np.degrees(np.arctan2(evecs[1, -1], evecs[0, -1]))
w, h = 2 * 2.1459 * np.sqrt(np.maximum(evals[::-1], 0))
zcol = ZONE_COLORS.get(zname, "#888888")
ax.add_patch(Ellipse(cen, w, h, angle=ang, facecolor="none",
edgecolor=zcol, alpha=0.75, lw=1.4, ls="--", zorder=1.6))
ax.text(cen[0], cen[1], zname, fontsize=8.5, color=zcol, ha="center",
va="center", style="italic", fontweight="bold", zorder=2, alpha=0.9)
# Inglehart-Welzel zone hulls: a shaded convex blob per zone with >=3 member societies, drawn
# UNDER the society dots (zorder<3). The zone name sits at the hull centroid in grey, echoing the
# Economist WVS map. A 2-member zone has no polygon, so it's shown as its connecting segment.