From a2fe33ac53cda4e1c5d8aa8a36e9b1aeec5930c6 Mon Sep 17 00:00:00 2001 From: wassname <1103714+wassname@users.noreply.github.com> Date: Thu, 25 Jun 2026 08:45:01 +0800 Subject: [PATCH] maps: foundation SPLOM + minimap, crop synthetic-haze maps to societies+steer - plot_splom: KxK pairs-plot (mfq2 real joint only -- others ship independent- marginal haze that would fabricate off-diagonal correlation). lower=joint scatter w/ AI base->steer trajectory, diag=marginal+AI rules, upper=Pearson r sized by |r|. Ordered by PC1 loading so binding (authority/loyalty) cluster. full + AI-zoom (macro/micro). NaN-safe at collapsed poles. - ipsative map: synthetic haze is far wider than the societies, so it now crops to societies+steer (haze clips) and adds a 'full space' minimap with a viewport rectangle -- readable big5/16pf/humor maps with macro context kept. - compass labels via textalloc (was overlapping); NaN-safe crop. Co-Authored-By: Claudypoo <288921227+claudypoo@users.noreply.github.com> --- scripts/plot_steer_showcase.py | 10 ++ src/tinymfv/maps.py | 177 ++++++++++++++++++++++++++++++--- 2 files changed, 174 insertions(+), 13 deletions(-) diff --git a/scripts/plot_steer_showcase.py b/scripts/plot_steer_showcase.py index a5955da..cda15c6 100644 --- a/scripts/plot_steer_showcase.py +++ b/scripts/plot_steer_showcase.py @@ -135,6 +135,16 @@ def plot_ordinal(run_dir: Path, out: Path, name: str, vec_label: str, C: float) paths = [T.maps.save_both(figm, out / name, "map_pca_ipsative")] plt.close(figm) + # SPLOM only for mfq2: real per-respondent joint (others ship independent-marginal haze, whose + # off-diagonals would fabricate the correlation structure). Full + AI-zoom (macro + micro). + if name == "mfq2": + proffrac = {c: _frac(prof_c[c], instr.scale_max) for c in cs} + for zoom, tag in [(False, "splom"), (True, "splom_zoom")]: + figs = T.maps.plot_splom(instr, dims, respondents, Mfrac, _frac(base, instr.scale_max), + proffrac, zoom=zoom, vec_label=vec_label) + paths.append(T.maps.save_both(figs, out / name, tag)) + plt.close(figs) + figr = T.maps.plot_range(instr, dims, cs, prof, humans, None, vec_label) paths.append(T.maps.save_both(figr, out / name, "range")) plt.close(figr) diff --git a/src/tinymfv/maps.py b/src/tinymfv/maps.py index 2c2f090..8938626 100644 --- a/src/tinymfv/maps.py +++ b/src/tinymfv/maps.py @@ -118,18 +118,51 @@ def compass(ax_main, L: np.ndarray, labels: list[str], title: str = "compass", cax = ax_main.inset_axes(list(box)) cax.patch.set_alpha(0.0) 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) - cax.text(x / r * (r + 0.07), y / r * (r + 0.07), lab.capitalize(), fontsize=8, - fontweight="bold", color=color, ha="left" if x >= 0 else "right", - va="bottom" if y >= 0 else "top", clip_on=False) + 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).""" + from matplotlib.patches import Rectangle + xlo, xhi, ylo, yhi = view + mm = ax_main.inset_axes(list(box)) + 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("full space", fontsize=6.5, 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.""" @@ -232,37 +265,155 @@ def plot_ipsative_pca(instr: Instrument, dims: list[str], countries: list[str], 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)) + # The synthetic haze (big5/16pf/humor: independent-marginal resample) is far wider than the + # societies, so cropping to its 2-98 pct buries the societies + steer in a tiny central blob. + # There we zoom to the SOCIETIES + steer anchors (the haze still scatters but clips) and add a + # minimap showing where that frame sits in the full human cloud. mfq2's cloud is real + # per-respondent spread (well-conditioned), so it stays the crop reference. + synthetic = haze is not None and respondents is None 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_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()) + ref = P if synthetic else Pi + cx, cy = np.percentile(ref[:, 0], [2, 98]), np.percentile(ref[:, 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 ax.set_xlim(wx0 - 0.05 * sx, wx1 + 0.05 * sx + pad[0] * sx) # right/top headroom for compass inset ax.set_ylim(wy0 - 0.05 * sy, wy1 + 0.05 * sy + pad[1] * sy) 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 + # compass + minimap in the least-crowded corners: 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): + corners = {"TR": (0.62, 0.70), "TL": (0.04, 0.70), "BR": (0.62, 0.04), "BL": (0.04, 0.04)} + def crowd(name): + bx, by = corners[name] 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)) + ranked = sorted(corners, key=crowd) + want_minimap = synthetic and cloud is not None + comp_corner = ranked[1] if want_minimap else ranked[0] + if want_minimap: + mb = corners[ranked[0]] + _minimap(ax, Pi, P, pb, (xlo, xhi, ylo, yhi), box=(mb[0], mb[1], 0.26, 0.26)) + cb = corners[comp_corner] + compass(ax, Vt[:2].T, dims, title=f"{instr.display} compass", box=(cb[0], cb[1], 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) 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: # marginal + AI rules + ax.hist(cloud_n[:, fr], bins=22, range=rng[fr], color=CLOUD_GREY, + alpha=0.55, edgecolor="none") + 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.axvline(prof_n[cc][fr], color=col, lw=(1.6 if cc == 0 else 0.9), zorder=5) + ax.set_xlim(*rng[fr]); ax.set_yticks([]) + ax.text(0.5, 0.86, dims[fr], transform=ax.transAxes, ha="center", va="top", + fontsize=7.5, fontweight="bold", color="0.25") + elif r > c: # joint scatter + trajectory + ax.scatter(cloud_n[:, fc], cloud_n[:, fr], 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, base_y: float,