From b1ff08a337639fdd6f01022726154c73440715c6 Mon Sep 17 00:00:00 2001 From: wassname <1103714+wassname@users.noreply.github.com> Date: Sat, 4 Jul 2026 21:37:42 +0800 Subject: [PATCH] WVS map: generic zone/label selection + Economist pole signposts Replace hardcoded zone lists with geometric rules that work on any map: - maps.select_spread_zones: greedy max-coverage on hull areas -- seed the largest zone, add whichever contributes the most new non-overlapping area. Drops central/covered zones (Orthodox) and keeps the corner cultures. - maps.outlying_countries: the n countries farthest from the centroid, unioned with named landmarks + one representative (most-central member) per drawn zone so every region has at least one identifiable label. - draw_zone_hulls: edge-only coloured outline (no fill), contour only for 2+ member groups, label anchored to the hull's top vertex. WVS map: four arrowed pole signposts (Traditional/Secular-Rational/Survival/ Self-expression) in a padded inner margin so they don't collide with title/ticks; model stars use a palette disjoint from the zone colours. Co-Authored-By: Claudypoo <288921227+claudypoo@users.noreply.github.com> --- scripts/wvs_map.py | 44 +++++++++++++++++++++++++------- src/tinymfv/maps.py | 61 ++++++++++++++++++++++++++++++++++++-------- src/tinymfv/zones.py | 15 +++++++---- 3 files changed, 95 insertions(+), 25 deletions(-) diff --git a/scripts/wvs_map.py b/scripts/wvs_map.py index 8105877..27bf117 100644 --- a/scripts/wvs_map.py +++ b/scripts/wvs_map.py @@ -42,7 +42,9 @@ from tinymfv.read import read_items, resolve_answer_ids from tinymfv.read_api import read_items_sampled from tinymfv.iw_axes import AXIS_ITEMS, X_AXIS, Y_AXIS, SKIP, resolve_items, positiveness -MODEL_COLORS = ["#c0392b", "#8e44ad", "#16a085", "#d35400", "#2980b9", "#c2185b"] +# model-star palette deliberately DISJOINT from ZONE_COLORS (muted blue/red/orange/brown/yellow/ +# green), so a star never camouflages into a zone -- black / magenta / deep-purple read as "model". +MODEL_COLORS = ["#111111", "#d81b9a", "#5b2c86", "#008b8b", "#b8860b", "#8b0000"] # option labels are single digits 0..n-1 -- single-token (unlike '10' on the justifiable scale) and # the format the answer-token reader is tuned for (a bare digit, not a letter the model ignores in # favour of the option word). @@ -192,29 +194,53 @@ def main() -> None: cpath.write_text(json.dumps(allc)) models = {k: model_axis_scores(v, meta, resolved) for k, v in vecs.items()} - zones, emph = zones_for(countries) + # Generic legibility rule (same on every map): draw only the zones that cover the most separate + # space (farthest-first over macro-zone centroids), colour dots by their drawn zone (grey if their + # zone wasn't selected), and label the named landmarks (US/Japan/China...) plus the 4 most-outlying + # countries. + zones_all, emph = zones_for(countries) # 6 macro zones + zones = maps.select_spread_zones(P, countries, zones_all, 4) zone_of_c = {c: z for z, members in zones.items() for c in members} - dot_cols = [maps.ZONE_COLORS.get(zone_of_c[c], "#888888") for c in countries] + dot_cols = [maps.ZONE_COLORS.get(zone_of_c.get(c), "#888888") for c in countries] + # Labels: named landmarks + the 4 most-outlying + one representative per drawn zone (its most + # central member) so every region has at least one identifiable country. + cidx = {c: i for i, c in enumerate(countries)} + reps = set() + for members in zones.values(): + mem = [c for c in members if c in cidx] + pts = P[[cidx[c] for c in mem]] + reps.add(mem[int(np.argmin(np.hypot(*(pts - pts.mean(0)).T)))]) + label_set = emph | maps.outlying_countries(P, countries, 4) | reps fig, ax = plt.subplots(figsize=(11, 9)) ax.set_facecolor("#faf8f2") ax.grid(True, color="#eceadf", lw=0.3, zorder=0) ax.axhline(0.5, color="#d9d5c6", lw=0.8, zorder=1) ax.axvline(0.5, color="#d9d5c6", lw=0.8, zorder=1) maps.draw_zone_hulls(ax, P, countries, zones) - # Dots coloured by zone (region shown by colour, like the Economist) + only the named outliers - # labelled -- 90 country labels is the clutter the user flagged; the region name (drawn by - # draw_zone_regions at each zone centroid) carries the rest. ax.scatter(P[:, 0], P[:, 1], s=28, c=dot_cols, alpha=0.85, edgecolors="white", linewidths=0.5, zorder=3) for i, c in enumerate(countries): - if c in emph: + if c in label_set: ax.annotate(c, (P[i, 0], P[i, 1]), fontsize=9, xytext=(4, 3), textcoords="offset points", color="#111", fontweight="bold", zorder=6) for (name, pt), col in zip(models.items(), MODEL_COLORS): ax.scatter(*pt, s=150, marker="*", c=col, edgecolors="white", linewidths=1.0, zorder=8) ax.annotate(name, pt, xytext=(7, 4), textcoords="offset points", fontsize=9, fontweight="bold", color=col, zorder=9) - ax.set_xlabel(f"{X_AXIS} (right = self-expression)") - ax.set_ylabel(f"{Y_AXIS} (up = secular-rational)") + # Four pole signposts in the padded inner margin (Economist style): the label sits in whitespace + # just inside each edge with an arrow pointing OUT to its pole, so the two axes read unambiguously + # without colliding with ticks or the title. + ax.margins(0.13) + + def pole(tx, ty, tipx, tipy, text, rot): + ax.annotate(text, xy=(tipx, tipy), xytext=(tx, ty), xycoords="axes fraction", + ha="center", va="center", rotation=rot, fontsize=11, fontweight="bold", + color="#555", zorder=10, arrowprops=dict(arrowstyle="-|>", color="#999", lw=1.3)) + pole(0.5, 0.955, 0.5, 0.998, "Secular-Rational", 0) + pole(0.5, 0.045, 0.5, 0.002, "Traditional", 0) + pole(0.052, 0.5, 0.002, 0.5, "Survival", 90) + pole(0.948, 0.5, 0.998, 0.5, "Self-expression", 270) + ax.set_xlabel("") + ax.set_ylabel("") ax.set_title(f"WVS Inglehart-Welzel map: LLMs among {len(countries)} human societies " f"(approximate IW axes)", fontsize=12) ax.text(0.01, 0.01, diff --git a/src/tinymfv/maps.py b/src/tinymfv/maps.py index 370f440..01b3a8a 100644 --- a/src/tinymfv/maps.py +++ b/src/tinymfv/maps.py @@ -112,14 +112,52 @@ def _country_region(cen: np.ndarray, pts: np.ndarray | None, sigma: float, r_fix return Point(*cen).buffer(r_fixed, quad_segs=24) +def _zone_hull(P: np.ndarray, cidx: dict, members: list[str], buf: float): + """Buffered convex hull of a zone's country-mean points, or None if <2 members (can't contour).""" + from shapely.geometry import MultiPoint + pts = [tuple(P[cidx[c]]) for c in members if c in cidx] + return MultiPoint(pts).convex_hull.buffer(buf, quad_segs=16) if len(pts) >= 2 else None + + +def select_spread_zones(P: np.ndarray, countries: list[str], zones: dict[str, list[str]], + n: int = 4, pad: float = 0.022) -> dict[str, list[str]]: + """The `n` zones that COVER THE MOST SEPARATE SPACE -- greedy max-coverage on the actual hull + areas: seed with the largest-area zone, then repeatedly add whichever zone contributes the most + NEW (non-overlapping) area to the union. Central zones whose hull is already covered by the picks + (Orthodox sitting inside West+East-Asia) add little and are dropped; corner cultures win. Purely + geometric, so it works identically on any map. Zones with <2 members can't be contoured and are + skipped.""" + cidx = {c: i for i, c in enumerate(countries)} + buf = pad * float(np.hypot(*(P.max(0) - P.min(0)))) + hulls = {z: h for z, m in zones.items() if (h := _zone_hull(P, cidx, m, buf)) is not None} + if len(hulls) <= n: + return {z: zones[z] for z in hulls} + sel = [max(hulls, key=lambda z: hulls[z].area)] + union = hulls[sel[0]] + while len(sel) < n: + best = max((z for z in hulls if z not in sel), key=lambda z: hulls[z].difference(union).area) + sel.append(best) + union = union.union(hulls[best]) + return {z: zones[z] for z in sel} + + +def outlying_countries(P: np.ndarray, countries: list[str], n: int = 4) -> set[str]: + """The `n` countries farthest from the data centroid -- the automatic extreme labels for ANY map, + unioned with a named-major set (US/Japan/China...) so every map labels the same few landmarks plus + whatever its own extremes are.""" + d = np.hypot(*(P - P.mean(0)).T) + return {countries[i] for i in np.argsort(d)[::-1][:n]} + + def draw_zone_hulls(ax, P: np.ndarray, countries: list[str], zones: dict[str, list[str]], - pad: float = 0.022, alpha: float = 0.13) -> None: + pad: float = 0.022) -> None: """Economist-style zone outline: the tight CONVEX HULL of a zone's country-mean points, rounded - and slightly inflated (shapely buffer), lightly filled with the zone colour, thin outline, and a - white-haloed italic label at the centroid. A 1- or 2-country zone degenerates to a rounded - disc/capsule via the same buffer. Far cleaner than a union of per-country discs when the axes - already separate the countries (WVS IW map); the disc-union `draw_zone_regions` stays for the - instrument maps that overlay real within-country respondent spread.""" + and slightly inflated (shapely buffer), drawn as a coloured EDGE ONLY (no fill, so overlapping + zones don't muddy), with the zone label in the same colour anchored to the TOP of its own hull -- + so each label attaches unambiguously to one boundary even where hulls overlap. A 1- or 2-country + zone degenerates to a rounded disc/capsule via the same buffer. Cleaner than a union of + per-country discs when the axes already separate the countries (WVS IW map); the disc-union + `draw_zone_regions` stays for the instrument maps that overlay within-country respondent spread.""" import matplotlib.patheffects as pe from shapely.geometry import MultiPoint from matplotlib.patches import Polygon as MplPolygon @@ -127,14 +165,15 @@ def draw_zone_hulls(ax, P: np.ndarray, countries: list[str], zones: dict[str, li buf = pad * float(np.hypot(*(P.max(0) - P.min(0)))) for zname, members in zones.items(): pts = np.array([P[cidx[c]] for c in members if c in cidx]) - if not len(pts): + if len(pts) < 2: # convex hull needs 2+ members to contour continue geom = MultiPoint([tuple(p) for p in pts]).convex_hull.buffer(buf, quad_segs=16) + coords = np.asarray(geom.exterior.coords) zcol = ZONE_COLORS.get(zname, "#888888") - ax.add_patch(MplPolygon(np.asarray(geom.exterior.coords), closed=True, facecolor=zcol, - edgecolor=zcol, alpha=alpha, lw=1.1, zorder=1.5)) - cen = pts.mean(0) - ax.text(cen[0], cen[1], zname, fontsize=9.5, color=zcol, ha="center", va="center", + ax.add_patch(MplPolygon(coords, closed=True, facecolor="none", edgecolor=zcol, + lw=1.8, alpha=0.9, zorder=1.5)) + apex = coords[np.argmax(coords[:, 1])] # the hull's actual top vertex -> label sits ON the edge + ax.text(apex[0], apex[1], zname, fontsize=10, color=zcol, ha="center", va="bottom", style="italic", fontweight="bold", zorder=5, path_effects=[pe.withStroke(linewidth=3.0, foreground="white")]) diff --git a/src/tinymfv/zones.py b/src/tinymfv/zones.py index d777afc..66ca270 100644 --- a/src/tinymfv/zones.py +++ b/src/tinymfv/zones.py @@ -96,11 +96,14 @@ def zone_of(country: str) -> str | None: return None if canon is None else IW_ZONE[canon] -def zones_for(countries: list[str], macro: bool = True) -> tuple[dict[str, list[str]], set[str]]: +def zones_for(countries: list[str], macro: bool = True, + macro_map: dict[str, str | None] | None = None) -> tuple[dict[str, list[str]], set[str]]: """Group verbatim country strings by IW zone + the subset to emphasize (Economist outliers). - `macro` (default) collapses the nine fine zones to six broader ones (IW_MACRO) so low-dimensional - maps aren't over-fragmented. Known-corrupt rows are dropped with a warning; unrecognised countries - KeyError via zone_of.""" + `macro` (default) collapses the nine fine zones via `macro_map` (default IW_MACRO -> six zones; + pass IW_MACRO4 for the Economist's four). A fine zone mapping to None is UNGROUPED: its countries + return no hull group (plotted as bare grey dots). Known-corrupt rows are dropped with a warning; + unrecognised countries KeyError via zone_of.""" + macro_map = macro_map or IW_MACRO groups: dict[str, list[str]] = {} dropped: list[str] = [] emph: set[str] = set() @@ -109,7 +112,9 @@ def zones_for(countries: list[str], macro: bool = True) -> tuple[dict[str, list[ if z is None: dropped.append(c) continue - groups.setdefault(IW_MACRO[z] if macro else z, []).append(c) + coarse = macro_map[z] if macro else z + if coarse is not None: # None -> ungrouped (no hull), still a grey dot + groups.setdefault(coarse, []).append(c) if _CANON.get(c, c) in ECONOMIST_OUTLIERS: emph.add(c) if dropped: