initial commit

This commit is contained in:
Orihara Izaya
2026-11-10 15:31:59 +01:00
committed by Line Abele
commit d58e741f5d
12 changed files with 2012 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
# project_ArchiveGPT_scripts
This repository contains non-main analysis for the *ArchiveGPT* project. Each folder contains its own analysis.
The following analyses are contained:
- archivegpt_reliability: Analysis of interrater reliability, due to a question from a researcher.
@@ -0,0 +1,234 @@
# ArchiveGPT inter-rater reliability
This is an additional reproducible analysis of how far raters agreed with one another about the quality
of the descriptions in the [ArchiveGPT study](https://doi.org/10.1057/s41599-026-08367-6), and of what the reported
single-rater intraclass correlation of ~0.11 does and does not establish.
It is accompanied by the technical note `output/reliability_report.txt` — every result in that note is
produced by `run_all.py`.
---
## Why this code starts from the raw exports
The published file `analysis/data/clean_data/correctness_per_trial.csv` assigns
its `trial` column as `rep(1:80, ...)` — serial position (line 230 of
`01_pre-processing.R`). Presentation order was **randomised per participant**, so
that column does not identify the stimulus. No item-level reliability analysis
can be run from the published clean data alone.
Item identity survives only in the raw PsychoPy exports, in the `image` and
`desc_type` columns. This package rebuilds it:
```
item = image + "|" + desc_type # 40 images x 2 sources = 80 items
```
Each participant rated all 80 items across two blocks of 40, with the description
source flipping between blocks. The design is fully crossed: 139 participants
x 80 items = 11,120 ratings, one rating per cell, no missing data.
**Fixing this in the release** — adding `image` and `desc_type` to
`correctness_per_trial.csv` — would make this reconstruction step unnecessary and
close a genuine reproducibility gap.
---
## Quick start
```bash
pip install -r requirements.txt
# 1. Point config.yaml at your copy of the extracted ArchiveGPT release,
# or override it on the command line:
python run_all.py --data-root /path/to/archive_gpt
```
That writes three files to `output/`:
| file | contents |
|---|---|
| `reliability_report.txt` | every table in the note, as plain text |
| `results.json` | the same numbers, machine-readable |
| `ratings_long.csv` | the rebuilt trial-level data **with item identity** |
`ratings_long.csv` is the artefact worth keeping: it is what the published clean
data would look like if it carried item identity.
---
## Settings
Everything lives in **`config.yaml`**. The only value you normally change is:
```yaml
paths:
data_root: "D:/McDuf/Downloads/archive_gpt"
```
This must point at the extracted Zenodo release ([record 15640284](https://zenodo.org/records/15640284)) — the folder
containing `analysis/data/raw_data/` and `analysis/data/clean_data/`. Forward
slashes work on Windows. Use `"."` if you drop this package inside the release.
Other sections, all documented inline:
- **`columns`** — column names in the raw exports. The two slider columns per
measure (`slider_q1.response`, `slider_q1_2.response`) correspond to the two
presentation blocks.
- **`design`** — what an item is, how many to expect, which `desc_type` value
means archivist-written vs model-generated, which `expert_status` value marks
each panel.
- **`analysis`** — `primary_measure` (`accuracy` or `usefulness`), `alpha`,
Monte-Carlo `homogeneity_sims`, `seed`, and how many weak raters to drop in
the final adjustment rung.
- **`validation`** — whether to abort when the reconstruction disagrees with the
published data. **Leave `enforce: true`.**
CLI flags override the file: `--data-root`, `--output`, `--measure`, `--quick`
(200 simulations instead of 2000), `--no-validate`.
---
## What gets checked before anything is computed
The whole analysis rests on having reassembled item identity correctly, so that
is verified against the release before any coefficient is reported:
| check | what it proves |
|---|---|
| fully crossed design | 139 x 80 ratings, exactly one per participant x item |
| two balanced blocks | 40 items per block per participant |
| counterbalancing exact | description source flips between blocks for every image |
| ratings match `correctness_per_trial.csv` | max abs difference **0.0** |
| means match `accuracy_usefulness.csv` | max abs difference **1.4e-14** |
| independent ICC cross-check (pingouin) | max discrepancy **< 1e-15** |
The first comparison is on each participant's *sorted* ratings, because the
published `trial` column carries no item identity to align on. Passing both means
the reconstruction contains precisely the published ratings, and the only thing
added is the item label.
The pingouin cross-check is skipped with a notice if pingouin is not installed.
Everything else works without it.
---
## Layout
```
archivegpt_reliability/
├── config.yaml <- the only file you normally edit
├── requirements.txt
├── run_all.py <- entry point
├── README.md
└── src/
├── config.py loads and validates settings, resolves paths
├── build_dataset.py rebuilds item identity from the raw exports
├── validate.py checks the rebuild against the published data
├── icc.py variance components + the whole ICC family + CIs
├── diagnostics.py every Part II diagnostic
└── report.py renders the tables, writes the outputs
```
### `icc.py`
Implements the two-way random-effects decomposition
```
X_ij = mu + t_i + r_j + e_ij
```
and every coefficient derived from it. Confidence intervals follow McGraw &
Wong (1996) Table 7, and are validated against pingouin.
| function | purpose |
|---|---|
| `variance_components` | sigma^2_item, sigma^2_rater, sigma^2_error + mean squares |
| `icc_estimates` | ICC(1,1), (A,1), (C,1) and their k-rater forms |
| `icc_confidence_intervals` | exact-F and Satterthwaite intervals |
| `single_rater_validity` | sqrt(ICC(C,1)) — one rater's correlation with truth |
| `descriptive_scale` | the components translated into rating points |
| `spearman_brown`, `raters_needed` | panel-size planning |
| `crosscheck_pingouin` | independent re-estimation |
### `diagnostics.py`
| function | question it answers |
|---|---|
| `pairwise_stats` | distribution of rater-pair correlations |
| `homogeneity_test` | are raters interchangeable, or does quality genuinely differ? |
| `rater_validity` | each rater vs the rest, disattenuated |
| `severity_stats` | how differently raters use the scale |
| `cumulative_adjustments` | the coefficient as design effects are modelled |
| `block_effects` | first-block to second-block drift |
| `source_split` | agreement **and** true spread, by description source |
| `range_restriction_counterfactual` | what a lower ICC in a subset actually means |
| `cross_panel_agreement` | do the two panels rank the descriptions alike? |
| `substantive_effect` | the human-vs-model comparison, items fixed then random |
| `replication_available` | did anyone rate an item twice? |
| `image_taste_test` | is a rater's deviation stable across the same image? |
| `residual_split_consequences` | what turns on the unidentified residual split |
| `within_rater_measure_coherence` | is each rater internally self-consistent? |
---
## Three things the code is careful about
**1. Which axis a "within-item SD" is taken over.** `data.std(axis=1)` is the SD
across raters within an item; `axis=0` is the SD across items within a rater.
They are different quantities and easy to confuse — an earlier draft of the note
reported the second under the first's name. `source_split` now returns both,
named explicitly.
**2. Comparing like with like in the counterfactual.** The range-restriction
counterfactual must be built in the same ICC form as the observed value it is
compared against. Substituting a consistency-form counterfactual for an
absolute-agreement observation silently overstates the gap (0.171 instead of
0.105).
**3. The block-order artifact in `image_taste_test`.** The two descriptions of an
image necessarily fall in different blocks, so the ~4-point block drift induces a
spurious *negative* correlation if it is not removed first. Run with
`control_block=False` to see the artifact (median r = -0.053, p = 0.014) and
`True` for the real answer (median r = -0.017, p = 0.293). `run_all.py` prints
both.
---
## Known limitations
- **No replication.** Exactly one rating per participant x item, so the residual
confounds stable rater x item interaction with momentary noise. They are not
identified, and the interpretation of the whole result turns on the split.
`residual_split_consequences` shows the range; only a retest resolves it.
- **The two-way model has no block term.** The ~4-point block drift is absorbed
into the residual. `cumulative_adjustments` shows the effect of removing it
(0.156 -> 0.165).
- **`image_taste_test` is a proxy, not a retest.** It rules out stable taste for
the depicted *image*; it says nothing about taste for the specific
*description*, which is the component that matters.
- **Sample.** 29 participants carry `expert_status == "expert"`; an external
reanalysis reported 28. If you are comparing figures directly, resolve the
one-participant discrepancy first.
- **"Expert" is heterogeneous**: 14 archaeology or history students, 9 archive
students, 4 archivists, 1 archaeologist, 1 hobby historian.
---
## Requirements
Python 3.9+. `numpy`, `pandas`, `scipy`, `PyYAML`. Optionally `pingouin` for the
independent ICC cross-check.
## References
- Shrout, P. E., & Fleiss, J. L. (1979). *Psychological Bulletin*, 86(2), 420–428.
- McGraw, K. O., & Wong, S. P. (1996). *Psychological Methods*, 1(1), 30–46.
- Koo, T. K., & Li, M. Y. (2016). *Journal of Chiropractic Medicine*, 15(2), 155–163.
- Bornmann, L., Mutz, R., & Daniel, H.-D. (2010). *PLoS ONE*, 5(12), e14331.
- Erosheva, E. A., Martinková, P., & Lee, C. J. (2021). *JRSS-A*, 184(3), 904–919.
## Data
Abele, L., Anders, G., Aydın, T., Buder, J., Fischer, H., Kimmel, D., & Huff, M.
*ArchiveGPT.* Zenodo, record 15640284. https://zenodo.org/records/15640284
@@ -0,0 +1,98 @@
# ==============================================================================
# ArchiveGPT inter-rater reliability -- analysis settings
# ==============================================================================
# The only value you normally need to change is paths.data_root.
#
# Everything else describes the structure of the ArchiveGPT release
# (Zenodo record 15640284) and the parameters of the analysis. Override any
# value at the command line with, e.g.:
# python run_all.py --data-root /path/to/archive_gpt
# ==============================================================================
paths:
# ---------------------------------------------------------------------------
# Root of the extracted ArchiveGPT release. Must contain
# analysis/data/raw_data/ (the per-session PsychoPy exports)
# analysis/data/clean_data/ (the published aggregates)
# Forward slashes work on Windows too. Use "." if this package sits inside
# the release folder.
# ---------------------------------------------------------------------------
data_root: "D:/McDuf/Downloads/archive_gpt"
# Locations within data_root. Change only if you reorganised the release.
raw_data_dir: "analysis/data/raw_data"
clean_data_dir: "analysis/data/clean_data"
# Published clean-data files. These are used ONLY to (a) define which
# participants were retained in the published sample and (b) validate the
# reconstruction. The reliability analysis itself reads the raw exports,
# because the published trial-level file does not carry item identity.
participants_file: "dprimes_willingness_trust_expertise.csv"
trial_file: "correctness_per_trial.csv"
aggregate_file: "accuracy_usefulness.csv"
# Results are written here. Relative paths resolve against this config file.
output_dir: "output"
columns:
# Column names in the raw PsychoPy exports.
participant: "Participant"
image: "image"
desc_type: "desc_type"
# The experiment presents 80 descriptions in two blocks of 40. Each block
# writes to its own slider column; a trial has exactly one of them non-null.
# List them in block order.
accuracy: ["slider_q1.response", "slider_q1_2.response"]
usefulness: ["slider_q2.response", "slider_q2_2.response"]
# Column names in the published participants file.
expert_status: "expert_status"
expertise_detail: "archive_expertise_status"
design:
# An ITEM is one description: one image shown with one description source.
# 40 images x 2 sources = 80 items. Presentation order was randomised, so the
# `trial` column in the published clean data is serial position, NOT item
# identity. This is why the analysis must start from the raw exports.
n_images_expected: 40
n_items_expected: 80
# Values of desc_type identifying who wrote the description.
source_human: "expert" # written by an archivist
source_model: "ai" # generated by InternVL2
# Values of expert_status identifying the two rater panels.
panel_expert: "expert"
panel_nonexpert: "non_expert"
# Slider bounds, used for reporting only.
rating_scale: [0, 100]
analysis:
# Which rating to treat as primary. "usefulness" reruns everything on the
# second slider.
primary_measure: "accuracy"
# Confidence level for all interval estimates.
alpha: 0.05
# Monte Carlo replications for the rater-homogeneity test (Section 5 of the
# note). 2000 is ample; 200 is enough for a quick look.
homogeneity_sims: 2000
# Seed for every stochastic step, so runs are reproducible.
seed: 20240607
# How many lowest-validity raters to drop in the final rung of the
# cumulative-adjustment ladder (Section 2 of the note).
drop_weakest_n: 3
validation:
# Abort if the reconstruction disagrees with the published clean data.
# Leave this on: it is the check that proves the rebuilt item identity is
# correct.
enforce: true
# Maximum tolerated absolute difference against the published files.
tolerance: 1.0e-6
@@ -0,0 +1,9 @@
# Core (required)
numpy>=1.23
pandas>=1.5
scipy>=1.9
PyYAML>=6.0
# Optional: enables the independent ICC cross-check in run_all.py.
# Everything works without it; the check is simply skipped.
pingouin>=0.5.3
@@ -0,0 +1,109 @@
#!/usr/bin/env python3
"""Reproduce the full ArchiveGPT inter-rater reliability analysis.
python run_all.py # uses config.yaml as written
python run_all.py --data-root /path/to/archive_gpt
python run_all.py --measure usefulness
python run_all.py --quick # fewer simulations, for a fast look
Writes reliability_report.txt, results.json and ratings_long.csv to the output
directory named in config.yaml.
"""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from src.build_dataset import ReconstructionError, build_long_frame, rating_matrix
from src.config import ConfigError, load_config
from src.icc import crosscheck_pingouin
from src.report import build_report, write_outputs
from src.validate import ValidationError, validate_reconstruction
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="ArchiveGPT inter-rater reliability analysis.",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument("--config", default=None, help="settings file (default: config.yaml)")
parser.add_argument("--data-root", default=None, help="override paths.data_root")
parser.add_argument("--output", default=None, help="override paths.output_dir")
parser.add_argument(
"--measure",
choices=["accuracy", "usefulness"],
default=None,
help="override analysis.primary_measure",
)
parser.add_argument(
"--quick", action="store_true", help="use 200 homogeneity simulations instead of the configured number"
)
parser.add_argument("--no-validate", action="store_true", help="skip the validation checks")
return parser.parse_args()
def main() -> int:
args = parse_args()
try:
cfg = load_config(args.config, args.data_root, args.output)
except ConfigError as exc:
print(f"\nConfiguration problem:\n {exc}\n", file=sys.stderr)
return 2
if args.measure:
cfg.raw["analysis"]["primary_measure"] = args.measure
if args.quick:
cfg.raw["analysis"]["homogeneity_sims"] = 200
if args.no_validate:
cfg.raw["validation"]["enforce"] = False
print(cfg.describe())
print("\nRebuilding item identity from the raw exports ...")
try:
long = build_long_frame(cfg)
except ReconstructionError as exc:
print(f"\nReconstruction failed:\n {exc}\n", file=sys.stderr)
return 3
print("\nValidating against the published clean data ...")
try:
report = validate_reconstruction(long, cfg)
except ValidationError as exc:
print(f"\n{exc}\n", file=sys.stderr)
return 4
print(report.render())
matrix = rating_matrix(
long, measure=cfg.analysis["primary_measure"], panel=cfg.design["panel_expert"]
)
discrepancies = crosscheck_pingouin(matrix.to_numpy())
if discrepancies is None:
print("\n [SKIP] pingouin not installed; ICC cross-check not run "
"(pip install pingouin)")
else:
worst = max(discrepancies.values())
status = "PASS" if worst < 1e-9 else "FAIL"
print(f"\n [{status}] independent ICC cross-check (pingouin): "
f"max discrepancy {worst:.2e}")
if status == "FAIL":
return 5
print("\nRunning the analysis ...")
text, results = build_report(long, cfg)
print(text)
written = write_outputs(text, results, long, cfg)
print(f"\n{'=' * 78}\nWrote:")
for path in written:
print(f" {path}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,2 @@
"""ArchiveGPT inter-rater reliability analysis."""
__version__ = "1.0.0"
@@ -0,0 +1,211 @@
"""Rebuild trial-level ratings with item identity from the raw PsychoPy exports.
Why this module exists
----------------------
The published file ``clean_data/correctness_per_trial.csv`` assigns its ``trial``
column as ``rep(1:80, ...)`` -- serial position. Because presentation order was
randomised per participant, that column does NOT identify the stimulus, and no
item-level reliability analysis can be run from the published clean data alone.
Item identity survives only in the raw exports, in the ``image`` and
``desc_type`` columns. An item is one description:
item = image + "|" + desc_type (40 images x 2 sources = 80 items)
Each participant sees all 80 across two blocks of 40, with the description
source flipping between blocks. The design is fully crossed.
"""
from __future__ import annotations
import re
from pathlib import Path
import numpy as np
import pandas as pd
from .config import Config
VERSION_PATTERN = re.compile(r"Exp1b?_(v[0-9a-z]+)", re.IGNORECASE)
class ReconstructionError(RuntimeError):
"""Raised when the rebuilt dataset does not have the expected structure."""
def _session_version(filename: str) -> str:
match = VERSION_PATTERN.search(filename)
return match.group(1) if match else "unknown"
def _read_session(path: Path, cfg: Config) -> pd.DataFrame | None:
"""Extract one session's trials as long rows, or None if it holds no data.
Partial and aborted sessions are common in the release (many raw files lack
the slider columns entirely); those are skipped here and, in any case,
excluded later by the restriction to the published sample.
"""
cols = cfg.columns
try:
frame = pd.read_csv(path, low_memory=False)
except Exception:
return None
needed = {cols["image"], cols["desc_type"], cols["participant"]}
if not needed.issubset(frame.columns):
return None
participants = frame[cols["participant"]].dropna()
if participants.empty:
return None
participant = participants.iloc[0]
acc_cols = cfg.measure_columns("accuracy")
use_cols = cfg.measure_columns("usefulness")
version = _session_version(path.name)
blocks = []
for block, (acc_col, use_col) in enumerate(zip(acc_cols, use_cols), start=1):
if acc_col not in frame.columns:
continue
rows = frame[frame[acc_col].notna()]
if rows.empty:
continue
block_frame = pd.DataFrame(
{
"participant": participant,
"version": version,
"block": block,
"image": rows[cols["image"]].astype(str).values,
"desc_type": rows[cols["desc_type"]].astype(str).values,
"accuracy": rows[acc_col].astype(float).values,
"usefulness": (
rows[use_col].astype(float).values
if use_col in frame.columns
else np.nan
),
"source_file": path.name,
}
)
blocks.append(block_frame)
if not blocks:
return None
return pd.concat(blocks, ignore_index=True)
def build_long_frame(cfg: Config, verbose: bool = True) -> pd.DataFrame:
"""Read every raw export and return one row per participant x item.
The returned frame is restricted to the participants retained in the
published sample and carries their panel membership.
"""
files = sorted(cfg.raw_data_dir.glob("*.csv"))
if not files:
raise ReconstructionError(f"No CSV files found in {cfg.raw_data_dir}")
sessions = [frame for frame in (_read_session(path, cfg) for path in files) if frame is not None]
if not sessions:
raise ReconstructionError(
f"None of the {len(files)} files in {cfg.raw_data_dir} contained "
f"usable trials. Check columns.* in config.yaml."
)
long = pd.concat(sessions, ignore_index=True)
long = long.dropna(subset=["participant", "image", "desc_type", "accuracy"])
long["participant"] = long["participant"].astype(int)
long["item"] = long["image"] + "|" + long["desc_type"]
if verbose:
print(
f" read {len(files)} raw files -> {len(sessions)} with trials, "
f"{len(long):,} ratings, {long['participant'].nunique()} participants"
)
long = _restrict_to_published_sample(long, cfg, verbose=verbose)
_check_structure(long, cfg)
return long
def _restrict_to_published_sample(
long: pd.DataFrame, cfg: Config, verbose: bool = True
) -> pd.DataFrame:
"""Keep only participants in the published sample and attach panel labels."""
cols = cfg.columns
published = pd.read_csv(cfg.participants_file)
keep_cols = [cols["participant"], cols["expert_status"]]
if cols["expertise_detail"] in published.columns:
keep_cols.append(cols["expertise_detail"])
published = published[keep_cols].rename(
columns={
cols["participant"]: "participant",
cols["expert_status"]: "panel",
cols["expertise_detail"]: "expertise_detail",
}
)
merged = long.merge(published, on="participant", how="inner")
if verbose:
counts = merged.groupby("panel")["participant"].nunique().to_dict()
print(
f" restricted to published sample: {len(merged):,} ratings, "
f"{merged['participant'].nunique()} participants {counts}"
)
return merged
def _check_structure(long: pd.DataFrame, cfg: Config) -> None:
"""Assert the rebuilt design is what the experiment specifies."""
design = cfg.design
n_images = long["image"].nunique()
n_items = long["item"].nunique()
if n_images != design["n_images_expected"]:
raise ReconstructionError(
f"Expected {design['n_images_expected']} images, found {n_images}."
)
if n_items != design["n_items_expected"]:
raise ReconstructionError(
f"Expected {design['n_items_expected']} items "
f"(image x desc_type), found {n_items}."
)
per_cell = long.groupby(["participant", "item"]).size()
if per_cell.max() != 1:
raise ReconstructionError(
f"Design is not one rating per participant x item "
f"(max = {per_cell.max()}). Repeated measurements would change "
f"the model in Part I of the note."
)
per_participant = long.groupby("participant")["item"].nunique()
incomplete = per_participant[per_participant != design["n_items_expected"]]
if not incomplete.empty:
raise ReconstructionError(
f"{len(incomplete)} participants do not have all "
f"{design['n_items_expected']} items: "
f"{incomplete.head().to_dict()}"
)
def rating_matrix(
long: pd.DataFrame,
measure: str = "accuracy",
panel: str | None = None,
desc_type: str | None = None,
) -> pd.DataFrame:
"""Pivot to an items x raters matrix, dropping any incomplete rows.
Returns a DataFrame so item and participant labels survive; call
``.to_numpy()`` for the array the ICC routines expect.
"""
subset = long
if panel is not None:
subset = subset[subset["panel"] == panel]
if desc_type is not None:
subset = subset[subset["desc_type"] == desc_type]
matrix = subset.pivot_table(index="item", columns="participant", values=measure)
return matrix.dropna()
@@ -0,0 +1,162 @@
"""Load, resolve and validate the analysis settings in config.yaml."""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import Any
try:
import yaml
except ImportError as exc: # pragma: no cover
raise SystemExit(
"PyYAML is required to read config.yaml.\n"
" pip install pyyaml (or: pip install -r requirements.txt)"
) from exc
PACKAGE_ROOT = Path(__file__).resolve().parent.parent
DEFAULT_CONFIG = PACKAGE_ROOT / "config.yaml"
class ConfigError(RuntimeError):
"""Raised when the settings file is unusable or points somewhere wrong."""
@dataclass
class Config:
"""Resolved settings. Paths are absolute; everything else is as written."""
raw: dict[str, Any]
config_path: Path
data_root: Path
raw_data_dir: Path
clean_data_dir: Path
participants_file: Path
trial_file: Path
aggregate_file: Path
output_dir: Path
# --- convenience accessors -------------------------------------------------
@property
def columns(self) -> dict[str, Any]:
return self.raw["columns"]
@property
def design(self) -> dict[str, Any]:
return self.raw["design"]
@property
def analysis(self) -> dict[str, Any]:
return self.raw["analysis"]
@property
def validation(self) -> dict[str, Any]:
return self.raw["validation"]
def measure_columns(self, measure: str) -> list[str]:
"""Slider columns for 'accuracy' or 'usefulness', in block order."""
try:
return list(self.columns[measure])
except KeyError as exc:
raise ConfigError(
f"Unknown measure {measure!r}. "
f"Expected one of: accuracy, usefulness."
) from exc
def describe(self) -> str:
return (
f"config : {self.config_path}\n"
f"data_root : {self.data_root}\n"
f"raw exports : {self.raw_data_dir}\n"
f"clean data : {self.clean_data_dir}\n"
f"output : {self.output_dir}\n"
f"measure : {self.analysis['primary_measure']}\n"
f"seed : {self.analysis['seed']}"
)
def _resolve(base: Path, value: str) -> Path:
"""Resolve `value` against `base` unless it is already absolute."""
path = Path(str(value)).expanduser()
return path if path.is_absolute() else (base / path)
def load_config(
config_path: str | Path | None = None,
data_root_override: str | Path | None = None,
output_dir_override: str | Path | None = None,
) -> Config:
"""Read config.yaml, resolve every path, and check the data is reachable.
Parameters
----------
config_path
Path to the settings file. Defaults to config.yaml beside this package.
data_root_override
If given, replaces paths.data_root (the --data-root CLI flag).
output_dir_override
If given, replaces paths.output_dir (the --output CLI flag).
"""
config_path = Path(config_path or DEFAULT_CONFIG).expanduser().resolve()
if not config_path.is_file():
raise ConfigError(f"Settings file not found: {config_path}")
with config_path.open("r", encoding="utf-8") as handle:
raw = yaml.safe_load(handle)
for section in ("paths", "columns", "design", "analysis", "validation"):
if section not in raw:
raise ConfigError(f"config.yaml is missing the '{section}' section.")
paths = raw["paths"]
here = config_path.parent
data_root = _resolve(here, data_root_override or paths["data_root"]).resolve()
if not data_root.is_dir():
raise ConfigError(
f"data_root does not exist: {data_root}\n"
f"Edit paths.data_root in {config_path}, or pass --data-root."
)
raw_data_dir = _resolve(data_root, paths["raw_data_dir"])
clean_data_dir = _resolve(data_root, paths["clean_data_dir"])
for label, directory in (("raw_data_dir", raw_data_dir), ("clean_data_dir", clean_data_dir)):
if not directory.is_dir():
raise ConfigError(
f"{label} does not exist: {directory}\n"
f"Does data_root point at the extracted ArchiveGPT release?"
)
participants_file = _resolve(clean_data_dir, paths["participants_file"])
trial_file = _resolve(clean_data_dir, paths["trial_file"])
aggregate_file = _resolve(clean_data_dir, paths["aggregate_file"])
if not participants_file.is_file():
raise ConfigError(
f"Participants file not found: {participants_file}\n"
f"This file defines the retained sample and is required."
)
output_dir = _resolve(here, output_dir_override or paths["output_dir"])
output_dir.mkdir(parents=True, exist_ok=True)
measure = raw["analysis"].get("primary_measure", "accuracy")
if measure not in raw["columns"]:
raise ConfigError(
f"analysis.primary_measure is {measure!r}, which has no entry under "
f"'columns'. Expected 'accuracy' or 'usefulness'."
)
return Config(
raw=raw,
config_path=config_path,
data_root=data_root,
raw_data_dir=raw_data_dir,
clean_data_dir=clean_data_dir,
participants_file=participants_file,
trial_file=trial_file,
aggregate_file=aggregate_file,
output_dir=output_dir,
)
@@ -0,0 +1,416 @@
"""Every diagnostic reported in Part II of the technical note.
Each function is independent and takes either the long frame or an items x
raters matrix, so they can be run piecemeal from a notebook.
"""
from __future__ import annotations
import numpy as np
import pandas as pd
from scipy import stats
from .config import Config
from .icc import icc_estimates, variance_components
# ---------------------------------------------------------------------------
# Pairwise agreement and whether the panel is homogeneous
# ---------------------------------------------------------------------------
def pairwise_stats(matrix) -> dict[str, float]:
"""Distribution of the correlations between every pair of raters."""
data = np.asarray(matrix, dtype=float)
corr = np.corrcoef(data.T)
pairs = corr[np.triu_indices(data.shape[1], k=1)]
return {
"n_pairs": int(pairs.size),
"median": float(np.median(pairs)),
"mean": float(pairs.mean()),
"sd": float(pairs.std(ddof=1)),
"min": float(pairs.min()),
"max": float(pairs.max()),
"share_below_0.10": float((pairs < 0.10).mean()),
"share_negative": float((pairs < 0).mean()),
}
def homogeneity_test(matrix, n_sims: int = 2000, seed: int = 0) -> dict[str, float]:
"""Are the raters interchangeable, or does their quality genuinely differ?
Simulates panels of interchangeable raters with the observed variance
components and compares the dispersion of pairwise correlations. If the
observed dispersion exceeds the simulated range, rater quality is
heterogeneous and a single pooled coefficient hides real structure.
"""
comp = variance_components(matrix)
observed = pairwise_stats(matrix)["sd"]
rng = np.random.default_rng(seed)
n, k = comp.n_items, comp.n_raters
sd_item = np.sqrt(max(comp.var_item, 0.0))
sd_error = np.sqrt(comp.var_error)
simulated = np.empty(n_sims)
for i in range(n_sims):
truth = rng.normal(0, sd_item, (n, 1))
noise = rng.normal(0, sd_error, (n, k))
corr = np.corrcoef((truth + noise).T)
simulated[i] = corr[np.triu_indices(k, k=1)].std(ddof=1)
lo, hi = np.percentile(simulated, [2.5, 97.5])
excess = max(observed**2 - simulated.mean() ** 2, 0.0)
return {
"observed_sd": float(observed),
"expected_sd": float(simulated.mean()),
"expected_lo": float(lo),
"expected_hi": float(hi),
"heterogeneous": bool(observed > hi),
"implied_true_sd": float(np.sqrt(excess)),
"n_sims": int(n_sims),
}
# ---------------------------------------------------------------------------
# Individual raters
# ---------------------------------------------------------------------------
def rater_validity(matrix) -> pd.DataFrame:
"""Each rater's correlation with the mean of all others, disattenuated.
The comparison mean is itself unreliable, so the raw correlation understates
a rater's validity. Dividing by the square root of that mean's reliability
gives the implied correlation with the item's true score.
"""
data = np.asarray(matrix, dtype=float)
k = data.shape[1]
c1 = icc_estimates(data)["ICC(C,1)"]
reliability_rest = (k - 1) * c1 / (1 + (k - 2) * c1) if c1 > 0 else np.nan
raw = np.array(
[np.corrcoef(data[:, j], np.delete(data, j, axis=1).mean(axis=1))[0, 1] for j in range(k)]
)
validity = raw / np.sqrt(reliability_rest) if reliability_rest > 0 else np.full(k, np.nan)
labels = matrix.columns if isinstance(matrix, pd.DataFrame) else np.arange(k)
return pd.DataFrame(
{"rater": labels, "corr_with_rest": raw, "implied_validity": validity}
).sort_values("corr_with_rest", ascending=False, ignore_index=True)
def severity_stats(matrix) -> dict[str, float]:
"""How differently raters use the scale, independent of their ordering."""
data = np.asarray(matrix, dtype=float)
means = data.mean(axis=0)
sds = data.std(axis=0, ddof=1)
return {
"rater_mean_min": float(means.min()),
"rater_mean_median": float(np.median(means)),
"rater_mean_max": float(means.max()),
"rater_mean_spread": float(means.max() - means.min()),
"rater_mean_sd": float(means.std(ddof=1)),
"within_rater_sd_min": float(sds.min()),
"within_rater_sd_median": float(np.median(sds)),
"within_rater_sd_max": float(sds.max()),
}
# ---------------------------------------------------------------------------
# Where the coefficient goes as design and rater effects are modelled
# ---------------------------------------------------------------------------
def cumulative_adjustments(
long: pd.DataFrame, cfg: Config, measure: str, panel: str
) -> list[tuple[str, float]]:
"""The ladder from the reported figure to a fully specified one."""
from .build_dataset import rating_matrix
subset = long[long["panel"] == panel].copy()
matrix = rating_matrix(subset, measure=measure, panel=panel)
estimates = icc_estimates(matrix.to_numpy())
rungs = [
("as reported: ICC(A,1), severity treated as error", estimates["ICC(A,1)"]),
("+ severity modelled: ICC(C,1)", estimates["ICC(C,1)"]),
]
# Remove each rater's block-specific mean: the block-order drift is a design
# effect the two-way model has no term for, so it lands in the residual.
subset["adjusted"] = (
subset[measure]
- subset.groupby(["participant", "block"])[measure].transform("mean")
+ subset.groupby("participant")[measure].transform("mean")
)
drift_removed = rating_matrix(subset, measure="adjusted", panel=panel)
rungs.append(
("+ block-order drift removed within rater", icc_estimates(drift_removed.to_numpy())["ICC(C,1)"])
)
# Drop the weakest raters.
n_drop = int(cfg.analysis.get("drop_weakest_n", 0))
if n_drop > 0:
ranked = rater_validity(drift_removed)
keep = ranked["rater"].to_numpy()[: len(ranked) - n_drop]
trimmed = drift_removed[keep]
rungs.append(
(
f"+ {n_drop} lowest-validity raters excluded",
icc_estimates(trimmed.to_numpy())["ICC(C,1)"],
)
)
return rungs
def block_effects(long: pd.DataFrame, measure: str, panel: str | None = None) -> dict[str, float]:
"""Mean drift between the first and second block of 40 descriptions."""
subset = long if panel is None else long[long["panel"] == panel]
means = subset.groupby("block")[measure].mean()
first, second = float(means.get(1, np.nan)), float(means.get(2, np.nan))
return {"block1_mean": first, "block2_mean": second, "drift": first - second}
# ---------------------------------------------------------------------------
# Restriction of range: splitting by who wrote the description
# ---------------------------------------------------------------------------
def source_split(long: pd.DataFrame, cfg: Config, measure: str, panel: str) -> pd.DataFrame:
"""Compare the two description sources on agreement AND on true spread.
A lower ICC for one source does not mean raters agreed less about it. It can
equally mean that source produced more uniform descriptions, leaving less
true variance to detect. Both quantities are reported side by side.
"""
from .build_dataset import rating_matrix
rows = []
for source in (cfg.design["source_human"], cfg.design["source_model"]):
matrix = rating_matrix(long, measure=measure, panel=panel, desc_type=source)
data = matrix.to_numpy()
comp = variance_components(data)
item_means = data.mean(axis=1)
within = [
np.abs(row[:, None] - row[None, :])[np.triu_indices(len(row), k=1)].mean()
for row in data
]
rows.append(
{
"source": source,
"n_items": comp.n_items,
"mean_disagreement_pts": float(np.mean(within)),
# SD across RATERS within an item, averaged over items. Note the
# axis: axis=1 is within-item; axis=0 would be within-rater
# across items, which is a different quantity entirely.
"within_item_sd_pts": float(data.std(axis=1, ddof=1).mean()),
"within_rater_sd_pts": float(data.std(axis=0, ddof=1).mean()),
"var_rater": comp.var_rater,
"var_error": comp.var_error,
"item_mean_sd_pts": float(item_means.std(ddof=1)),
"item_mean_min": float(item_means.min()),
"item_mean_max": float(item_means.max()),
"var_item": comp.var_item,
"ICC(A,1)": icc_estimates(data)["ICC(A,1)"],
"mean_rating": float(data.mean()),
}
)
return pd.DataFrame(rows)
def range_restriction_counterfactual(split: pd.DataFrame, cfg: Config) -> dict[str, float]:
"""What the human-written subset's ICC would be with the model subset's spread.
The counterfactual must be built in the SAME form as the observed value it is
compared against -- here ICC(A,1) -- so the human subset's rater variance stays
in the denominator. Mixing an absolute-agreement observation with a
consistency-form counterfactual silently overstates the gap.
"""
human = split[split["source"] == cfg.design["source_human"]].iloc[0]
model = split[split["source"] == cfg.design["source_model"]].iloc[0]
# Substitute the model subset's true variance; keep the human subset's
# rater and residual variance exactly as observed.
counterfactual = model["var_item"] / (
model["var_item"] + human["var_rater"] + human["var_error"]
)
return {
"observed_human": float(human["ICC(A,1)"]),
"counterfactual_human": float(counterfactual),
# From the ANOVA variance components.
"item_variance_ratio": float(model["var_item"] / human["var_item"]),
# From the observed item means: includes sampling noise, so it differs
# slightly from sqrt(item_variance_ratio). Both are reported because the
# first is the estimand and the second is what a reader can see.
"item_sd_ratio_observed": float(
model["item_mean_sd_pts"] / human["item_mean_sd_pts"]
),
"item_sd_ratio_components": float(np.sqrt(model["var_item"] / human["var_item"])),
}
# ---------------------------------------------------------------------------
# Does the aggregate hold up, and do the panels agree with each other?
# ---------------------------------------------------------------------------
def cross_panel_agreement(long: pd.DataFrame, cfg: Config, measure: str) -> dict[str, float]:
"""Correlation between the two panels' item means.
Individuals within a panel may correlate weakly while the panels themselves
converge -- the signature of real signal recovered from noisy judgements.
"""
expert = long[long["panel"] == cfg.design["panel_expert"]].groupby("item")[measure].mean()
novice = long[long["panel"] == cfg.design["panel_nonexpert"]].groupby("item")[measure].mean()
joined = pd.concat([expert, novice], axis=1, keys=["expert", "nonexpert"]).dropna()
return {
"r": float(joined["expert"].corr(joined["nonexpert"])),
"n_items": int(len(joined)),
}
def substantive_effect(long: pd.DataFrame, cfg: Config, measure: str, panel: str) -> dict[str, float]:
"""The human-vs-model comparison, with items fixed and then items random.
Treating items as fixed generalises only to these 80 descriptions. Treating
them as random generalises to new descriptions, which is usually the claim
a paper actually wants to make.
"""
subset = long[long["panel"] == panel]
human, model = cfg.design["source_human"], cfg.design["source_model"]
wide = subset.pivot_table(index="participant", columns="desc_type", values=measure)
diff = (wide[human] - wide[model]).dropna()
t_participants = stats.ttest_1samp(diff, 0.0)
item_means = subset.groupby(["item", "desc_type"])[measure].mean().reset_index()
a = item_means.loc[item_means["desc_type"] == human, measure]
b = item_means.loc[item_means["desc_type"] == model, measure]
t_items = stats.ttest_ind(a, b)
pooled_sd = np.sqrt((a.var(ddof=1) + b.var(ddof=1)) / 2)
return {
"mean_human": float(wide[human].mean()),
"mean_model": float(wide[model].mean()),
"difference": float(diff.mean()),
"n_raters": int(len(diff)),
"t_participants": float(t_participants.statistic),
"df_participants": int(len(diff) - 1),
"p_participants": float(t_participants.pvalue),
"dz": float(diff.mean() / diff.std(ddof=1)),
"t_items": float(t_items.statistic),
"df_items": int(len(a) + len(b) - 2),
"p_items": float(t_items.pvalue),
"d_items": float((a.mean() - b.mean()) / pooled_sd),
}
# ---------------------------------------------------------------------------
# The replication question
# ---------------------------------------------------------------------------
def replication_available(long: pd.DataFrame) -> dict[str, int]:
"""Did any participant rate any item more than once?"""
per_cell = long.groupby(["participant", "item"]).size()
return {
"min_per_cell": int(per_cell.min()),
"max_per_cell": int(per_cell.max()),
"n_cells": int(len(per_cell)),
"has_replication": bool(per_cell.max() > 1),
}
def image_taste_test(
long: pd.DataFrame, cfg: Config, measure: str, panel: str, control_block: bool = True
) -> dict[str, float]:
"""Is a rater's deviation from consensus stable across the same image?
Each rater saw both descriptions of every image, roughly forty trials apart.
If their deviation reflected stable taste for the depicted image, the two
deviations would correlate.
``control_block`` matters. The two descriptions of any image necessarily
fall in different blocks, so the block-order drift induces a spurious
NEGATIVE correlation if it is not removed first. Run with
``control_block=False`` to see the artifact.
"""
subset = long[long["panel"] == panel].copy()
grouping = ["participant", "block"] if control_block else ["participant"]
subset["residual"] = (
subset[measure]
- subset.groupby("item")[measure].transform("mean")
- subset.groupby(grouping)[measure].transform("mean")
+ subset[measure].mean()
)
wide = (
subset.pivot_table(index=["participant", "image"], columns="desc_type", values="residual")
.dropna()
.reset_index()
)
human, model = cfg.design["source_human"], cfg.design["source_model"]
per_rater = wide.groupby("participant").apply(
lambda g: g[model].corr(g[human]), include_groups=False
).dropna()
test = stats.ttest_1samp(per_rater, 0.0)
ci = stats.t.interval(0.95, len(per_rater) - 1, per_rater.mean(), per_rater.sem())
return {
"median_r": float(per_rater.median()),
"mean_r": float(per_rater.mean()),
"pooled_r": float(wide[model].corr(wide[human])),
"n_raters": int(len(per_rater)),
"t": float(test.statistic),
"df": int(len(per_rater) - 1),
"p": float(test.pvalue),
"ci_lo": float(ci[0]),
"ci_hi": float(ci[1]),
"block_controlled": control_block,
}
def residual_split_consequences(matrix, shares=(0.0, 0.25, 0.50, 0.75)) -> pd.DataFrame:
"""What the coefficient becomes under each possible split of the residual.
The residual confounds stable rater x item interaction with momentary noise.
One rating per cell cannot separate them, and the interpretation of the whole
result turns on the split -- which is what a retest would settle.
"""
comp = variance_components(matrix)
rows = []
for share in shares:
stable = comp.var_error * share
noise = comp.var_error * (1 - share)
rows.append(
{
"stable_share": share,
"consensus_referenced_ICC": comp.var_item / (comp.var_item + noise),
"interaction_free_ICC": (
comp.var_item / (comp.var_item + stable) if stable > 0 else float("nan")
),
}
)
return pd.DataFrame(rows)
def within_rater_measure_coherence(long: pd.DataFrame, panel: str) -> dict[str, float]:
"""Correlation between a rater's two sliders, raw and consensus-residualised.
A high value means each rater is internally self-consistent. Because both
judgements were made on the same screen, it demonstrates stability WITHIN an
encounter, not across encounters -- which is exactly the gap a retest closes.
"""
subset = long[long["panel"] == panel].copy()
if subset["usefulness"].isna().all():
return {}
raw = subset.groupby("participant").apply(
lambda g: g["accuracy"].corr(g["usefulness"]), include_groups=False
)
for measure in ("accuracy", "usefulness"):
subset[f"res_{measure}"] = (
subset[measure]
- subset.groupby("item")[measure].transform("mean")
- subset.groupby("participant")[measure].transform("mean")
+ subset[measure].mean()
)
residual = subset.groupby("participant").apply(
lambda g: g["res_accuracy"].corr(g["res_usefulness"]), include_groups=False
)
return {
"raw_median": float(raw.median()),
"raw_q25": float(raw.quantile(0.25)),
"raw_q75": float(raw.quantile(0.75)),
"residual_median": float(residual.median()),
}
@@ -0,0 +1,277 @@
"""Variance components and the full intraclass-correlation family.
Implements the two-way random-effects decomposition
X_ij = mu + t_i + r_j + e_ij
on a complete items x raters matrix, and every ICC form derived from it.
Confidence intervals follow McGraw & Wong (1996), Table 7.
Nothing here depends on anything beyond numpy and scipy, so the estimates can
be checked against an independent implementation (see ``crosscheck_pingouin``).
References
----------
Shrout, P. E., & Fleiss, J. L. (1979). Psychological Bulletin, 86(2), 420-428.
McGraw, K. O., & Wong, S. P. (1996). Psychological Methods, 1(1), 30-46.
"""
from __future__ import annotations
from dataclasses import dataclass, asdict
import numpy as np
from scipy import stats
@dataclass
class Components:
"""Variance components and mean squares from the two-way ANOVA."""
n_items: int
n_raters: int
grand_mean: float
ms_item: float
ms_rater: float
ms_error: float
ms_within: float # one-way pooled error, for ICC(1,*)
var_item: float # sigma^2_t -- true differences between descriptions
var_rater: float # sigma^2_r -- rater severity (a main effect)
var_error: float # sigma^2_e -- residual: interaction + noise
@property
def var_total(self) -> float:
return self.var_item + self.var_rater + self.var_error
@property
def shares(self) -> dict[str, float]:
total = self.var_total
return {
"item": self.var_item / total,
"rater": self.var_rater / total,
"error": self.var_error / total,
}
def as_dict(self) -> dict:
out = asdict(self)
out["var_total"] = self.var_total
out["shares"] = self.shares
return out
def variance_components(matrix) -> Components:
"""Two-way random-effects ANOVA on a complete items x raters matrix."""
data = np.asarray(matrix, dtype=float)
if data.ndim != 2:
raise ValueError("Expected a 2-D items x raters matrix.")
if np.isnan(data).any():
raise ValueError("Matrix contains missing values; pass a complete matrix.")
n, k = data.shape
if n < 2 or k < 2:
raise ValueError(f"Need at least 2 items and 2 raters, got {n} x {k}.")
grand = data.mean()
row_means = data.mean(axis=1)
col_means = data.mean(axis=0)
ss_item = k * ((row_means - grand) ** 2).sum()
ss_rater = n * ((col_means - grand) ** 2).sum()
ss_total = ((data - grand) ** 2).sum()
ss_error = ss_total - ss_item - ss_rater
ms_item = ss_item / (n - 1)
ms_rater = ss_rater / (k - 1)
ms_error = ss_error / ((n - 1) * (k - 1))
ms_within = (ss_rater + ss_error) / (n * (k - 1))
return Components(
n_items=n,
n_raters=k,
grand_mean=float(grand),
ms_item=float(ms_item),
ms_rater=float(ms_rater),
ms_error=float(ms_error),
ms_within=float(ms_within),
var_item=float((ms_item - ms_error) / k),
var_rater=float((ms_rater - ms_error) / n),
var_error=float(ms_error),
)
def icc_estimates(matrix) -> dict[str, float]:
"""All ICC point estimates.
Keys use the McGraw & Wong names, with Shrout & Fleiss equivalents:
ICC(1,1) = one-way single -- rater and residual inseparable
ICC(A,1) = ICC(2,1) absolute agreement, single rater
ICC(C,1) = ICC(3,1) consistency, single rater
ICC(1,k), ICC(A,k), ICC(C,k) -- the same for the mean of k raters
"""
c = variance_components(matrix)
n, k = c.n_items, c.n_raters
msr, msc, mse, msw = c.ms_item, c.ms_rater, c.ms_error, c.ms_within
return {
"ICC(1,1)": (msr - msw) / (msr + (k - 1) * msw),
"ICC(A,1)": (msr - mse) / (msr + (k - 1) * mse + k * (msc - mse) / n),
"ICC(C,1)": (msr - mse) / (msr + (k - 1) * mse),
"ICC(1,k)": (msr - msw) / msr,
"ICC(A,k)": (msr - mse) / (msr + (msc - mse) / n),
"ICC(C,k)": (msr - mse) / msr,
}
def icc_confidence_intervals(matrix, alpha: float = 0.05) -> dict[str, tuple[float, float]]:
"""Confidence intervals for ICC(A,1), ICC(C,1), ICC(A,k), ICC(C,k).
Follows McGraw & Wong (1996), Table 7. ICC(1,*) is omitted because the
design here is fully crossed and the one-way form should not be reported.
"""
c = variance_components(matrix)
n, k = c.n_items, c.n_raters
msr, msc, mse = c.ms_item, c.ms_rater, c.ms_error
point = icc_estimates(matrix)
q = 1.0 - alpha / 2.0
out: dict[str, tuple[float, float]] = {}
# --- consistency forms: a plain F ratio ------------------------------------
f_obs = msr / mse
df1, df2 = n - 1, (n - 1) * (k - 1)
f_lower = f_obs / stats.f.ppf(q, df1, df2)
f_upper = f_obs * stats.f.ppf(q, df2, df1)
out["ICC(C,1)"] = (
(f_lower - 1) / (f_lower + k - 1),
(f_upper - 1) / (f_upper + k - 1),
)
out["ICC(C,k)"] = (1 - 1 / f_lower, 1 - 1 / f_upper)
# --- absolute-agreement forms: Satterthwaite approximation ------------------
def _satterthwaite(coef_a: float, coef_b: float) -> float:
num = (coef_a * msc + coef_b * mse) ** 2
den = (coef_a * msc) ** 2 / (k - 1) + (coef_b * mse) ** 2 / ((n - 1) * (k - 1))
return num / den
r1 = point["ICC(A,1)"]
if 0 < r1 < 1:
a1 = k * r1 / (n * (1 - r1))
b1 = 1 + k * r1 * (n - 1) / (n * (1 - r1))
v1 = _satterthwaite(a1, b1)
fl = stats.f.ppf(q, n - 1, v1)
fu = stats.f.ppf(q, v1, n - 1)
out["ICC(A,1)"] = (
n * (msr - fl * mse) / (fl * (k * msc + (k * n - k - n) * mse) + n * msr),
n * (fu * msr - mse) / (k * msc + (k * n - k - n) * mse + n * fu * msr),
)
else:
out["ICC(A,1)"] = (float("nan"), float("nan"))
rk = point["ICC(A,k)"]
if 0 < rk < 1:
ak = rk / (n * (1 - rk))
bk = 1 + rk * (n - 1) / (n * (1 - rk))
vk = _satterthwaite(ak, bk)
fl = stats.f.ppf(q, n - 1, vk)
fu = stats.f.ppf(q, vk, n - 1)
out["ICC(A,k)"] = (
n * (msr - fl * mse) / (fl * (msc - mse) + n * msr),
n * (fu * msr - mse) / (msc - mse + n * fu * msr),
)
else:
out["ICC(A,k)"] = (float("nan"), float("nan"))
return out
def single_rater_validity(matrix) -> float:
"""Correlation between one rating and the item's true score.
ICC(C,1) is that correlation SQUARED, so this is its square root. It is the
fair way to describe an individual rater: a median pairwise correlation of
0.14 between two noisy raters corresponds to each rater correlating 0.39
with the truth.
"""
c1 = icc_estimates(matrix)["ICC(C,1)"]
return float(np.sqrt(c1)) if c1 > 0 else float("nan")
def descriptive_scale(matrix) -> dict[str, float]:
"""Translate the components into expected differences in rating points.
For a normal variable with SD s, the expected absolute difference between
two independent draws is 2s/sqrt(pi).
"""
c = variance_components(matrix)
single_error = np.sqrt(max(c.var_rater, 0) + c.var_error)
return {
"true_difference_between_items": 2 * np.sqrt(max(c.var_item, 0)) / np.sqrt(np.pi),
"difference_between_rater_means": 2 * np.sqrt(max(c.var_rater, 0)) / np.sqrt(np.pi),
"difference_on_same_item": 2 * np.sqrt(c.var_error) / np.sqrt(np.pi),
"band_single_rating": 1.96 * single_error,
"band_panel_mean": 1.96 * single_error / np.sqrt(c.n_raters),
"signal_to_noise": np.sqrt(max(c.var_item, 0)) / single_error,
}
def spearman_brown(icc_single: float, k: int) -> float:
"""Reliability of the mean of k raters, given the single-rater value."""
return k * icc_single / (1 + (k - 1) * icc_single)
def raters_needed(icc_single: float, target: float) -> float:
"""How many raters the mean needs to reach `target` reliability."""
return target * (1 - icc_single) / (icc_single * (1 - target))
def crosscheck_pingouin(matrix, long_frame=None) -> dict[str, float] | None:
"""Re-estimate with pingouin and return the absolute discrepancies.
Returns None when pingouin is not installed. Any discrepancy larger than
floating-point noise indicates a bug in this module and should be treated
as a failure.
"""
try:
import pandas as pd
import pingouin as pg
except ImportError:
return None
data = np.asarray(matrix, dtype=float)
n, k = data.shape
tidy = pd.DataFrame(
{
"item": np.repeat(np.arange(n), k),
"rater": np.tile(np.arange(k), n),
"rating": data.ravel(),
}
)
theirs = pg.intraclass_corr(
data=tidy, targets="item", raters="rater", ratings="rating"
).set_index("Type")["ICC"]
# pingouin >= 0.5.3 uses the McGraw & Wong names directly; older releases
# used ICC1/ICC2/ICC3. Accept either.
legacy = {
"ICC1": "ICC(1,1)",
"ICC2": "ICC(A,1)",
"ICC3": "ICC(C,1)",
"ICC1k": "ICC(1,k)",
"ICC2k": "ICC(A,k)",
"ICC3k": "ICC(C,k)",
}
ours = icc_estimates(matrix)
discrepancies: dict[str, float] = {}
for label in ours:
if label in theirs.index:
discrepancies[label] = abs(float(theirs[label]) - ours[label])
else:
for old, new in legacy.items():
if new == label and old in theirs.index:
discrepancies[label] = abs(float(theirs[old]) - ours[label])
if not discrepancies:
raise RuntimeError(
"pingouin returned no recognisable ICC labels "
f"(saw {list(theirs.index)}); cross-check could not be run."
)
return discrepancies
@@ -0,0 +1,302 @@
"""Render every table in the technical note as plain text, and as JSON."""
from __future__ import annotations
import json
from datetime import date
from pathlib import Path
import numpy as np
import pandas as pd
from . import diagnostics as dx
from .build_dataset import rating_matrix
from .config import Config
from .icc import (
descriptive_scale,
icc_confidence_intervals,
icc_estimates,
raters_needed,
single_rater_validity,
variance_components,
)
RULE = "=" * 78
THIN = "-" * 78
def _h(title: str) -> str:
return f"\n{RULE}\n{title}\n{RULE}"
def build_report(long: pd.DataFrame, cfg: Config) -> tuple[str, dict]:
"""Run the full analysis and return (text report, machine-readable results)."""
measure = cfg.analysis["primary_measure"]
alpha = float(cfg.analysis["alpha"])
expert = cfg.design["panel_expert"]
nonexpert = cfg.design["panel_nonexpert"]
out: list[str] = []
results: dict = {
"generated": date.today().isoformat(),
"measure": measure,
"data_root": str(cfg.data_root),
}
out.append(f"ArchiveGPT inter-rater reliability -- {measure}")
out.append(f"generated {date.today().isoformat()} | data: {cfg.data_root}")
# -- sample ---------------------------------------------------------------
out.append(_h("SAMPLE"))
panels = long.groupby("panel")["participant"].nunique().to_dict()
out.append(f" {len(long):,} ratings, {long['participant'].nunique()} participants, "
f"{long['item'].nunique()} items ({long['image'].nunique()} images x 2 sources)")
out.append(f" panels: {panels}")
if "expertise_detail" in long.columns:
detail = (
long.drop_duplicates("participant")
.groupby("expertise_detail")["participant"]
.nunique()
.sort_values(ascending=False)
.to_dict()
)
out.append(f" expertise detail: {detail}")
results["sample"] = {"n_ratings": len(long), "panels": panels}
# -- Part I: components ---------------------------------------------------
out.append(_h("PART I -- VARIANCE COMPONENTS"))
results["components"] = {}
for panel in (expert, nonexpert):
matrix = rating_matrix(long, measure=measure, panel=panel)
comp = variance_components(matrix.to_numpy())
shares = comp.shares
out.append(f"\n {panel} ({comp.n_items} items x {comp.n_raters} raters, "
f"grand mean {comp.grand_mean:.1f})")
out.append(f" {'component':<26s}{'variance':>10s}{'SD':>8s}{'share':>9s}")
out.append(f" {THIN[:53]}")
for label, var in (
("item (signal)", comp.var_item),
("rater (severity)", comp.var_rater),
("residual", comp.var_error),
):
key = label.split()[0]
out.append(
f" {label:<26s}{var:10.1f}{np.sqrt(max(var,0)):8.1f}"
f"{shares[key if key != 'residual' else 'error']:9.1%}"
)
out.append(f" {'total (one rating)':<26s}{comp.var_total:10.1f}"
f"{np.sqrt(comp.var_total):8.1f}{1.0:9.1%}")
results["components"][panel] = comp.as_dict()
# -- Part I: the ICC family ----------------------------------------------
out.append(_h("PART I -- THE ICC FAMILY"))
results["icc"] = {}
for panel in (expert, nonexpert):
matrix = rating_matrix(long, measure=measure, panel=panel)
data = matrix.to_numpy()
est = icc_estimates(data)
cis = icc_confidence_intervals(data, alpha=alpha)
k = data.shape[1]
out.append(f"\n {panel} (k = {k})")
out.append(f" {'form':<12s}{'estimate':>10s} {int((1-alpha)*100)}% CI")
out.append(f" {THIN[:44]}")
for form in ("ICC(1,1)", "ICC(A,1)", "ICC(C,1)", "ICC(A,k)", "ICC(C,k)"):
ci = cis.get(form)
ci_text = f"[{ci[0]:.3f}, {ci[1]:.3f}]" if ci else "--"
out.append(f" {form:<12s}{est[form]:10.3f} {ci_text}")
validity = single_rater_validity(data)
out.append(f" single-rater validity = sqrt(ICC(C,1)) = {validity:.2f}"
f" (a median pairwise r of {validity**2:.2f} is this value squared)")
results["icc"][panel] = {"estimates": est, "ci": {k_: list(v) for k_, v in cis.items()}}
# -- Part I: descriptive scale -------------------------------------------
out.append(_h("PART I -- THE COMPONENTS IN RATING POINTS (expert panel)"))
matrix = rating_matrix(long, measure=measure, panel=expert)
scale = descriptive_scale(matrix.to_numpy())
out.append(f" true difference between two random descriptions {scale['true_difference_between_items']:6.1f}")
out.append(f" difference between two raters' overall averages {scale['difference_between_rater_means']:6.1f}")
out.append(f" difference between two raters on the SAME item {scale['difference_on_same_item']:6.1f}")
out.append(f" 95% band, one rating around an item's true score +/-{scale['band_single_rating']:5.1f}")
out.append(f" 95% band, panel mean around an item's true score +/-{scale['band_panel_mean']:5.1f}")
out.append(f" signal-to-noise for a single rating {scale['signal_to_noise']:6.2f}")
results["descriptive_scale"] = scale
single = icc_estimates(matrix.to_numpy())["ICC(A,1)"]
out.append(f"\n raters needed for an aggregate reliability of "
f"0.70 / 0.80 / 0.90: "
f"{raters_needed(single, 0.70):.0f} / {raters_needed(single, 0.80):.0f} / "
f"{raters_needed(single, 0.90):.0f}")
# -- Part II: pairwise + homogeneity -------------------------------------
out.append(_h("PART II -- PAIRWISE AGREEMENT AND PANEL HOMOGENEITY"))
results["pairwise"] = {}
for panel in (expert, nonexpert):
data = rating_matrix(long, measure=measure, panel=panel).to_numpy()
pw = dx.pairwise_stats(data)
out.append(f"\n {panel}: {pw['n_pairs']} pairs, median r = {pw['median']:.3f}, "
f"SD = {pw['sd']:.3f}")
out.append(f" below r = 0.10: {pw['share_below_0.10']:.1%} negative: {pw['share_negative']:.1%}")
results["pairwise"][panel] = pw
data = rating_matrix(long, measure=measure, panel=expert).to_numpy()
hom = dx.homogeneity_test(
data, n_sims=int(cfg.analysis["homogeneity_sims"]), seed=int(cfg.analysis["seed"])
)
out.append(f"\n Homogeneity test ({expert} panel, {hom['n_sims']} simulations):")
out.append(f" observed SD of pairwise r {hom['observed_sd']:.3f}")
out.append(f" expected if raters interchangeable {hom['expected_sd']:.3f} "
f"(95% range {hom['expected_lo']:.3f}-{hom['expected_hi']:.3f})")
out.append(f" verdict: {'HETEROGENEOUS -- rater quality genuinely differs' if hom['heterogeneous'] else 'consistent with interchangeable raters'}")
if hom["heterogeneous"]:
out.append(f" implied SD of true pairwise agreement {hom['implied_true_sd']:.3f}")
results["homogeneity"] = hom
# -- Part II: individual raters ------------------------------------------
out.append(_h("PART II -- INDIVIDUAL RATER VALIDITY (expert panel)"))
matrix = rating_matrix(long, measure=measure, panel=expert)
val = dx.rater_validity(matrix)
qs = [0, 25, 50, 75, 100]
out.append(f" {'':<10s}" + "".join(f"{f'p{q}':>10s}" for q in qs))
out.append(f" {'corr rest':<10s}" + "".join(
f"{np.percentile(val['corr_with_rest'], q):+10.2f}" for q in qs))
out.append(f" {'validity':<10s}" + "".join(
f"{np.percentile(val['implied_validity'], q):+10.2f}" for q in qs))
n_dead = int((val["corr_with_rest"] <= 0.10).sum())
n_good = int((val["implied_validity"] >= 0.50).sum())
out.append(f"\n contributing essentially nothing (r <= 0.10): {n_dead} of {len(val)}")
out.append(f" reaching validity >= 0.50: {n_good} of {len(val)}")
results["rater_validity"] = {
"table": val.to_dict(orient="records"),
"n_noncontributing": n_dead,
"n_solid": n_good,
}
sev = dx.severity_stats(matrix.to_numpy())
out.append(f"\n Severity (scale use, independent of ordering):")
out.append(f" rater means span {sev['rater_mean_min']:.1f} to {sev['rater_mean_max']:.1f} "
f"({sev['rater_mean_spread']:.1f} points), SD {sev['rater_mean_sd']:.1f}")
out.append(f" within-rater SD spans {sev['within_rater_sd_min']:.1f} to "
f"{sev['within_rater_sd_max']:.1f}")
results["severity"] = sev
# -- Part II: cumulative ladder ------------------------------------------
out.append(_h("PART II -- COEFFICIENT AS DESIGN AND RATER EFFECTS ARE MODELLED"))
ladder = dx.cumulative_adjustments(long, cfg, measure, expert)
for label, value in ladder:
out.append(f" {label:<52s} {value:.3f}")
blocks = dx.block_effects(long, measure, expert)
out.append(f"\n block-order drift: block 1 mean {blocks['block1_mean']:.1f}, "
f"block 2 mean {blocks['block2_mean']:.1f} ({blocks['drift']:+.1f})")
results["cumulative"] = ladder
results["block_effects"] = blocks
# -- Part II: source split -----------------------------------------------
out.append(_h("PART II -- SPLIT BY DESCRIPTION SOURCE (expert panel)"))
split = dx.source_split(long, cfg, measure, expert)
human, model = cfg.design["source_human"], cfg.design["source_model"]
rows = [
("mean disagreement between two raters (pts)", "mean_disagreement_pts", "{:.1f}"),
("within-item SD, across raters (pts)", "within_item_sd_pts", "{:.1f}"),
("residual variance", "var_error", "{:.1f}"),
("SD of true item means (pts)", "item_mean_sd_pts", "{:.1f}"),
("item variance", "var_item", "{:.1f}"),
("ICC(A,1)", "ICC(A,1)", "{:.3f}"),
("mean rating", "mean_rating", "{:.1f}"),
]
out.append(f" {'':<44s}{human:>14s}{model:>14s}")
out.append(f" {THIN[:72]}")
for label, key, fmt in rows:
a = split.loc[split["source"] == human, key].iloc[0]
b = split.loc[split["source"] == model, key].iloc[0]
out.append(f" {label:<44s}{fmt.format(a):>14s}{fmt.format(b):>14s}")
cf = dx.range_restriction_counterfactual(split, cfg)
out.append(f"\n RANGE RESTRICTION CHECK")
out.append(f" A lower ICC for one source does not mean raters agreed less about it.")
out.append(f" observed ICC(A,1) for {human}-written: {cf['observed_human']:.3f}")
out.append(f" same raters, with the {model} subset's true spread: {cf['counterfactual_human']:.3f}")
out.append(f" true variance ratio {model}/{human}: {cf['item_variance_ratio']:.2f}x "
f"({cf['item_sd_ratio_observed']:.2f}x on the observed item-mean SD scale)")
results["source_split"] = split.to_dict(orient="records")
results["range_restriction"] = cf
# -- Part II: does the aggregate hold ------------------------------------
out.append(_h("PART II -- THE AGGREGATE, AND THE SUBSTANTIVE EFFECT"))
for panel in (expert, nonexpert):
data = rating_matrix(long, measure=measure, panel=panel).to_numpy()
est = icc_estimates(data)
out.append(f" {panel:<12s} single rater {est['ICC(A,1)']:.3f} "
f"mean of {data.shape[1]:>3d} raters {est['ICC(A,k)']:.3f}")
xp = dx.cross_panel_agreement(long, cfg, measure)
out.append(f"\n the two panels rank the {xp['n_items']} descriptions at r = {xp['r']:.3f}")
results["cross_panel"] = xp
results["substantive"] = {}
for panel in (expert, nonexpert):
eff = dx.substantive_effect(long, cfg, measure, panel)
out.append(f"\n {panel}: {human}-written {eff['mean_human']:.1f} vs "
f"{model}-written {eff['mean_model']:.1f} ({eff['difference']:+.2f} pts)")
out.append(f" items FIXED : t({eff['df_participants']}) = {eff['t_participants']:.2f}, "
f"p = {eff['p_participants']:.2g}, dz = {eff['dz']:.2f}")
out.append(f" items RANDOM : t({eff['df_items']}) = {eff['t_items']:.2f}, "
f"p = {eff['p_items']:.2g}, d = {eff['d_items']:.2f}")
results["substantive"][panel] = eff
# -- Part II: replication -------------------------------------------------
out.append(_h("PART II -- REPLICATION AND THE RETEST QUESTION"))
rep = dx.replication_available(long)
out.append(f" ratings per participant x item: min {rep['min_per_cell']}, "
f"max {rep['max_per_cell']} across {rep['n_cells']:,} cells")
out.append(f" replication available: {rep['has_replication']}")
results["replication"] = rep
out.append(f"\n Proxy test -- is a rater's deviation stable across the same IMAGE?")
for controlled in (False, True):
taste = dx.image_taste_test(long, cfg, measure, expert, control_block=controlled)
tag = "block drift controlled" if controlled else "block drift NOT controlled (artifact)"
out.append(f" {tag:<38s} median r = {taste['median_r']:+.3f}, "
f"t({taste['df']}) = {taste['t']:+.2f}, p = {taste['p']:.3f}")
if controlled:
out.append(f" {'':<38s} 95% CI [{taste['ci_lo']:+.3f}, {taste['ci_hi']:+.3f}]")
results["image_taste"] = taste
coherence = dx.within_rater_measure_coherence(long, expert)
if coherence:
out.append(f"\n Within-rater coherence across the two sliders:")
out.append(f" raw median r = {coherence['raw_median']:.2f}, "
f"consensus-residualised median r = {coherence['residual_median']:.2f}")
out.append(f" (same screen seconds apart: stability WITHIN an encounter, "
f"not across encounters)")
results["measure_coherence"] = coherence
out.append(f"\n Consequences of the unidentified split of the residual:")
consequences = dx.residual_split_consequences(
rating_matrix(long, measure=measure, panel=expert).to_numpy()
)
out.append(f" {'stable share':>14s}{'consensus ICC':>16s}{'interaction-free ICC':>22s}")
for _, row in consequences.iterrows():
free = row["interaction_free_ICC"]
out.append(f" {row['stable_share']:>13.0%}{row['consensus_referenced_ICC']:>16.3f}"
f"{(f'{free:.3f}' if np.isfinite(free) else '--'):>22s}")
results["residual_split"] = consequences.to_dict(orient="records")
return "\n".join(out), results
def write_outputs(text: str, results: dict, long: pd.DataFrame, cfg: Config) -> list[Path]:
"""Write the text report, the JSON results and the rebuilt dataset."""
written = []
report_path = cfg.output_dir / "reliability_report.txt"
report_path.write_text(text, encoding="utf-8")
written.append(report_path)
json_path = cfg.output_dir / "results.json"
json_path.write_text(json.dumps(results, indent=2, default=float), encoding="utf-8")
written.append(json_path)
data_path = cfg.output_dir / "ratings_long.csv"
long.to_csv(data_path, index=False)
written.append(data_path)
return written
@@ -0,0 +1,186 @@
"""Prove the rebuilt item identity is correct by checking it against the release.
The reliability analysis stands or falls on whether ``image`` + ``desc_type``
was reassembled correctly from the raw exports. Two independent checks are
available, because the published clean data -- while it cannot identify items --
does contain the same ratings in a different arrangement:
1. Every rating must appear in ``correctness_per_trial.csv``. Comparison is on
each participant's sorted values, since the published ``trial`` column is
serial position and carries no item identity to align on.
2. The implied participant x desc_type means must reproduce
``accuracy_usefulness.csv`` exactly.
If both pass, the reconstruction contains precisely the published ratings, and
the only thing added is the item label.
"""
from __future__ import annotations
from dataclasses import dataclass, field
import numpy as np
import pandas as pd
from .config import Config
class ValidationError(RuntimeError):
"""Raised when the reconstruction disagrees with the published data."""
@dataclass
class ValidationReport:
checks: list[tuple[str, bool, str]] = field(default_factory=list)
def add(self, name: str, passed: bool, detail: str) -> None:
self.checks.append((name, passed, detail))
@property
def ok(self) -> bool:
return all(passed for _, passed, _ in self.checks)
def render(self) -> str:
lines = []
for name, passed, detail in self.checks:
mark = "PASS" if passed else "FAIL"
lines.append(f" [{mark}] {name}: {detail}")
return "\n".join(lines)
def validate_reconstruction(long: pd.DataFrame, cfg: Config) -> ValidationReport:
"""Run every available check and return the report."""
report = ValidationReport()
tolerance = float(cfg.validation["tolerance"])
measure = cfg.analysis["primary_measure"]
_check_design(long, cfg, report)
_check_trial_file(long, cfg, report, tolerance, measure)
_check_aggregate_file(long, cfg, report, tolerance)
if cfg.validation.get("enforce", True) and not report.ok:
raise ValidationError(
"Reconstruction does not match the published clean data:\n"
+ report.render()
+ "\n\nSet validation.enforce: false in config.yaml to continue anyway."
)
return report
def _check_design(long: pd.DataFrame, cfg: Config, report: ValidationReport) -> None:
design = cfg.design
n_part = long["participant"].nunique()
n_items = long["item"].nunique()
expected_rows = n_part * design["n_items_expected"]
report.add(
"fully crossed design",
len(long) == expected_rows and long.groupby(["participant", "item"]).size().max() == 1,
f"{len(long):,} ratings = {n_part} participants x {n_items} items, "
f"one rating per cell",
)
per_block = long.groupby(["participant", "block"])["item"].nunique()
report.add(
"two balanced blocks",
set(per_block.unique()) <= {design["n_items_expected"] // 2},
f"each participant rated {design['n_items_expected'] // 2} items per block",
)
flip = (
long.pivot_table(
index=["participant", "image"], columns="block", values="desc_type", aggfunc="first"
)
.dropna()
)
if flip.shape[1] == 2:
same = (flip.iloc[:, 0] == flip.iloc[:, 1]).sum()
report.add(
"counterbalancing exact",
same == 0,
f"description source flips between blocks for every image "
f"({same} exceptions)",
)
def _check_trial_file(
long: pd.DataFrame,
cfg: Config,
report: ValidationReport,
tolerance: float,
measure: str,
) -> None:
"""Compare per-participant sorted ratings with the published trial file."""
if not cfg.trial_file.is_file():
report.add("published trial file", True, f"not present, skipped ({cfg.trial_file.name})")
return
published = pd.read_csv(cfg.trial_file)
published_col = {"accuracy": "perc_acc", "usefulness": "perc_use"}.get(measure)
if published_col not in published.columns:
report.add(
"published trial file",
True,
f"no '{published_col}' column, skipped",
)
return
worst = 0.0
mismatched = 0
for participant, ours in long.groupby("participant")[measure]:
theirs = published.loc[published["Participant"] == participant, published_col].dropna()
if len(theirs) != len(ours):
mismatched += 1
continue
diff = np.abs(np.sort(ours.to_numpy()) - np.sort(theirs.to_numpy())).max()
worst = max(worst, float(diff))
report.add(
f"ratings match {cfg.trial_file.name}",
worst <= tolerance and mismatched == 0,
f"max abs difference {worst:.2e} across "
f"{long['participant'].nunique()} participants, {mismatched} length mismatches",
)
def _check_aggregate_file(
long: pd.DataFrame, cfg: Config, report: ValidationReport, tolerance: float
) -> None:
"""Compare implied participant x desc_type means with the published aggregate."""
if not cfg.aggregate_file.is_file():
report.add(
"published aggregate file", True, f"not present, skipped ({cfg.aggregate_file.name})"
)
return
published = pd.read_csv(cfg.aggregate_file)
pairs = [("accuracy", "mean_perc_acc"), ("usefulness", "mean_perc_use")]
worst = 0.0
compared = 0
for measure, published_col in pairs:
if published_col not in published.columns or long[measure].isna().all():
continue
ours = (
long.groupby(["participant", "desc_type"])[measure]
.mean()
.rename("ours")
.reset_index()
)
merged = ours.merge(
published[["Participant", "desc_type", published_col]],
left_on=["participant", "desc_type"],
right_on=["Participant", "desc_type"],
how="inner",
)
if merged.empty:
continue
diff = (merged["ours"] - merged[published_col]).abs().max()
worst = max(worst, float(diff))
compared += len(merged)
report.add(
f"means match {cfg.aggregate_file.name}",
worst <= tolerance,
f"max abs difference {worst:.2e} across {compared} participant x source cells",
)