Files
moral-maps/src/tinymfv/instruments.py
T

85 lines
3.9 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)
_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]