"""Instrument-parameterized culture-map + range viz: the visual proof of the survey eval. Two plot families, both pure (numpy arrays + an Instrument in, matplotlib Figure out). All data loading -- LLM profiles, calibration windows, the human cross-cultural CSVs -- stays in the calling experiment; this module only draws, so it serves every instrument (MFQ-2 / Big5 / 16PF / HSQ) the same way. plot_ipsative_pca : where the model sits among human cultures. Each society's profile is row-centred (its overall endorsement level removed) then PCA'd, so the axes are RELATIVE emphasis across factors, not acquiescence. The model's base and steered endpoint profiles are projected into the same space; a compass inset shows how each factor loads. plot_range : per-factor, the steer as a directed c-sweep (tail at the -c pole, single arrowhead at the +c pole) against the strip of human societies. With a zoomed small-multiple companion (plot_range_zoom), one panel per factor on its own y-axis. Ported from the weight_steer_honesty experiment (mft_honesty.maps / mapviz + scripts/ fig_profile_sweeps.py). The experiment's MFQ-2-specific overlays (MFT theory axes, delta table) stay there -- they are about the steering vectors, not the instrument. """ from __future__ import annotations import csv from pathlib import Path import numpy as np import matplotlib.pyplot as plt from matplotlib.patches import Ellipse from .instrument import Instrument DATA = Path(__file__).resolve().parent / "data" # Official MFQ-2 final 6-factor keying, verbatim from Atari et al. Code_Study2.R `model_6factors`. # Drives respondent_profiles below (the individual cloud behind the ipsative map). MFQ-2 only. MFQ2_FOUNDATION_ITEMS = { "care": ["care1", "care3", "care11", "care12", "care13", "care14"], "equality": ["equalFairness6", "equalFairness10", "equality2", "equality4", "equality6", "equality10"], "proportionality": ["propFairness1", "propFairness3", "proportionality5", "proportionality9", "proportionality12", "proportionality17"], "loyalty": ["loyalty5", "loyalty6", "loyalty12", "loyalty13", "loyalty14", "loyalty16"], "authority": ["authority6", "authority8", "authority11", "authority14", "authority18", "authority20"], "purity": ["purity2", "purity3", "purity6", "purity9", "purity13", "purity17"], } 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 (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() missing = [c for items in MFQ2_FOUNDATION_ITEMS.values() for c in items if c not in cols] if missing: 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, 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 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) COUNTRY_GREY = "0.38" # society means (the named dots) MEDIAN_GREY = "0.15" POS_COL, NEG_COL = "#c0392b", "#2c6fbb" # +c side (red) / -c side (blue) of the sweep DX_HUMAN, DX_STEER = -0.17, 0.18 GROUP_PITCH = 1.55 # x-distance between factors; > pair width so each (societies, steer) reads as one unit # ipsative-map palette. Keep steer colors identical to the range plots: # base is neutral, +c is red, -c is blue, human societies are grey. C_BASE, C_HON, C_DIS, C_HUM = "#111111", POS_COL, NEG_COL, "#888888" # Stable per-zone fill colors so the SAME Inglehart-Welzel zone reads the same across every # instrument's map (research consistency). Keyed by the zone names the caller passes; an unlisted # zone falls back to grey. -- added by Claude ZONE_COLORS = { "English-Speaking": "#4e79a7", "Protestant Europe": "#59a14f", "Catholic Europe": "#8cd17d", "Orthodox": "#b6992d", "Baltic": "#499894", "Confucian": "#e15759", "Latin America": "#f28e2b", "African-Islamic": "#9c755f", "South Asia": "#b07aa1", } def draw_zone_ellipses(ax, P: np.ndarray, countries: list[str], zones: dict[str, list[str]]) -> None: """Per-zone blob: a ~1.6-sigma covariance ellipse over that zone's member COUNTRY points (P is countries x 2). Between-country spread keeps zones separate; an eigenvalue floor (a fraction of the overall P spread) gives a 1-country zone a small circle and a 2-country zone real width instead of a line. Shared by the instrument maps and the WVS map.""" cidx = {c: i for i, c in enumerate(countries)} floor = (0.07 * float(np.hypot(*(P.max(0) - P.min(0))))) ** 2 for zname, members in zones.items(): zp = P[[cidx[c] for c in members if c in cidx]] if len(zp) == 0: continue cen = zp.mean(0) cov = np.cov(zp.T) if len(zp) > 1 else np.zeros((2, 2)) evals, evecs = np.linalg.eigh(cov) ang = np.degrees(np.arctan2(evecs[1, -1], evecs[0, -1])) w, h = 2 * 1.6 * np.sqrt(np.maximum(evals[::-1], floor)) zcol = ZONE_COLORS.get(zname, "#888888") ax.add_patch(Ellipse(cen, w, h, angle=ang, facecolor=zcol, edgecolor=zcol, alpha=0.12, lw=1.0, zorder=1.5)) ax.add_patch(Ellipse(cen, w, h, angle=ang, facecolor="none", edgecolor=zcol, alpha=0.7, lw=1.2, zorder=1.7)) 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) def save_both(fig, fig_dir: Path, stem: str, dpi: int = 200) -> Path: fig_dir.mkdir(parents=True, exist_ok=True) fig.savefig(fig_dir / f"{stem}.png", dpi=dpi, bbox_inches="tight") fig.savefig(fig_dir / f"{stem}.svg", bbox_inches="tight") return fig_dir / f"{stem}.png" # --- ipsative PCA culture map ------------------------------------------------------------------- def row_centre_op(K: int) -> np.ndarray: """Ipsative operator: M @ Pc subtracts each row's own mean across the K factors, removing the overall-endorsement/acquiescence level that would otherwise dominate PC1.""" return np.eye(K) - np.ones((K, K)) / K def ipsative_pca(M: np.ndarray, k: int = 2): """Row-centre each row of M (societies x K), then PCA across rows. Returns (P, Vt, var, mu, Pc); project a new point v via ((v @ Pc) - mu) @ Vt[:k].T. SVD signs are stabilized here (PC1 loads + on factor 0, PC2 on factor 1) so this helper and any plot built on it share ONE orientation -- otherwise saved coords could mirror the figure.""" K = M.shape[1] Pc = row_centre_op(K) Mp = M @ Pc mu = Mp.mean(axis=0) Mc = Mp - mu _, S, Vt = np.linalg.svd(Mc, full_matrices=False) if Vt[0, 0] < 0: Vt[0] = -Vt[0] if Vt.shape[0] > 1 and Vt[1, 1 % K] < 0: Vt[1] = -Vt[1] var = (S ** 2) / (S ** 2).sum() return Mc @ Vt[:k].T, Vt, var, mu, Pc def compass(ax_main, L: np.ndarray, labels: list[str], title: str = "compass", box=(0.62, 0.70, 0.30, 0.27), color: str = "#3a6b35") -> None: """Compass-rose inset (axes-fraction): each factor is an arrow (direction = 2D loading, length = magnitude); reference circle at 80% of the shortest arrow so labels sit at the tips.""" lens = np.linalg.norm(L, axis=1) L = L / (lens.max() + 1e-9) circle_r = 0.8 * lens.min() / lens.max() cax = ax_main.inset_axes(list(box)) cax.patch.set_facecolor("#faf8f2"); cax.patch.set_alpha(0.92) # opaque: sits in the padded legend strip cax.add_patch(plt.Circle((0, 0), circle_r, fill=False, color="#bbbbbb", lw=0.7)) tx, ty, tips_x, tips_y = [], [], [], [] for j, lab in enumerate(labels): x, y = L[j] cax.annotate("", xy=(x, y), xytext=(0, 0), arrowprops=dict(arrowstyle="->", color=color, lw=1.1)) r = np.hypot(x, y) tx.append(x / r * (r + 0.07)); ty.append(y / r * (r + 0.07)); tips_x.append(x); tips_y.append(y) cax.set_xlim(-1.5, 1.5); cax.set_ylim(-1.5, 1.5) placed = False try: # textalloc spreads colliding tip labels import textalloc as ta ta.allocate_text(ax_main.figure, cax, tx, ty, [l.capitalize() for l in labels], x_scatter=tips_x + [0], y_scatter=tips_y + [0], textsize=7.5, linecolor=color, linewidth=0.5, textcolor=color, draw_lines=True) placed = True except Exception: placed = False if not placed: for j, lab in enumerate(labels): x, y = L[j]; r = np.hypot(x, y) cax.text(x / r * (r + 0.07), y / r * (r + 0.07), lab.capitalize(), fontsize=7.5, fontweight="bold", color=color, ha="left" if x >= 0 else "right", va="bottom" if y >= 0 else "top", clip_on=False) cax.set_aspect("equal"); cax.axis("off") cax.set_title(title, fontsize=10, fontweight="bold", color=color, pad=3) def _minimap(ax_main, cloud_full: np.ndarray, societies: np.ndarray, base_pt, view, box) -> None: """Macro overview inset: the FULL human cloud + all societies + the model base, with a red rectangle marking the zoomed main frame -- so a tightly-cropped map still shows where its window sits in the whole space (and any off-frame society stays visible here). No labels: the inset is too small to letter without clutter; orientation comes from the rectangle alone.""" from matplotlib.patches import Rectangle xlo, xhi, ylo, yhi = view mm = ax_main.inset_axes(list(box)) mm.set_facecolor("#faf8f2") # opaque: sits in the padded legend strip mm.scatter(cloud_full[:, 0], cloud_full[:, 1], s=2, c="#8f8a7e", alpha=0.12, edgecolors="none", rasterized=True, zorder=1) mm.scatter(societies[:, 0], societies[:, 1], s=5, c=C_HUM, alpha=0.8, edgecolors="none", zorder=2) if base_pt is not None: mm.plot(base_pt[0], base_pt[1], "o", ms=3, color=C_BASE, zorder=3) mm.add_patch(Rectangle((xlo, ylo), xhi - xlo, yhi - ylo, fill=False, ec=POS_COL, lw=1.0, zorder=4)) mm.set_xticks([]); mm.set_yticks([]) mm.set_title("all human respondents", fontsize=6.0, color="0.4", pad=2) for s in mm.spines.values(): s.set_color("0.7"); s.set_linewidth(0.5) def _axis_gloss(load1: np.ndarray, dims: list[str], n: int = 2) -> str: """One-line interpretation of a PC from its loadings: top-n +loading factors vs top-n -loading, e.g. 'loyalty/authority (+) vs equality/care (-)'. So the axis is readable without the compass.""" order = np.argsort(load1) neg = "/".join(dims[i] for i in order[:n]) pos = "/".join(dims[i] for i in order[::-1][:n]) return f"{pos} (+) vs {neg} (-)" def plot_ipsative_pca(instr: Instrument, dims: list[str], countries: list[str], M: np.ndarray, base: np.ndarray, pos: np.ndarray | None, neg: np.ndarray | None, *, respondents: np.ndarray | None = None, haze: np.ndarray | None = None, traj: dict[float, np.ndarray] | None = None, traj_incoherent: set | None = None, boots: dict | None = None, emphasize: set[str] | None = None, zones: dict[str, 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 text (base, +pole, -pole) -- override it for a non-honesty steer or a different coefficient. `respondents` (n_resp x K, SAME 0-1 fraction scale as M) is the PCA BASIS when given: fitting on people (thousands of points, real covariance) beats fitting on a handful of noisy society means. `haze` (n x K, same fraction scale) is the human cloud SCATTERED behind the societies and used for the crop -- separate from the fit so instruments with only society-level mean+sd (big5/16pf/ humor: a marginal resample) get a backdrop without that resample dictating the axes. mfq2 passes real `respondents` (also used as the haze when `haze` is None). With neither, fit on M, pad-crop, no backdrop. `traj` (signed c-multiplier -> length-K fraction vector) draws the coherent steer path through PC space. Public README plots pass only the coherent prefix; incoherent c values are omitted. `traj_incoherent` is the subset of those c whose admin pmass fell below the coherence floor -- drawn hollow. `boots` optionally maps 'base'/'honest'/'dis' -> (n x K) bootstrap matrices. `zones` maps an Inglehart-Welzel zone name to the subset of `countries` (verbatim strings) in it; each zone with >=3 members gets a shaded convex hull (echoes the Economist WVS map's zone blobs), `emphasize` is a subset of `countries` labelled bold-first so named outliers (China, US, Sweden...) always survive the label-collision drop. `zones` maps an IW zone name to its member `countries`; each becomes a covariance ellipse over that zone's COUNTRY-MEAN points (between-country spread, so zones stay separate -- contouring individual respondents instead gives huge overlap since within-culture variance dominates). An eigenvalue floor gives a 1- or 2-country zone a visible blob. The PCA is fit on the country means M; `respondents`/`haze` only scatter + set the crop. Returns the Figure.""" try: import textalloc as ta except ImportError: ta = None _, Vt, var, mu, Pc = ipsative_pca(M) # fit on country means: between-country axes P = (M @ Pc - mu) @ Vt[:2].T cloud = haze if haze is not None else respondents # what we scatter + crop to (fit is separate) def proj(v): return ((v @ Pc) - mu) @ Vt[:2].T if v is not None else None pb, ph, pf = proj(base), proj(pos), proj(neg) fig, ax = plt.subplots(figsize=(8.5, 7.5)) ax.set_facecolor("#faf8f2") ax.grid(True, color="#eceadf", lw=0.3, zorder=0) if cloud is not None: # grey haze/respondents (rasterized; SVG-safe) 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) if zones: draw_zone_ellipses(ax, P, countries, zones) ax.scatter(P[:, 0], P[:, 1], s=26, c=C_HUM, alpha=0.7, edgecolors="white", linewidths=0.5, zorder=3) # Society labels: each name/ISO code is pinned RIGHT NEXT to its dot (small fixed offset, no # leader line). A label is dropped if its box would collide with an already-placed one -- better an # omitted code than one flung far from its point. `emphasize` countries are placed FIRST (so they # win contested space) and drawn bold+dark, so the named outliers always survive the drop. fig.canvas.draw() renderer = fig.canvas.get_renderer() placed_boxes = [] emph = emphasize or set() order_lr = list(np.argsort(P[:, 0])) # left-to-right; leftmost wins contested space order = [i for i in order_lr if countries[i] in emph] + [i for i in order_lr if countries[i] not in emph] for i in order: is_e = countries[i] in emph t = ax.annotate(countries[i], (P[i, 0], P[i, 1]), fontsize=8.5 if is_e else 7, color="#111111" if is_e else "#555555", fontweight="bold" if is_e else "normal", xytext=(3, 2), textcoords="offset points", zorder=7 if is_e else 6) bb = t.get_window_extent(renderer) if any(bb.overlaps(b) for b in placed_boxes): t.remove() else: placed_boxes.append(bb) for key, col, pt in [("base", C_BASE, pb), ("honest", C_HON, ph), ("dis", C_DIS, pf)]: if boots and key in boots and pt is not None: bp = (np.asarray(boots[key]) @ Pc - mu) @ Vt[:2].T e1, e2 = 1.96 * bp.std(0) ax.errorbar(pt[0], pt[1], xerr=e1, yerr=e2, fmt="none", ecolor=col, elinewidth=0.7, alpha=0.55, capsize=2.5, capthick=0.8, zorder=4) base_lab, pos_lab, neg_lab = labels if pb is not None: ax.scatter(*pb, s=72, c=C_BASE, marker="o", edgecolors="white", linewidths=1.0, zorder=7) ax.annotate(base_lab, pb, xytext=(9, -13), textcoords="offset points", fontsize=9, color=C_BASE, fontweight="bold", ha="left", va="center", zorder=8) traj_pts = None if traj: inco = traj_incoherent or set() cs_sorted = sorted(traj) traj_pts = np.array([proj(traj[c]) for c in cs_sorted]) # Two arms from base (c=0): +c red, -c blue. Marker size is constant so path length, not ink # area, carries the coefficient change. for lo, hi in [(0.0, max(cs_sorted)), (min(cs_sorted), 0.0)]: arm = [(c, proj(traj[c])) for c in cs_sorted if lo <= c <= hi] if len(arm) < 2: continue xy = np.array([p for _, p in arm]) col = POS_COL if hi > 0 else NEG_COL ax.plot(xy[:, 0], xy[:, 1], "-", color=col, lw=1.1, zorder=4, alpha=0.75) c_end = max((c for c, _p in arm), key=abs) for c, p in arm: if c == 0: continue fill = col if c == c_end and c not in inco else "none" ax.scatter(p[0], p[1], s=42, c=fill, edgecolors=col, linewidths=1.25, zorder=6) if c == c_end: lab = pos_lab if c > 0 else neg_lab dxy, ha = ((9, 9), "left") if c > 0 else ((-9, -1), "right") ax.annotate(lab, p, xytext=dxy, textcoords="offset points", fontsize=9, color=col, fontweight="bold", ha=ha, va="center", zorder=8) else: for pt, col, lab, dxy, ha in [(ph, C_HON, pos_lab, (9, 9), "left"), (pf, C_DIS, neg_lab, (-9, -1), "right")]: if pt is None: continue ax.scatter(*pt, s=42, c=col, marker="o", edgecolors="white", linewidths=1.0, zorder=7) ax.annotate(lab, pt, xytext=dxy, textcoords="offset points", fontsize=9, color=col, fontweight="bold", ha=ha, va="center", zorder=8) # Crop to the SOCIETIES + steer anchors for EVERY instrument (the human cloud is far wider and would # bury them in a central blob; it stays a clipped backdrop). Then PAD THE BOTTOM to reserve a clean # strip for the legend insets -- deterministic placement, identical on every plot, no overlap with # data or labels regardless of where the trajectory heads. PAD_B = 0.62 # bottom legend strip = PAD_B of the data y-range if cloud is not None: anc_extra = [traj_pts] if traj_pts is not None else [] anc = np.vstack([P] + [p for p in (pb, ph, pf) if p is not None] + anc_extra) cx, cy = np.percentile(P[:, 0], [2, 98]), np.percentile(P[:, 1], [2, 98]) wx0, wx1 = min(cx[0], np.nanmin(anc[:, 0])), max(cx[1], np.nanmax(anc[:, 0])) wy0, wy1 = min(cy[0], np.nanmin(anc[:, 1])), max(cy[1], np.nanmax(anc[:, 1])) sx, sy = wx1 - wx0, wy1 - wy0 dview = (wx0 - 0.12 * sx, wx1 + 0.12 * sx, wy0 - 0.12 * sy, wy1 + 0.12 * sy) # data frame, no legend pad else: x0, x1 = ax.get_xlim(); y0, y1 = ax.get_ylim() dview = (x0, x1, y0, y1); sy = y1 - y0 ax.set_xlim(dview[0], dview[1]) ax.set_ylim(dview[2] - PAD_B * sy, dview[3]) # legend strip along the padded bottom: minimap bottom-right (it reads like a small map), compass # bottom-left. Both insets are opaque (set in compass()/_minimap) so the faint haze stays behind them. if cloud is not None: _minimap(ax, Pi, P, pb, dview, box=(0.71, 0.01, 0.27, 0.27)) compass(ax, Vt[:2].T, dims, title=f"{instr.display} compass", box=(0.0, 0.01, 0.40, 0.26)) ax.set_xlabel(f"PC1 ({var[0]*100:.0f}% var) · {_axis_gloss(Vt[0], dims)}") ax.set_ylabel(f"PC2 ({var[1]*100:.0f}% var) · {_axis_gloss(Vt[1], dims)}") ax.set_title(f"{instr.name}: ipsative culture map ({len(countries)} societies)", fontsize=10) return fig # --- foundation scatter-plot matrix (SPLOM) ----------------------------------------------------- def plot_splom(instr: Instrument, dims: list[str], cloud: np.ndarray, M: np.ndarray, base: np.ndarray, prof_by_c: dict[float, np.ndarray], *, select: int | None = None, zoom: bool = False, vec_label: str = ""): """Scatter-plot matrix of the foundations: the ipsative map's 2-PC projection seen pair by pair, so the steer's path is read in each raw foundation-plane, not just the top-2 PCs. lower triangle : human joint scatter (REAL covariance) -- respondents recede (light), society means sit darker, and the AI base -> +-c trajectory dominates (saturated). diagonal : that foundation's human marginal (hist) with the AI base/+-c as vertical rules. upper triangle : Pearson r as a number sized+coloured by |r| (red +, blue -) -- the correlation the lower scatter shows, given once as a value rather than a duplicate cloud. Foundations are ordered by ipsative PC1 loading so correlated factors sit adjacent (block structure) and the order matches plot_ipsative_pca. `cloud`/`M`/`base`/`prof_by_c` are 0-1 fraction; displayed on the native 1..scale_max scale. `select` keeps the N foundations the steer moves most (largest |+c - -c| span); None keeps all. `zoom=True` frames each axis to the AI trajectory +-margin (micro: the small steer move); False frames to the human 2-98 pct (macro: AI vs the whole human spread). mfq2 ONLY -- the other instruments ship an independent-marginal haze (no real joint), so their off-diagonals would FABRICATE the correlation structure.""" cs = sorted(prof_by_c) K = len(dims) _, Vt, *_ = ipsative_pca(cloud if cloud is not None else M) order = list(np.argsort(Vt[0])[::-1]) # high +PC1 first (matches the map axis) if select is not None and select < K: span = np.abs(prof_by_c[cs[-1]] - prof_by_c[cs[0]]) keep = set(np.argsort(span)[::-1][:select].tolist()) order = [i for i in order if i in keep] n = len(order) smax = instr.scale_max nat = lambda fr: 1.0 + np.asarray(fr) * (smax - 1) cloud_n, M_n = nat(cloud), nat(M) prof_n = {c: nat(prof_by_c[c]) for c in cs} # per-foundation display range (shared down each col / across each row) # NaN-safe: a collapsed pole reads NaN ("do not compare"); nan-reduce the trajectory and fall # back to the human spread when a foundation's whole steer arm collapsed. rng: dict[int, tuple[float, float]] = {} for f in order: tv = np.array([prof_n[c][f] for c in cs]) tlo, thi = (np.nanmin(tv), np.nanmax(tv)) if np.isfinite(tv).any() else (np.nan, np.nan) hlo, hhi = np.percentile(cloud_n[:, f], [2, 98]) if zoom and np.isfinite(tlo): lo, hi = tlo, thi m = max(0.20 * (hi - lo), 0.06 * (smax - 1)) else: lo = np.nanmin([hlo, tlo]); hi = np.nanmax([hhi, thi]) m = 0.04 * (hi - lo) rng[f] = (lo - m, hi + m) fig, axes = plt.subplots(n, n, figsize=(1.55 * n + 0.6, 1.55 * n + 0.6), squeeze=False) rngen = np.random.default_rng(0) for r in range(n): fr = order[r] for c in range(n): fc = order[c] ax = axes[r][c] ax.tick_params(labelsize=6, length=2) if r == c: # human distribution (violin) + AI steer # horizontal violin = human spread of this foundation; AI base/+C/-C ride on it as the # same trajectory dots used off-diagonal (1-D analogue), so the diagonal reads the same way. vp = ax.violinplot(cloud_n[:, fr], positions=[0.5], vert=False, widths=0.9, showextrema=False) for b in vp["bodies"]: b.set_facecolor(CLOUD_GREY); b.set_alpha(0.5); b.set_edgecolor("none") ax.plot([prof_n[cc][fr] for cc in cs], [0.5] * len(cs), "-", color="0.5", lw=0.7, zorder=4) cmax = max(abs(cc) for cc in cs) or 1.0 for cc in cs: if not np.isfinite(prof_n[cc][fr]): continue col = "black" if cc == 0 else (POS_COL if cc > 0 else NEG_COL) ax.scatter(prof_n[cc][fr], 0.5, s=(34 if cc == 0 else 14 + 18 * abs(cc) / cmax), color=col, edgecolors="white", linewidths=0.4, zorder=6) ax.set_xlim(*rng[fr]); ax.set_ylim(0, 1); ax.set_yticks([]) ax.text(0.5, 0.94, dims[fr], transform=ax.transAxes, ha="center", va="top", fontsize=7.5, fontweight="bold", color="0.25") elif r > c: # joint scatter + trajectory # jitter the respondent cloud: MFQ-2 per-foundation scores are ordinal, so raw points # land on a lattice that reads as confusing grid-dots; jitter softens it to a density. jit = 0.05 * (smax - 1) ax.scatter(cloud_n[:, fc] + rngen.uniform(-jit, jit, cloud_n.shape[0]), cloud_n[:, fr] + rngen.uniform(-jit, jit, cloud_n.shape[0]), s=3, color=CLOUD_GREY, alpha=0.10, edgecolors="none", zorder=1, rasterized=True) ax.scatter(M_n[:, fc], M_n[:, fr], s=9, color=COUNTRY_GREY, alpha=0.75, edgecolors="none", zorder=2) px = [prof_n[cc][fc] for cc in cs]; py = [prof_n[cc][fr] for cc in cs] ax.plot(px, py, "-", color="0.5", lw=0.7, zorder=4) cmax = max(abs(cc) for cc in cs) or 1.0 for cc in cs: col = "black" if cc == 0 else (POS_COL if cc > 0 else NEG_COL) ax.scatter(prof_n[cc][fc], prof_n[cc][fr], s=(34 if cc == 0 else 14 + 18 * abs(cc) / cmax), color=col, edgecolors="white", linewidths=0.4, zorder=6) ax.set_xlim(*rng[fc]); ax.set_ylim(*rng[fr]) else: # upper: correlation as a value rp = float(np.corrcoef(cloud_n[:, fc], cloud_n[:, fr])[0, 1]) ax.text(0.5, 0.5, f"{rp:+.2f}", transform=ax.transAxes, ha="center", va="center", fontsize=7 + 11 * abs(rp), color=POS_COL if rp > 0 else NEG_COL) ax.set_xticks([]); ax.set_yticks([]) ax.spines[:].set_visible(False) if r != c: ax.spines[["top", "right"]].set_visible(False) if c != 0 or r == 0: ax.set_yticklabels([]) if r != n - 1: ax.set_xticklabels([]) if r == n - 1: ax.set_xlabel(dims[fc], fontsize=7) if c == 0 and r != 0: ax.set_ylabel(dims[fr], fontsize=7) scope = "zoomed to AI steer +-margin" if zoom else "full human range (2-98 pct)" fig.suptitle(f"{instr.display} foundation pairs: human joint (grey) vs AI base->steer ({vec_label})\n" f"lower=scatter, diag=marginal, upper=Pearson r · {scope}", fontsize=9) fig.tight_layout(rect=(0, 0, 1, 0.97)) return fig # --- per-vector steer range --------------------------------------------------------------------- def draw_steer(ax, xs: float, cs: list[float], yv: np.ndarray, lw: float = 2.0, lane_dx: float = 0.14) -> None: """Draw one coherent AI c-path. X is only the sign lane: negative, base, positive.""" assert list(cs) == sorted(cs) and 0.0 in cs, f"draw_steer needs sorted cs with c=0 (yv[-1]=+pole, yv[0]=-pole), got {cs}" xs_by_c = {c: xs + lane_dx * np.sign(c) for c in cs} for side_cs, col in [([c for c in cs if c <= 0.0], NEG_COL), ([c for c in cs if c >= 0.0], POS_COL)]: if len(side_cs) < 2: continue x_path = [xs_by_c[c] for c in side_cs] y_path = [float(yv[cs.index(c)]) for c in side_cs] ax.plot(x_path, y_path, color=col, lw=lw, alpha=0.9, zorder=6, solid_capstyle="round") for c, y in zip(cs, yv): if c == 0.0: col = "black" else: col = POS_COL if c > 0 else NEG_COL x = xs_by_c[c] ax.scatter(x, float(y), s=18, color=col, edgecolors="white", linewidths=0.45, zorder=8) def draw_range_panel(ax, instr: Instrument, dims: list[str], cs: list[float], prof: dict, humans: dict, cloud: np.ndarray | None, vec: str) -> tuple[float, float]: """One vector's range panel: human society dots plus one AI steer column per factor.""" rng = np.random.default_rng(0) ys: list[float] = [] spans = [float(np.ptp([prof[c][i] for c in cs])) for i in range(len(dims))] label_i = int(np.argmax(spans)) if len(cs) >= 2 else -1 for i, d in enumerate(dims): gx = i * GROUP_PITCH xh = gx + DX_HUMAN if cloud is not None: col = cloud[:, i] jit = (rng.random(len(col)) - 0.5) * 0.30 yj = (rng.random(len(col)) - 0.5) * 0.17 ax.scatter(xh + jit, col + yj, s=2, color=CLOUD_GREY, alpha=0.13, edgecolor="none", zorder=0) means = np.array([m for _, m in humans[d]]) names = [c for c, _ in humans[d]] jit2 = (rng.random(len(means)) - 0.5) * 0.16 ax.scatter(xh + jit2, means, s=24, color=COUNTRY_GREY, alpha=0.9, edgecolor="white", linewidth=0.3, zorder=3) ax.plot([xh - 0.15, xh + 0.15], [np.median(means)] * 2, color=MEDIAN_GREY, lw=1.5, zorder=4) ys += means.tolist() for idx, va, dy in [(int(means.argmax()), "bottom", 0.03), (int(means.argmin()), "top", -0.03)]: ax.annotate(names[idx], (xh, means[idx] + dy), fontsize=7, ha="center", va=va, color="0.25", zorder=5) xs = gx + DX_STEER yv = np.array([prof[c][i] for c in cs]) ys += yv.tolist() draw_steer(ax, xs, cs, yv) if i == label_i: # Only the two pole labels, to the RIGHT of the steer column. No 'base' tag: the black dot # between the two coloured arms is self-evidently the unsteered model, and on a near-collapsed # pole (e.g. humor affiliative, +c ~ base) a 'base' tag overprints the +c tag. for c_end, y_end, col in [(cs[-1], yv[-1], POS_COL), (cs[0], yv[0], NEG_COL)]: ax.annotate(f"c={c_end:+g}", (xs + 0.30, y_end), fontsize=6.8, ha="left", va="center", color=col, zorder=9) pad = 0.10 * (max(ys) - min(ys)) ax.set_ylim(min(ys) - pad, max(ys) + 1.7 * pad) # top headroom for the human/AI strip # Direct labels (show, not tell): bracket the FIRST group's two columns as the human society strip # vs the AI steer, so the grey-dots / arms encoding is read once off the data rather than a caption. # Pinned to a fixed strip at the top of the panel (axes-fraction y) so it always clears the data. from matplotlib.transforms import blended_transform_factory trans = blended_transform_factory(ax.transData, ax.transAxes) ybar = 0.965 for x0, txt, col, half in [(DX_HUMAN, "human", COUNTRY_GREY, 0.20), (DX_STEER, "AI", MEDIAN_GREY, 0.10)]: ax.plot([x0 - half, x0 - half, x0 + half, x0 + half], [ybar - 0.03, ybar, ybar, ybar - 0.03], transform=trans, color=col, lw=0.8, clip_on=False, zorder=10) ax.text(x0, ybar + 0.012, txt, transform=trans, fontsize=7.5, ha="center", va="bottom", fontweight="bold", color=col, zorder=10, clip_on=False) ax.set_xticks(np.arange(len(dims)) * GROUP_PITCH) ax.set_xticklabels(dims, rotation=30, ha="right", fontsize=8) ax.set_xlim(-0.6, (len(dims) - 1) * GROUP_PITCH + 0.6) ax.grid(axis="y", alpha=0.15) ax.spines[["top", "right"]].set_visible(False) return min(ys) - pad, max(ys) + pad def plot_range(instr: Instrument, dims: list[str], cs: list[float], prof: dict, humans: dict, cloud: np.ndarray | None, vec: str, *, ylabel: str | None = None): """Single-axes range figure for one steering vector. Returns the Figure. The dot/line encoding legend belongs in the figure CAPTION, not the image: a paragraph of legend text in the suptitle forces the figure wide to fit one line and squashes the panel. The axes title already names the geometry (tail=-c, arrow=+c). `ylabel` overrides the default ordinal " mean (1-M)" -- nominal MFV passes its own "relative emphasis (z across foundations)" since its y-values are z-scores, not a 1-M scale.""" figw = max(6.4, 1.5 * len(dims) + 1.5) fig, ax = plt.subplots(figsize=(figw, 4.8)) draw_range_panel(ax, instr, dims, cs, prof, humans, cloud, vec) ax.set_ylabel(ylabel or f"{instr.display} mean (1-{instr.scale_max})") fig.suptitle(f"Steered {instr.display}: {vec}", fontsize=11) fig.tight_layout() return fig def plot_range_zoom(instr: Instrument, dims: list[str], cs: list[float], prof: dict, humans: dict, vec: str): """Zoomed companion: one subplot per factor with its OWN y-axis, so the steer (small vs the human spread) is legible. Societies near the steer named; off-range extremes in the corners. Returns the Figure.""" try: import textalloc as ta except ImportError: ta = None n = len(dims) ncol = min(3, n) nrow = (n + ncol - 1) // ncol fig, axes = plt.subplots(nrow, ncol, figsize=(3.9 * ncol, 3.2 * nrow), squeeze=False) rng = np.random.default_rng(0) for i, d in enumerate(dims): ax = axes[i // ncol][i % ncol] yv = np.array([prof[c][i] for c in cs]) soc = humans[d] soc_vals = np.array([m for _, m in soc]) q1, q3 = np.percentile(soc_vals, [25, 75]) # nanmin/nanmax: a collapsed pole reads NaN (read.py NaN-at-collapse, "do not compare"). The # base (c=0) is always finite, so the axis still frames the un-collapsed cells. lo, hi = min(np.nanmin(yv), q1), max(np.nanmax(yv), q3) m = max(0.10, 0.30 * (hi - lo)) ylo, yhi = lo - m, hi + m near = [(name, v) for name, v in soc if ylo <= v <= yhi] soc_x = (-0.42 + (rng.random(len(near)) - 0.5) * 0.16) if near else np.array([]) if near: ax.scatter(soc_x, [v for _, v in near], s=20, color=COUNTRY_GREY, edgecolor="white", linewidth=0.3, zorder=3) med = float(np.median(soc_vals)) if ylo <= med <= yhi: ax.plot([-0.60, -0.24], [med, med], color=MEDIAN_GREY, lw=1.2, zorder=4) xs = 0.30 base_y = float(yv[list(cs).index(0.0)]) draw_steer(ax, xs, cs, yv, lw=2.4) named = {} if near: name_xy = {nm: (float(x), v) for (nm, v), x in zip(near, soc_x)} for ref in (base_y, float(yv.max()), float(yv.min())): named[min(near, key=lambda t: abs(t[1] - ref))[0]] = ref tx = [xs] * len(cs) + [name_xy[nm][0] for nm in named] if near else [xs] * len(cs) ty = list(map(float, yv)) + [name_xy[nm][1] for nm in named] if near else list(map(float, yv)) txt = [("c=0" if c == 0 else f"c={c:+g}") for c in cs] + list(named) dot_x = [xs] * len(cs) + (list(soc_x) if near else []) dot_y = list(map(float, yv)) + ([v for _, v in near] if near else []) placed = False if ta is not None: try: ta.allocate_text(fig, ax, tx, ty, txt, x_scatter=dot_x, y_scatter=dot_y, textsize=6.5, linecolor="#bbbbbb", linewidth=0.4, textcolor="#333333") placed = True except Exception: placed = False if not placed: for x, y, t in zip(tx, ty, txt): ax.annotate(t, (x + (0.12 if x >= 0 else -0.12), y), fontsize=6.5, ha="left" if x >= 0 else "right", va="center", color="#333333") mx_name, mx_val = max(soc, key=lambda t: t[1]) mn_name, mn_val = min(soc, key=lambda t: t[1]) if mx_val > yhi: ax.text(0.02, 0.99, f"↑ {mx_name} {mx_val:.2f}", transform=ax.transAxes, fontsize=6.3, ha="left", va="top", color=MEDIAN_GREY) if mn_val < ylo: ax.text(0.02, 0.01, f"↓ {mn_name} {mn_val:.2f}", transform=ax.transAxes, fontsize=6.3, ha="left", va="bottom", color=MEDIAN_GREY) ax.set_title(d, fontsize=9) ax.set_xlim(-1.05, 1.25) ax.set_ylim(ylo, yhi) ax.set_xticks([]) ax.tick_params(labelsize=7) ax.spines[["top", "right", "bottom"]].set_visible(False) for k in range(n, nrow * ncol): axes[k // ncol][k % ncol].axis("off") fig.suptitle(f"Steered {instr.display}, zoomed per subscale: {vec}", fontsize=11) fig.tight_layout() return fig