mirror of
https://github.com/wassname/moral-maps.git
synced 2026-09-11 12:20:38 +08:00
Add named-axis "value map" (maps.plot_value_map + value_axes) for all instruments
The interpretable alternative to the blind ipsative PCA map: two NAMED axes with four pole signposts through the human-median crosshair, Economist-style zone hulls, textalloc labels, no compass/minimap. Shared renderer used by every instrument (and next the WVS map). value_axes.py defines the per-instrument groupings from the literature: - mfq2/mfv: Individualizing (care/fairness) <-> Binding (loyalty/authority/purity), MFT; 2nd axis = equality<->proportionality (mfq2) / liberty<->authority (mfv). - big5: Plasticity (E+O) x Stability (A+C+reverse-N), DeYoung 2007 meta-traits. - humor: Adaptive<->Maladaptive x Self<->Other directed, HSQ 2x2 (Martin 2003). Human-only renders confirm the structure reproduces the literature: West societies sit Individualizing, African-Islamic sit Binding (mfq2); West high Plasticity+ Stability (big5). Co-Authored-By: Claudypoo <288921227+claudypoo@users.noreply.github.com>
This commit is contained in:
@@ -181,6 +181,92 @@ def draw_zone_hulls(ax, P: np.ndarray, countries: list[str], zones: dict[str, li
|
||||
path_effects=[pe.withStroke(linewidth=3.0, foreground="white")])
|
||||
|
||||
|
||||
def _pole_signposts(ax, med_x: float, med_y: float, poles: tuple[str, str, str, str]) -> None:
|
||||
"""Four arrowed pole signposts sitting ON the median crosshairs (x=med_x vertical, y=med_y
|
||||
horizontal), pointing out to each pole, in the padded inner margin. poles = (x_neg, x_pos, y_neg,
|
||||
y_pos). All labels horizontal."""
|
||||
import matplotlib.patheffects as pe
|
||||
from matplotlib.transforms import blended_transform_factory
|
||||
xn, xp, yn, yp = poles
|
||||
tX = blended_transform_factory(ax.transData, ax.transAxes) # x=data (on x=med line), y=axes frac
|
||||
tY = blended_transform_factory(ax.transAxes, ax.transData) # x=axes frac, y=data (on y=med line)
|
||||
kw = dict(fontsize=11, fontweight="bold", color="#555", zorder=10, ha="center", va="center",
|
||||
path_effects=[pe.withStroke(linewidth=3.0, foreground="white")])
|
||||
awp = dict(arrowstyle="-|>", color="#999", lw=1.3)
|
||||
ax.annotate(yp, xy=(med_x, 0.995), xytext=(med_x, 0.945), xycoords=tX, arrowprops=awp, **kw)
|
||||
ax.annotate(yn, xy=(med_x, 0.005), xytext=(med_x, 0.055), xycoords=tX, arrowprops=awp, **kw)
|
||||
ax.annotate(xn, xy=(0.006, med_y), xytext=(0.085, med_y), xycoords=tY, arrowprops=awp, **kw)
|
||||
ax.annotate(xp, xy=(0.994, med_y), xytext=(0.9, med_y), xycoords=tY, arrowprops=awp, **kw)
|
||||
|
||||
|
||||
# model-star palette: saturated/dark tones, distinct from the muted ZONE_COLORS (shared by the WVS map
|
||||
# script + plot_value_map). The coloured label ties each star to its name.
|
||||
MODEL_STAR_COLORS = ["#111111", "#d81b9a", "#5b2c86", "#008b8b", "#b8860b", "#8b0000",
|
||||
"#c2185b", "#00429d", "#5d1451", "#1a5e1a", "#7a3b00", "#444444",
|
||||
"#a80000", "#006d6d"]
|
||||
|
||||
|
||||
def plot_value_map(display: str, countries: list[str], P: np.ndarray,
|
||||
poles: tuple[str, str, str, str], *, models: dict[str, tuple[float, float]] | None = None,
|
||||
emphasize: set[str] | None = None, title: str | None = None, note: str | None = None):
|
||||
"""The interpretable "4-value map": two NAMED axes with four pole signposts through the human
|
||||
MEDIAN crosshair, Economist-style zone hulls (the 4 most-separate zones), zone-coloured dots, and
|
||||
textalloc labels (landmarks + corner outliers + one representative per zone + any models). NO
|
||||
compass / minimap / ticks -- this is the alternative to plot_ipsative_pca, not a replacement.
|
||||
|
||||
P is countries x 2 already in the named-axis space (see value_axes.value_coords / iw_axes). `models`
|
||||
maps a model name to its (x, y) in the SAME space -> drawn as labelled stars. Returns the Figure."""
|
||||
from .zones import zones_for
|
||||
import textalloc as ta
|
||||
zones_all, emph = zones_for(countries)
|
||||
emph = (emphasize or set()) | emph
|
||||
zones = select_spread_zones(P, countries, zones_all, 4)
|
||||
zone_of_c = {c: z for z, ms in zones.items() for c in ms}
|
||||
dot_cols = [ZONE_COLORS.get(zone_of_c.get(c), "#888888") for c in countries]
|
||||
cidx = {c: i for i, c in enumerate(countries)}
|
||||
reps = set()
|
||||
for ms in zones.values():
|
||||
mem = [c for c in ms if c in cidx]
|
||||
mp = P[[cidx[c] for c in mem]]
|
||||
reps.add(mem[int(np.argmin(np.hypot(*(mp - mp.mean(0)).T)))])
|
||||
label_set = emph | outlying_countries(P, countries, 4) | reps
|
||||
|
||||
med_x, med_y = float(np.median(P[:, 0])), float(np.median(P[:, 1]))
|
||||
fig, ax = plt.subplots(figsize=(10.5, 8.5))
|
||||
ax.set_facecolor("#faf8f2")
|
||||
ax.grid(True, color="#eceadf", lw=0.3, zorder=0)
|
||||
ax.axhline(med_y, color="#c9c4b4", lw=1.0, zorder=1)
|
||||
ax.axvline(med_x, color="#c9c4b4", lw=1.0, zorder=1)
|
||||
draw_zone_hulls(ax, P, countries, zones)
|
||||
ax.scatter(P[:, 0], P[:, 1], s=28, c=dot_cols, alpha=0.85, edgecolors="white", linewidths=0.5, zorder=3)
|
||||
|
||||
lab_i = [i for i, c in enumerate(countries) if c in label_set]
|
||||
tx = [P[i, 0] for i in lab_i]
|
||||
ty = [P[i, 1] for i in lab_i]
|
||||
txt = [countries[i] for i in lab_i]
|
||||
tcol = ["#111"] * len(lab_i)
|
||||
if models:
|
||||
mnames = list(models)
|
||||
mpts = np.array([models[k] for k in mnames])
|
||||
for (name, pt), col in zip(models.items(), MODEL_STAR_COLORS):
|
||||
ax.scatter(*pt, s=190, marker="*", c=col, edgecolors="white", linewidths=1.0, zorder=8)
|
||||
tx += list(mpts[:, 0]); ty += list(mpts[:, 1]); txt += mnames
|
||||
tcol += list(MODEL_STAR_COLORS[:len(mnames)])
|
||||
sx = list(P[:, 0]) + list(mpts[:, 0]); sy = list(P[:, 1]) + list(mpts[:, 1])
|
||||
else:
|
||||
sx, sy = list(P[:, 0]), list(P[:, 1])
|
||||
ax.margins(0.13)
|
||||
ta.allocate_text(fig, ax, tx, ty, txt, x_scatter=sx, y_scatter=sy,
|
||||
textsize=9, textcolor=tcol, linecolor="#aaa", linewidth=0.6, draw_lines=True)
|
||||
_pole_signposts(ax, med_x, med_y, poles)
|
||||
ax.set_xticks([]); ax.set_yticks([]); ax.set_xlabel(""); ax.set_ylabel("")
|
||||
ax.set_title(title or f"{display}: value map", fontsize=12)
|
||||
if note:
|
||||
ax.text(0.01, 0.01, note, transform=ax.transAxes, fontsize=6.5, color="#888",
|
||||
va="bottom", ha="left", zorder=10)
|
||||
return fig
|
||||
|
||||
|
||||
def draw_zone_regions(ax, P: np.ndarray, countries: list[str], zones: dict[str, list[str]],
|
||||
cloud_P: np.ndarray | None = None, cloud_countries: list[str] | None = None,
|
||||
sigma: float = 1.0) -> None:
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Named 2-axis "value map" groupings per instrument -- the interpretable alternative to the blind
|
||||
ipsative PCA map. Each instrument gets two axes with FOUR named pole directions (like the WVS
|
||||
Inglehart-Welzel map), so a point's position reads directly. -- authored by Claude
|
||||
|
||||
An axis is a list of (factor, sign): the axis score of a 0-1 fraction profile is the mean over its
|
||||
factors of the endorsement (sign +1) or its complement 1-endorsement (sign -1). So a factor entered
|
||||
with -1 is reverse-scored (big5 Stability reverses neuroticism; a contrast axis puts one pole's
|
||||
factors at -1). High score = the axis's POSITIVE (second) pole.
|
||||
|
||||
Sources: MFT individualizing/binding -- Graham & Haidt; MFQ-2 equality/proportionality fairness split
|
||||
-- Atari et al. 2023. Big Five meta-traits Plasticity/Stability -- DeYoung 2007. HSQ 2x2
|
||||
adaptive/maladaptive x self/other -- Martin et al. 2003. WVS -- Inglehart-Welzel (see tinymfv.iw_axes;
|
||||
the WVS map builds its own item-level axes, this table is for the psychometric instruments).
|
||||
|
||||
The debatable calls (flagged): the SECOND MFT axis is not canonical -- mfq2 uses the documented
|
||||
equality(egalitarian) vs proportionality(meritocratic) fairness split; mfv (no equality/proportionality
|
||||
factors) uses liberty(autonomy) vs authority(hierarchy). Rename/retune freely; this is just data.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
|
||||
# instrument -> (x_axis, y_axis); each axis = (neg_pole_label, pos_pole_label, [(factor, sign)]).
|
||||
VALUE_AXES: dict[str, tuple] = {
|
||||
"mfq2": (
|
||||
("Individualizing", "Binding",
|
||||
[("care", -1), ("equality", -1), ("proportionality", -1),
|
||||
("loyalty", 1), ("authority", 1), ("purity", 1)]),
|
||||
("Equality", "Proportionality", [("equality", -1), ("proportionality", 1)]),
|
||||
),
|
||||
"mfv": (
|
||||
("Individualizing", "Binding",
|
||||
[("care", -1), ("fairness", -1), ("liberty", -1),
|
||||
("authority", 1), ("loyalty", 1), ("sanctity", 1)]),
|
||||
("Liberty", "Authority", [("liberty", -1), ("authority", 1)]),
|
||||
),
|
||||
"big5": (
|
||||
("low Plasticity", "Plasticity", [("extraversion", 1), ("openness", 1)]),
|
||||
("low Stability", "Stability",
|
||||
[("agreeableness", 1), ("conscientiousness", 1), ("neuroticism", -1)]),
|
||||
),
|
||||
"humor_styles": (
|
||||
("Maladaptive", "Adaptive",
|
||||
[("affiliative", 1), ("selfenhancing", 1), ("aggressive", -1), ("selfdefeating", -1)]),
|
||||
("Other-directed", "Self-directed",
|
||||
[("selfenhancing", 1), ("selfdefeating", 1), ("affiliative", -1), ("aggressive", -1)]),
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def axis_score(profile_frac: np.ndarray, dims: list[str], axis: list[tuple]) -> float:
|
||||
"""Mean signed endorsement over an axis's factors (sign -1 -> 1 - endorsement). Input is a 0-1
|
||||
fraction profile in `dims` order."""
|
||||
idx = {d: i for i, d in enumerate(dims)}
|
||||
vals = [profile_frac[idx[f]] if s > 0 else 1.0 - profile_frac[idx[f]] for f, s in axis]
|
||||
return float(np.mean(vals))
|
||||
|
||||
|
||||
def value_coords(M: np.ndarray, dims: list[str], name: str) -> tuple[np.ndarray, tuple[str, str, str, str]]:
|
||||
"""(P[n,2], pole_labels) for instrument `name`: project each 0-1 fraction row of M onto its two
|
||||
named value axes. pole_labels = (x_neg, x_pos, y_neg, y_pos)."""
|
||||
(xn, xp, xa), (yn, yp, ya) = VALUE_AXES[name]
|
||||
P = np.array([[axis_score(row, dims, xa), axis_score(row, dims, ya)] for row in M])
|
||||
return P, (xn, xp, yn, yp)
|
||||
Reference in New Issue
Block a user