
Validity: robustness, calibration, and honest claims
Source:vignettes/validity-robustness-and-calibration.Rmd
validity-robustness-and-calibration.RmdA language model will answer almost anything you ask, fluently, whether or not the answer means what you hope it means. The danger in using one for research is not that it stays silent; it is that it speaks confidently about a population it has never observed, and that small changes to the prompt move the answer in ways you never see. This vignette covers three habits that keep a model-generated result honest: testing whether it survives perturbation, calibrating it against human labels before you treat it as a measurement, and naming what kind of claim you are actually making.
library(LLMRagent)
cfg <- LLMR::llm_config("groq", "openai/gpt-oss-20b", temperature = 0.7)Robustness: does the result hold under a small perturbation?
Suppose you ask a model to rate how risky an activity is. A
defensible result is one that barely moves when you raise the
temperature, reorder the options, or reword the question. A fragile
result is one that swings. agent_robustness() runs your
procedure across a grid of such perturbations and reports how much the
answer moves along each axis.
You write the procedure once, as a function of a condition. The battery varies the axes you name and hands each cell its own perturbed settings:
rate_risk <- function(cond, rep) {
cfg_i <- LLMR::llm_config("groq", "openai/gpt-oss-20b",
temperature = as.numeric(cond$temperature))
a <- agent("Rater", cfg_i)
r <- a$reply("On a scale of 1 to 10, how risky is skydiving? Reply with only the integer.")
as.numeric(gsub("[^0-9].*$", "", trimws(r)))
}
battery <- agent_robustness(
rate_risk,
vary = list(temperature = c("0", "1")),
reps = 3,
measure = function(r) r,
.config = cfg
)
battery$by_axis # instability, dispersion, agreement along each axis
battery$overall # a single 'fragile' verdictFor a numeric measure the per-axis agreement is an interval
Krippendorff alpha; for a categorical one it is nominal. The
fragile flag in overall is a coarse warning,
not a verdict: read by_axis to see which axis is doing the
damage. If your task is single-item coding rather than a multi-agent
procedure, you can omit run_fn entirely and let the battery
drive ‘LLMR’’s replication machinery directly.
One degree of freedom the battery closes is selective paraphrasing.
It is easy, and tempting, to try a handful of rewordings and keep the
one that “works”. vary_prompt() with
paraphrase = generates the rewordings for you, from the
actual prompt, and records them in the manifest, so the rewordings
become an archived axis of the study rather than a hidden degree of
freedom.
Calibration: from a guess to a measurement
When you use a model to code text (is this response anxious, is this comment toxic, does this abstract concern climate), the raw rate of “yes” answers is a biased estimate of the true rate, because the model makes mistakes. Calibration corrects that bias using a small set of human-labeled examples, and gives you an honest interval instead of a point you should not trust.
The shape of the call matters, so look closely. You have model
predictions on all units. You have human gold labels on
a subset. You tell agent_calibrate() which
units are which:
# model coded all 12 statements; a human labeled the first 6
preds <- c(1, 0, 1, 0, 1, 0, 1, 1, 1, 0, 1, 0) # the model's codings (all units)
gold <- tibble::tibble(id = 1:6, value = c(1, 0, 1, 0, 1, 0)) # human labels (subset)
cal <- agent_calibrate(
predictions = preds,
gold = gold, # (id, value): the labeled subset
id = 1:12, # the id vector for ALL units (length == length(preds))
method = "ppi",
estimand = "proportion"
)
cal$naive$estimate # the biased plug-in: just mean(preds)
cal$estimate # the calibrated estimate, with a confidence intervalThe contract is the easy thing to get wrong, so read it closely.
id is the identifier for every unit, the same length as
predictions; the ids in gold are matched into
it to locate the labeled rows. The estimate columns are
estimate, std_error, conf_low,
conf_high. The method = "naive" row is there
only for contrast: it is the number you would have reported without
calibration, and you should not report it.
A word on the statistics, stated plainly. The estimator here is
prediction-powered inference (PPI): the estimate is the model’s mean
corrected by the average labeling error on the gold set, with a matching
variance. Under a simple random gold sample this also coincides with the
design-based (DSL) point estimate. The interval is honest but simple;
for weighted or non-random gold samples, or for careful regression
inference, use a full implementation through the calibration bridge
(as_llmrcontent_validation()).
attach_calibration(run, cal) ties a calibration to a
run. That does two things: it folds the calibration into the study
manifest, and it permits the one claim type you are otherwise forbidden
from making.
Claim types: say what you mean
The last habit is the simplest and the most easily forgotten. State what kind of claim a run supports, and let the package hold you to it.
run <- as_agent_run(
agent("Coder", cfg)$reply("Is this abstract about climate? Answer yes or no.\n...")
)
# a coding run is a coding run; calling it a population estimate is refused
tryCatch(
mark_claim_type(run, "calibrated_inference"),
llmragent_claim_error = function(e) "refused: no calibration is attached"
)
mark_claim_type(run, "coding") # this is honestFour types are recognized: instrument_pilot (you are
still tuning the prompt), theory_probe (you are exploring
what a model will say, not measuring a population), coding
(the model is annotating data, to be validated against gold), and
calibrated_inference (you have done the validation and may
speak about a rate). The last is refused unless a calibration is
attached, and report() will not let population-estimate
phrasing through without it. The aim is to make population-level
language conditional on calibration. Without it, report()
refuses or scopes any population-estimate phrasing.
The shape of an honest study
Put together, the three habits describe a workflow. Pilot the prompt and call it a pilot. Code the data and call it coding. Validate against a gold set, attach the calibration, and only then speak about a rate, with the interval calibration gives you. Test that the whole thing survives perturbation, and archive the robustness battery alongside the result. None of this makes the model more truthful. It makes your account of what the model did something a careful reader can accept.