Add Inglehart-Welzel zone hulls + outlier labels to ipsative maps

Echoes the Economist WVS 'Godless hippies' chart: shaded convex-hull blobs per
IW cultural zone (inline 2D hull, no scipy dep so the maps extra stays
matplotlib-only) and bold-first labels for named outliers. Caller owns the zone
taxonomy + name/ISO2 normalizer, fails loud on unmapped countries; the corrupt
'(nu' big5 row is explicitly excluded with a warning.

Co-Authored-By: Claudypoo <288921227+claudypoo@users.noreply.github.com>
This commit is contained in:
wassname
2026-07-04 19:16:41 +08:00
co-authored by Claudypoo
parent 3ae1fd48c9
commit 0e7178dfbd
2 changed files with 163 additions and 10 deletions
+87 -2
View File
@@ -35,11 +35,94 @@ matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np
from loguru import logger
import tinymfv as T
from tinymfv import get_instrument
ORDINAL = ["mfq2", "big5", "16pf", "humor_styles"]
# --- Inglehart-Welzel cultural zones (for the map's zone hulls) ---------------------------------
# WVS Wave 7 nine-cluster taxonomy. Membership is VALUE-based not geographic, so a handful are
# judgment calls: Ireland->English-Speaking, Switzerland->Protestant Europe, Philippines->Latin
# America, South Africa/Turkey->African-Islamic, India/Pakistan/Thailand->South Asia. Source: WVS
# Findings + en.wikipedia.org/wiki/Inglehart-Welzel_cultural_map_of_the_world. -- added by Claude
IW_ZONE = {
# English-Speaking
"United States": "English-Speaking", "Great Britain": "English-Speaking",
"Australia": "English-Speaking", "Canada": "English-Speaking",
"New Zealand": "English-Speaking", "Ireland": "English-Speaking",
# Protestant Europe
"Germany": "Protestant Europe", "Sweden": "Protestant Europe", "Norway": "Protestant Europe",
"Denmark": "Protestant Europe", "Netherlands": "Protestant Europe",
"Finland": "Protestant Europe", "Switzerland": "Protestant Europe",
# Catholic Europe
"France": "Catholic Europe", "Belgium": "Catholic Europe", "Italy": "Catholic Europe",
"Spain": "Catholic Europe", "Poland": "Catholic Europe", "Portugal": "Catholic Europe",
"Croatia": "Catholic Europe",
# Orthodox / Ex-Communist
"Russia": "Orthodox", "Ukraine": "Orthodox", "Bulgaria": "Orthodox", "Serbia": "Orthodox",
"Greece": "Orthodox", "Romania": "Orthodox", "Bosnia & Herzegovina": "Orthodox",
"Hungary": "Orthodox",
# Baltic
"Latvia": "Baltic", "Estonia": "Baltic",
# Confucian
"Japan": "Confucian", "China": "Confucian", "South Korea": "Confucian",
"Hong Kong": "Confucian", "Vietnam": "Confucian", "Singapore": "Confucian",
# Latin America
"Argentina": "Latin America", "Chile": "Latin America", "Colombia": "Latin America",
"Mexico": "Latin America", "Peru": "Latin America", "Brazil": "Latin America",
"Ecuador": "Latin America", "Philippines": "Latin America",
# African-Islamic
"Egypt": "African-Islamic", "Kenya": "African-Islamic", "Morocco": "African-Islamic",
"Nigeria": "African-Islamic", "Saudi Arabia": "African-Islamic",
"United Arab Emirates": "African-Islamic", "Turkey": "African-Islamic",
"Iran": "African-Islamic", "Indonesia": "African-Islamic", "Malaysia": "African-Islamic",
"South Africa": "African-Islamic",
# South Asia
"India": "South Asia", "Pakistan": "South Asia", "Thailand": "South Asia",
}
# raw country string (as it appears in the human CSVs) -> canonical IW_ZONE key. Our CSVs mix full
# names (mfv/mfq2/humor, with a "Columbia" typo) and ISO2 codes (big5/16pf). A `None` value marks a
# row we KNOW is corrupt and deliberately exclude from hulls (surfaced by a loud warning, not a
# silent drop); an unrecognised country that is NOT here falls through to IW_ZONE and KeyErrors.
_COUNTRY_CANON = {
"AE": "United Arab Emirates", "AU": "Australia", "BR": "Brazil", "CA": "Canada",
"CN": "China", "DE": "Germany", "DK": "Denmark", "EC": "Ecuador", "ES": "Spain",
"FI": "Finland", "FR": "France", "GB": "Great Britain", "GR": "Greece", "HK": "Hong Kong",
"HR": "Croatia", "ID": "Indonesia", "IE": "Ireland", "IN": "India", "IT": "Italy",
"MX": "Mexico", "MY": "Malaysia", "NL": "Netherlands", "NO": "Norway", "NZ": "New Zealand",
"PH": "Philippines", "PK": "Pakistan", "PL": "Poland", "RO": "Romania", "SE": "Sweden",
"SG": "Singapore", "TH": "Thailand", "TR": "Turkey", "US": "United States", "ZA": "South Africa",
"Columbia": "Colombia", "UAE": "United Arab Emirates",
"(nu": None, # corrupt big5 row (n=369); country unidentifiable from the aggregate CSV
}
# The named outliers on the Economist chart, bolded on our maps where present. -- added by Claude
ECONOMIST_OUTLIERS = {"China", "South Korea", "United States", "Great Britain", "Japan",
"Nigeria", "Pakistan", "Sweden"}
def zones_for(countries: list[str]) -> tuple[dict[str, list[str]], set[str]]:
"""Group verbatim country strings by IW zone + the subset to emphasize. Fails loud (KeyError)
on a country absent from the taxonomy so a name-normalization bug can't silently drop a dot from
its hull; a `None` canon (known-corrupt row) is excluded with a warning instead."""
groups: dict[str, list[str]] = {}
dropped: list[str] = []
emph: set[str] = set()
for c in countries:
canon = _COUNTRY_CANON.get(c, c)
if canon is None:
dropped.append(c)
continue
groups.setdefault(IW_ZONE[canon], []).append(c)
if canon in ECONOMIST_OUTLIERS:
emph.add(c)
if dropped:
logger.warning(f"excluded known-unmapped countries from zone hulls: {dropped}")
return groups, emph
def _frac(x, scale_max: int) -> np.ndarray:
return (np.asarray(x, float) - 1) / (scale_max - 1)
@@ -191,10 +274,11 @@ def plot_ordinal(run_dir: Path, out: Path, name: str, vec_label: str, C: float,
else:
respondents, haze = None, human_haze(instr)
traj = {c: _frac(prof_c[c], instr.scale_max) for c in coh_cs}
zones, emph = zones_for(countries)
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, labels=labels)
traj=traj, zones=zones, emphasize=emph, 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)
@@ -268,8 +352,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)
fig = T.maps.plot_ipsative_pca(_MFV_INSTR, founds, countries, M, prof[0.0], prof[pos_c], prof[neg_c],
traj=traj, labels=labels)
traj=traj, zones=zones, emphasize=emph, 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)