diff --git a/src/tinymfv/maps.py b/src/tinymfv/maps.py index 9d3fdc6..c4215d2 100644 --- a/src/tinymfv/maps.py +++ b/src/tinymfv/maps.py @@ -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: diff --git a/src/tinymfv/value_axes.py b/src/tinymfv/value_axes.py new file mode 100644 index 0000000..6d662bc --- /dev/null +++ b/src/tinymfv/value_axes.py @@ -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)