LLM metric-cleaning runbook
Raw model text → continuous and categorical CA metrics (reproducible pipeline)
Shared metrics: docs/ml_vs_llm.md
Parsing / ingestion: src/inference/ca_prompts.py + src/ca_personas/llm/base.py
Scoring key: src/ca_personas/scoring.py
Evaluation: src/ca_personas/evaluate.py
Braintrust logging: src/inference/braintrust_tracing.py
This runbook documents the exact cleaning and scoring steps that turn raw LLM text outputs into the continuous and categorical metrics reported on the site. Every step below maps to real code (no hand-waving): running the listed functions end-to-end reproduces the N = 1,205 v1 tables (241 participants × 5 tiers) and the band / distance / exact-match metrics.
1 Phase 1 — Data ingestion & JSON parsing
Source table. A vLLM run produces a result CSV with two required columns, caseid (e.g. P1023__demos) and generated_text.
CLI entry point:
python -m inference.ingest_results \
--result_csv outputs/vllm_results/results.csv \
--predictions_csv outputs/predictions/vllm_predictions.csvWhat it does (src/inference/ca_prompts.py::results_to_predictions):
- Reads the result CSV and normalizes
caseid;parse_caseidsplits it back intoparticipant_id+tier. Rows with malformed ids are logged aserror=ValueError: …rows and excluded from metrics. - For each row, passes
generated_texttoextract_json_object(src/ca_personas/llm/base.py:47):- Strips surrounding conversational text with
_normalize_model_text. - If a fenced block exists, keeps the content of the first
```json … ```fence. - Otherwise extracts the text between the first
{and the last}. json.loadsthe remaining string.
- Strips surrounding conversational text with
- Per-row failures are captured — they never crash the batch:
error = "<ExceptionType>: <message>". The CLI aborts (non-zero exit) only if zero rows parsed, so a silent all-fail run cannot masquerade as success.
Parse success is a reported quality gate. The v1 Llama-3.2-3B-Instruct run reached 100 % parse; the Llama-3.2-3B base run produced malformed JSON and is excluded from all analyses (see docs/persona_prompt_versions.qmd).
2 Phase 2 — Merge with ground truth & validation
Validation at parse time (src/ca_personas/llm/base.py::validate_prediction):
- Required keys are
self_reported_group_caandself_reported_interpersonal_ca; missing keys raiseValueError. - Scores are coerced with
_as_int_score: bools and non-integral floats are rejected; the value must parse to an integer. - Bounds check: both scores must satisfy
6 ≤ score ≤ 30(the PRCA subscale range). Out-of-range predictions are rejected, not clipped.
Merge (src/ca_personas/evaluate.py::evaluate_predictions):
- Ground truth is joined to predictions on
participant_id(left join). - Rows without a matching
gt_group_ca/gt_interpersonal_caemit a warning and are excluded from metric means; all metrics use the matched subset only. - Ground-truth subscales come from
add_ground_truth_scores(src/ca_personas/scoring.py:130): six Likert items per subscale, with reverse coding applied — group comfort itemsQ2 / Q4 / Q6, interpersonal comfort itemsQ14 / Q16 / Q17(reverse_score = 6 - item). Any missing item yieldsNonefor that subscale.
3 Phase 3 — Scoring & banding
Continuous subscales (scoring.py::subscale_score): each side sums six items on the 6–30 scale.
Bands (scoring.py::ca_band, low_max=13, high_min=20):
| Condition | Band |
|---|---|
score ≤ 13 |
low |
14–19 |
moderate |
score ≥ 20 |
high |
The same function derives both ground-truth bands and (when a model reports its own band) resolved prediction bands. Reported bands are normalized to low / moderate / high; whenever a numeric score is present, the band is re-derived from the score so a model’s self-reported band can never silently disagree with its own score (pred_*_band_resolved).
Ordinal encoding for distance math (scoring.py::BAND_ORDINAL): low = 0, moderate = 1, high = 2.
4 Phase 4 — Continuous metrics (row level)
For each side {group, interpersonal} (evaluate.py::evaluate_predictions):
| Column | Definition |
|---|---|
error_<side> |
pred − gt signed error (under-prediction is negative) |
abs_error_<side> |
|pred − gt| |
score_distance_<side> |
abs_error on the 6–30 scale |
norm_score_distance_<side> |
abs_error / 24, clipped to [0, 1] |
exact_match_<side> |
rounded pred == gt (integer equality) |
PRCA_SCORE_RANGE = 30 − 6 = 24 is the normalizer; clipping keeps out-of-range predictions from producing distances > 1.
5 Phase 5 — Categorical / band metrics (row level)
| Column | Definition |
|---|---|
band_match_<side> |
resolved pred band == gt band (missing if either is missing) |
band_distance_<side> |
ordinal steps |ord(pred) − ord(gt)| ∈ {0, 1, 2} (low↔︎high = 2) |
norm_band_distance_<side> |
band_distance / 2 ∈ [0, 1] |
Adjacent misses (moderate↔︎high) are therefore counted as closer than opposite misses (low↔︎high).
6 Phase 6 — Aggregation across tiers and groups
By tier / overall (evaluate.py::summarize_errors): MAE, RMSE-style spread, exact-match accuracy, band accuracy, and mean score/band distances for the whole set and each tier.
By demographic group (evaluate.py::summarize_errors_by_group): MAE and band accuracy sliced on Sex / Age / Student / Employment / transit / Q28, the inputs to the stereotyping Δ-MAE audits (docs/stereotyping_evaluation.md).
Inverse-MAE (higher is better, for prompt ranking) — computed in src/inference/braintrust_tracing.py:266:
inverse_mae_mean = 1 − min(1, mae_mean / 24)
where mae_mean averages abs_error_group and abs_error_interpersonal. A perfect predictor scores 1.0; chance-level absolute error (≥ 24) scores 0.
7 Reproducing the N = 1,205 v1 tables
python -m inference.ingest_results \
--result_csv outputs/vllm_results/results.csv \
--predictions_csv outputs/predictions/vllm_predictions.csvthen, inside Python:
from ca_personas.evaluate import evaluate_predictions, summarize_errors
from ca_personas.paths import full_cohort_paths
from ca_personas.load import load_participants
participants, _ = load_participants(*full_cohort_paths(), join_how="inner")
predictions = pd.read_csv("outputs/predictions/vllm_predictions.csv")
evaluation = evaluate_predictions(participants, predictions)
summary = summarize_errors(evaluation) # MAE / exact / band / distance by tierExpect 1,205 prediction rows (241 × 5 tiers) for the v1 prompt generation, and a reported parse count matching the run’s n_parsed in the ingest payload.
8 Failure modes & how the pipeline handles them
| Symptom | Handling |
|---|---|
| Conversational text around JSON | extract_json_object strips fences / keeps first {→last } |
Malformed caseid |
Logged as error row, excluded from metrics |
| Missing / non-integer / out-of-range score | validate_prediction raises → logged per row |
| Model-reported band vs score disagreement | Band re-derived from score (pred_*_band_resolved) |
| Zero rows parsed | ingest_results exits non-zero — no silent empty tables |
| No ground-truth match | Warning + matched-subset-only metrics |