diff --git a/scripts/plot_steer_showcase.py b/scripts/plot_steer_showcase.py index 34635fc..a5955da 100644 --- a/scripts/plot_steer_showcase.py +++ b/scripts/plot_steer_showcase.py @@ -89,23 +89,31 @@ def human_strip(instr) -> dict[str, list[tuple[str, float]]]: return strip -def read_profiles(run_dir: Path, name: str, dims: list[str]) -> dict[str, np.ndarray]: - """{pole: profile-vector in instrument factor order} from _profiles.csv (model-scale means).""" - by_pole: dict[str, dict[str, float]] = {} +def read_profiles(run_dir: Path, name: str, dims: list[str]) -> tuple[dict[float, np.ndarray], dict[float, float]]: + """({c: profile-vector in factor order}, {c: pmass}) from _profiles.csv. `c` is the signed + multiplier of calibrated C (0 = base); a single-multiplier run yields just {-1, 0, +1}.""" + by_c: dict[float, dict[str, float]] = {} + pmass: dict[float, float] = {} with open(run_dir / f"{name}_profiles.csv", newline="") as fh: for r in csv.DictReader(fh): - by_pole.setdefault(r["pole"], {})[r["foundation"]] = float(r["mean"]) - return {pole: np.array([d[f] for f in dims]) for pole, d in by_pole.items()} + c = float(r["c"]) + by_c.setdefault(c, {})[r["foundation"]] = float(r["mean"]) + pmass[c] = float(r["pmass"]) + return {c: np.array([d[f] for f in dims]) for c, d in by_c.items()}, pmass def plot_ordinal(run_dir: Path, out: Path, name: str, vec_label: str, C: float) -> list[Path]: instr = get_instrument(name) dims = instr.dimensions - prof_pole = read_profiles(run_dir, name, dims) - base, pos, neg = prof_pole["base"], prof_pole["pos"], prof_pole["neg"] + prof_c, pmass = read_profiles(run_dir, name, dims) + cs = sorted(prof_c) + base = prof_c[0.0] + # headline arrows = the calibrated coefficient (c=+-1); the trajectory dots at |c|>1 extend + # BEYOND the arrowheads, so a multi-C run shows deployment point + where stronger steer drifts. + pos = prof_c[1.0] if 1.0 in prof_c else prof_c[max(cs)] + neg = prof_c[-1.0] if -1.0 in prof_c else prof_c[min(cs)] humans = human_strip(instr) - cs = [-1.0, 0.0, 1.0] - prof = {-1.0: neg, 0.0: base, 1.0: pos} + prof = prof_c countries, Mfrac = human_matrix(instr) labels = (f"base (c=0)", f"+C={C:+.2f}", f"-C={-C:+.2f}") @@ -117,10 +125,13 @@ def plot_ordinal(run_dir: Path, out: Path, name: str, vec_label: str, C: float) respondents, haze = T.maps.respondent_profiles(dims, instr.scale_max), None else: respondents, haze = None, human_haze(instr) + # trajectory overlay only when the run swept more than the 3-point base/+-C (else the arrows suffice) + traj = {c: _frac(prof_c[c], instr.scale_max) for c in cs} if len(cs) > 3 else None + traj_inco = {c for c, pm in pmass.items() if pm < 0.9} if traj else None 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, - labels=labels) + traj=traj, traj_incoherent=traj_inco, labels=labels) paths = [T.maps.save_both(figm, out / name, "map_pca_ipsative")] plt.close(figm) diff --git a/src/tinymfv/maps.py b/src/tinymfv/maps.py index ec95287..2c2f090 100644 --- a/src/tinymfv/maps.py +++ b/src/tinymfv/maps.py @@ -142,6 +142,7 @@ def _axis_gloss(load1: np.ndarray, dims: list[str], n: int = 2) -> str: 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, pad=(0.18, 0.16), 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 @@ -153,7 +154,11 @@ def plot_ipsative_pca(instr: Instrument, dims: list[str], countries: list[str], 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. `boots` optionally maps 'base'/'honest'/'dis' -> (n x K) bootstrap matrices. Returns + no backdrop. `traj` (signed c-multiplier -> length-K fraction vector) draws the full steer SWEEP + as a connected path through PC space, so a multi-C run shows where the steer leaves the human + cloud and curves into incoherence (the base/pos/neg arrows stay as the headline +-C anchors). + `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. Returns the Figure.""" try: import textalloc as ta @@ -203,11 +208,35 @@ def plot_ipsative_pca(instr: Instrument, dims: list[str], countries: list[str], ax.scatter(*pt, s=120, c=col, marker=mk, edgecolors="white", linewidths=1.2, zorder=7) ax.annotate(lab, pt, xytext=dxy, textcoords="offset points", fontsize=9, color=col, fontweight="bold", ha=ha, va="center", zorder=8) - compass(ax, Vt[:2].T, dims, title=f"{instr.display} compass") + traj_pts = None + if traj: + inco = traj_incoherent or set() + cs_sorted = sorted(traj) + cmax = max(abs(c) for c in cs_sorted) or 1.0 + traj_pts = np.array([proj(traj[c]) for c in cs_sorted]) + # two arms fanning from base (c=0): +c red, -c blue. Marker grows with |c|; a point whose + # admin pmass fell below the coherence floor is hollow (the steer is no longer measuring). + 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]) + ax.plot(xy[:, 0], xy[:, 1], "-", color="0.55", lw=0.9, zorder=4, alpha=0.8) + for c, p in arm: + if c == 0: + continue + col = POS_COL if c > 0 else NEG_COL + ax.scatter(p[0], p[1], s=14 + 26 * abs(c) / cmax, c="none" if c in inco else col, + edgecolors=col, linewidths=1.0, zorder=6) + cend, pend = arm[-1] if hi > 0 else arm[0] + ax.annotate(f"c={cend:+.0f}", pend, xytext=(4, 4), textcoords="offset points", + fontsize=7, color=POS_COL if cend > 0 else NEG_COL, zorder=8, + bbox=dict(boxstyle="round,pad=0.1", fc="#faf8f2", ec="none", alpha=0.7)) if cloud is not None: # crop to the human-cloud core (2-98 pct) unioned with every anchor, so societies + # poles fill the frame instead of being buried in one corner of the full cloud. - anc = np.vstack([P] + [p for p in (pb, ph, pf) if p 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(Pi[:, 0], [2, 98]), np.percentile(Pi[:, 1], [2, 98]) wx0, wx1 = min(cx[0], anc[:, 0].min()), max(cx[1], anc[:, 0].max()) wy0, wy1 = min(cy[0], anc[:, 1].min()), max(cy[1], anc[:, 1].max()) @@ -217,6 +246,17 @@ def plot_ipsative_pca(instr: Instrument, dims: list[str], countries: list[str], else: x0, x1 = ax.get_xlim(); y0, y1 = ax.get_ylim() # modest top-right headroom for the compass inset ax.set_xlim(x0, x1 + pad[0] * (x1 - x0)); ax.set_ylim(y0, y1 + pad[1] * (y1 - y0)) + # compass last, in the least-crowded corner: the +c trajectory often heads toward the same + # loading the compass shows (e.g. +authority), so a fixed top-right box collides with it. + xlo, xhi = ax.get_xlim(); ylo, yhi = ax.get_ylim() + allpts = np.vstack([P] + [p for p in (pb, ph, pf) if p is not None] + + ([traj_pts] if traj_pts is not None else [])) + fx = (allpts[:, 0] - xlo) / (xhi - xlo); fy = (allpts[:, 1] - ylo) / (yhi - ylo) + corners = {"TR": (0.62, 0.70), "TL": (0.04, 0.70), "BR": (0.62, 0.03), "BL": (0.04, 0.03)} + def crowd(bx, by): + return int(((fx >= bx) & (fx <= bx + 0.30) & (fy >= by) & (fy <= by + 0.27)).sum()) + bx, by = min(corners.values(), key=lambda b: crowd(*b)) + compass(ax, Vt[:2].T, dims, title=f"{instr.display} compass", box=(bx, by, 0.30, 0.27)) 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)