Files
moral-maps/src/moralmaps/instruments.py
T
wassnameandClaudypoo 469788c418 rename package tinymfv -> moralmaps (repo -> moral-maps)
Import name tinymfv -> moralmaps, pip name tiny-mfv -> moral-maps, GitHub
URLs wassname/tinymfv -> wassname/moral-maps. HuggingFace dataset id
wassname/tiny-mfv left as-is (separate namespace, published data artifact).
Historical docs/spec/* and RESEARCH_JOURNAL keep their dated paths.

Co-Authored-By: Claudypoo <288921227+claudypoo@users.noreply.github.com>
2026-07-09 10:41:49 +08:00

91 lines
4.5 KiB
Python

"""Instrument registry: build `Instrument` objects (instrument.py schema) from the bundled survey
data. MFQ-2 plus three reused questionnaires (Big-Five, 16PF, Humor Styles), all ordinal 1-5.
Ported from the weight_steer_honesty experiment (`mft_honesty.instruments`). Each instrument is
three framing files (forward / inverted / negated) of the SAME items reworded; we load all three
into InstrItems tagged with `frame`, so the reader scores every (item, frame) and
`per_item_categorical` canonicalizes them to one forward distribution per item.
Per-instrument variation:
- data dir + framing filenames (MFQ-2: forward/inverted/negated.json; the others:
questionnaire{,_inverted,_negated}.json).
- dimensions: derived from the forward file in first-appearance order.
- keying: MFQ-2 items are all +1; IPIP/HSQ are mixed-key within a factor, sign read from
keying.json (written by the experiment's human-IPIP pipeline). Reverse items are reflected at
reduce_ordinal time, not here.
- human reference scale: all administered at 1-5; the HSQ human reference is on 1-7
(`human_scale_max`), reconciled to a 0-1 fraction at MAP time, not here. Per-item human_label is
left None (the maps use the per-country CSVs in `data/human/`, not per-item histograms).
"""
from __future__ import annotations
import json
from pathlib import Path
from .instrument import Instrument, InstrItem
DATA = Path(__file__).resolve().parent / "data"
SURVEYS = DATA / "surveys"
HUMAN = DATA / "human"
DIGITS_1_5 = ["1", "2", "3", "4", "5"]
# Same response-format prefill the experiment used: the model answers "(N)", we prefill "(".
PREFILL = "("
# instrument name -> (survey subdir, {frame: filename stem}, human_csv, display, human_scale_max)
# human_csv provenance (per-country reference means; ported from the mft_honesty experiment):
# mfq2 : Atari+ 2023 "Morality beyond the WEIRD", OSF osf.io/srtxn/ Study 2 (19 countries)
# big5 : OpenPsychometrics IPIP-FFM raw (openpsychometrics.org/_rawdata, BIG5), aggregated (24 countries)
# 16pf : OpenPsychometrics 16PF raw (openpsychometrics.org/tests/16PF), aggregated (34 countries)
# humor_styles : Schermer+ 2020 "Humor styles across 28 countries", doi 10.1007/s12144-019-00552-y (Tables 1-2)
# (MFV is not here -- it's read in scripts/plot_steer_showcase.py::read_human_mfv, provenance in that docstring.)
_F3 = {"forward": "questionnaire", "inverted": "questionnaire_inverted", "negated": "questionnaire_negated"}
_SPECS = {
"mfq2": ("mfq2", {"forward": "forward", "inverted": "inverted", "negated": "negated"},
"mfq2_country_foundations.csv", "Moral Foundations", 5),
"big5": ("big5", _F3, "big5_country_factors.csv", "Big Five", 5),
"16pf": ("16pf", _F3, "16pf_country_factors.csv", "16PF", 5),
"humor_styles": ("humor_styles", _F3, "humor_styles_country_factors.csv", "Humor Styles", 7),
}
def _load_keying(survey_dir: Path, default_forward_ids: list[str]) -> dict[str, int]:
p = survey_dir / "keying.json"
if not p.exists():
return {i: 1 for i in default_forward_ids} # MFQ-2: every item is +1
return {str(k): int(v) for k, v in json.loads(p.read_text()).items()}
def build_instrument(name: str) -> Instrument:
sub, frame_files, human_csv, display, hmax = _SPECS[name]
sdir = SURVEYS / sub
frames = {fr: json.loads((sdir / f"{stem}.json").read_text())[0] for fr, stem in frame_files.items()}
fwd = frames["forward"]
dims: list[str] = [] # subscales in first-appearance order (forward file)
for q in fwd["questions"]:
if q["dimension"] not in dims:
dims.append(q["dimension"])
keying = _load_keying(sdir, [str(q["id"]) for q in fwd["questions"]])
items: list[InstrItem] = []
for fr, doc in frames.items():
for q in doc["questions"]:
items.append(InstrItem(
id=str(q["id"]), prompt=q["content"], dimension=q["dimension"],
sign=keying[str(q["id"])], frame=fr, human_label=None,
meta={"task": doc["task"]})) # frame-specific response-scale legend
return Instrument(
name=name, construct="endorsement", kind="ordinal",
answer_space=DIGITS_1_5, dimensions=dims, items=items, prefill=PREFILL,
scale_max=5, human_scale_max=hmax,
display=display, human_csv=str(HUMAN / human_csv))
INSTRUMENTS: dict[str, Instrument] = {}
def get(name: str) -> Instrument:
if name not in INSTRUMENTS:
INSTRUMENTS[name] = build_instrument(name)
return INSTRUMENTS[name]