mirror of
https://github.com/wassname/moral-maps.git
synced 2026-09-10 12:14:54 +08:00
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>
This commit is contained in:
+35
-9
@@ -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,
|
||||
|
||||
+50
-11
@@ -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")])
|
||||
|
||||
|
||||
+10
-5
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user