Switch zone blobs to country-mean covariance ellipses, fit PCA on means

Per feedback the individual-respondent contours filled the frame (within >>
between variance). Now each IW zone is a ~1.6-sigma covariance ellipse over its
member country-mean dots, with an eigenvalue floor so 1-2 country zones get a
visible blob instead of a dot/line (fixes big5 SG/PK orphans). PCA now fits on the
country means M so the axes are between-country and zones separate. mfq2/big5/mfv
read cleanly; humor still overlaps (real negative result: humor country profiles
don't cluster the IW way).

Co-Authored-By: Claudypoo <288921227+claudypoo@users.noreply.github.com>
This commit is contained in:
wassname
2026-07-04 20:22:23 +08:00
co-authored by Claudypoo
parent 40d08f8d7a
commit b2532acd90
3 changed files with 62 additions and 86 deletions
+21 -23
View File
@@ -153,14 +153,15 @@ def human_matrix(instr) -> tuple[list[str], np.ndarray]:
return countries, _frac(raw, instr.human_scale_max)
def human_haze(instr, n_per_country: int = 200, seed: int = 0) -> np.ndarray:
"""Synthetic individual-respondent cloud (n x K, 0-1 fraction) for instruments that ship only
society-level stats (big5/16pf/humor: no raw per-person data like mfq2's Atari file). For each
(country, factor) we resample n Normal(mean, sd) draws from the published country mean+sd, so the
cloud carries BOTH between-country (different means) and within-country (sd) human spread. Caveat:
factors are drawn independently, so this marginal resample loses the cross-factor correlation a
real respondent matrix has -- it is a backdrop envelope, not a covariance estimate, and is NOT
used as the PCA basis (that stays the society means M)."""
def human_haze(instr, n_per_country: int = 200, seed: int = 0) -> tuple[np.ndarray, list[str]]:
"""Synthetic individual-respondent cloud (n x K, 0-1 fraction) + the country of each row, for
instruments that ship only society-level stats (big5/16pf/humor: no raw per-person data like
mfq2's Atari file). For each (country, factor) we resample n Normal(mean, sd) draws from the
published country mean+sd, so the cloud carries BOTH between-country (different means) and
within-country (sd) human spread. Caveat: factors are drawn independently, so this marginal
resample loses the cross-factor correlation a real respondent matrix has -- it is a backdrop
envelope, not a covariance estimate, and is NOT used as the PCA basis (that stays the society
means M). The returned country-per-row list lets the map contour it by IW zone."""
dims = instr.dimensions
rng = np.random.default_rng(seed)
stats: dict[tuple[str, str], tuple[float, float]] = {}
@@ -168,11 +169,12 @@ def human_haze(instr, n_per_country: int = 200, seed: int = 0) -> np.ndarray:
for r in csv.DictReader(fh):
stats[(r["country"], r["foundation"])] = (float(r["mean"]), float(r["sd"]))
countries = sorted({c for (c, _f) in stats})
blocks = []
blocks, row_country = [], []
for c in countries:
cols = [rng.normal(stats[(c, f)][0], stats[(c, f)][1], n_per_country) for f in dims]
blocks.append(np.clip(np.stack(cols, axis=1), 1.0, instr.human_scale_max))
return _frac(np.concatenate(blocks, axis=0), instr.human_scale_max)
row_country.extend([c] * n_per_country)
return _frac(np.concatenate(blocks, axis=0), instr.human_scale_max), row_country
def human_strip(instr) -> dict[str, list[tuple[str, float]]]:
@@ -276,23 +278,19 @@ 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)
# Each IW zone is a covariance ellipse over its member COUNTRY-MEAN dots (drawn in maps). mfq2
# scatters its real Atari respondents behind; the others scatter a per-country resample.
zones, emph = zones_for(countries)
if name == "mfq2":
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]
_, respondents = T.maps.respondent_profiles(dims, instr.scale_max)
haze = None
else:
respondents, haze = None, human_haze(instr)
zones, respondent_zones = zones_for(countries)[0], None
respondents, (haze, _) = None, human_haze(instr)
traj = {c: _frac(prof_c[c], instr.scale_max) for c in coh_cs}
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,
respondent_zones=respondent_zones, labels=labels)
traj=traj, emphasize=emph, zones=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)
@@ -366,9 +364,9 @@ def plot_mfv_map(run_dir: Path, out: Path, vec_label: str, C: float, coh_cs: lis
neg_c = min(c for c in coh_cs if c < 0.0)
labels = ("base (c=0)", f"c={pos_c:+g}", f"c={neg_c:+g}")
traj = {c: prof[c] for c in coh_cs}
zones, emph = zones_for(countries)
zones, emph = zones_for(countries) # MFV: 5 country dots, no cloud
fig = T.maps.plot_ipsative_pca(_MFV_INSTR, founds, countries, M, prof[0.0], prof[pos_c], prof[neg_c],
traj=traj, zones=zones, emphasize=emph, labels=labels)
traj=traj, emphasize=emph, zones=zones, labels=labels)
fig.axes[0].set_title(f"MFV vignettes: humans vs LLMs steered for {vec_label}", fontsize=10)
path = T.maps.save_both(fig, out / "mfv", "map_pca_ipsative")
plt.close(fig)