mirror of
https://github.com/wassname/moral-maps.git
synced 2026-09-09 11:27:22 +08:00
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:
@@ -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.
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user