pairwiseLLM turns judgments about which of two
writing samples is better into relative writing-quality scores.
The aspect being judged, such as overall quality or organization, is
called a trait. Comparing samples in this way is also
called comparative judgment (CJ).
This guide assumes you can run R code and work with a data frame. First, you will analyze bundled synthetic comparisons without an API key or provider charges. Then you will prepare samples and see how to collect new comparisons using an LLM. All code through “Prepare a judging prompt” runs locally; cloud requests later in the guide are shown but are not executed when this page builds.
For the relationship between pair selection, estimation, reliability, score recovery, and prediction, start with the integrated CJ workflow. It connects the frequentist, Bayesian, adaptive, bootstrap, and experimental lapse-model options.
Use R 4.4 or later. Install the package and the
optional sirt modeling engine once, then load pairwiseLLM
in each new R session:
You do not need CmdStan, Python, or an LLM account for this first example. Bayesian and adaptive analyses have additional setup described in their guides.
The package includes 190 comparison outcomes covering every unordered pair of 20 synthetic writing samples. These outcomes were generated for demonstration; they are not new LLM judgments or evidence of assessment validity.
data("example_writing_pairs", package = "pairwiseLLM")
head(example_writing_pairs, 4)
#> # A tibble: 4 × 3
#> ID1 ID2 better_id
#> <chr> <chr> <chr>
#> 1 S01 S02 S02
#> 2 S01 S03 S03
#> 3 S01 S04 S04
#> 4 S01 S05 S01ID1 and ID2 identify the two samples.
better_id identifies the winner.
build_bt_data() converts these winners into the format
needed by a Bradley–Terry (BT) model. The model estimates relative
scores from the pattern of wins and losses.
bt_data <- build_bt_data(example_writing_pairs)
fit <- fit_bt_model(bt_data, engine = "sirt", verbose = FALSE)
scores <- fit$theta |> dplyr::arrange(dplyr::desc(theta))
head(scores, 5)
#> # A tibble: 5 × 3
#> ID theta se
#> <chr> <dbl> <dbl>
#> 1 S18 2.88 1.16
#> 2 S13 1.91 0.794
#> 3 S20 1.73 0.985
#> 4 S15 1.12 0.842
#> 5 S14 0.921 0.836If sirt is missing, run the installation command above
before this block. Choosing the engine explicitly makes this example
consistent across machines; fit_bt_model() also supports an
automatic choice and BradleyTerry2.
| Column | Meaning | How to use it |
|---|---|---|
ID |
The sample identifier | Match the result to your original text. |
theta |
Estimated relative writing quality | Higher values indicate stronger writing on the assessed trait. |
se |
Standard error of the estimate | Larger values mean greater model uncertainty. |
The displayed rows are ordered by score. The full scores
table has 20 rows. A difference between two estimates is not
automatically a meaningful difference: consider their uncertainty and
the quality of the judgments.
Zero is a location on a relative scale, not a pass mark. These values are not percentages or rubric grades, and independently fitted cohorts do not share a common scale. For rubric categories, use the rubric calibration guide, which requires completed Bayesian CJ results; the frequentist fit above is not an accepted input.
The fit also reports scale-separation reliability, or SSR. This compares how spread out the estimated scores are with how uncertain those estimates are. You can read it from the fit you already created:
Values closer to 1 mean that estimated score differences are large compared with their uncertainty. A value below zero means that uncertainty is large compared with the spread of scores, so treat the ranking cautiously.
A high SSR does not prove that the ranking is accurate. Changing the fitting method can spread the scores farther apart and raise SSR without improving the ranking. Consider SSR alongside the score uncertainties and the quality of the judgments.
For the sirt fit above, if the package cannot calculate a valid
reliability value, it stops with an error and explains the problem. The
comparisons must also connect all samples, directly or through other
samples, to place them on one shared scale. If you use
BradleyTerry2, reliability is shown as NA,
meaning it is not available from that fitting method in this
package.
For the formula and a worked example, open the help page with
?scale_separation_reliability. The
?fit_bt_model help page explains the fitting settings and
the additional details saved with each fit.
For comparisons chosen randomly or fixed before seeing the results,
you can choose Firth estimation. This method reduces
small-sample bias and can still estimate scores when a sample always
wins or always loses. The comparisons must connect all samples, and the
optional brglm2 package must be installed.
firth_fit <- fit_bt_model(build_bt_data(example_writing_pairs),
engine = "brglm2", verbose = FALSE)
head(summarize_bt_fit(firth_fit), 5)
#> # A tibble: 5 × 6
#> ID theta se rank engine reliability
#> <chr> <dbl> <dbl> <int> <chr> <dbl>
#> 1 S01 -4.68 1.17 19 brglm2 0.908
#> 2 S02 -3.41 0.976 17 brglm2 0.908
#> 3 S03 -4.68 1.17 20 brglm2 0.908
#> 4 S04 -4.02 1.05 18 brglm2 0.908
#> 5 S05 -2.80 0.917 16 brglm2 0.908
head(predict(firth_fit)) # probability that the first sample wins each comparison
#> [1] 0.21830274 0.50000000 0.34047109 0.13188116 0.07605926 0.04301231The same cautions about uncertainty and reliability apply. If every
estimated score is equal, this method keeps the fit but reports
reliability as NA, because there is no score spread to
compare with uncertainty. This option is intended for random or fixed
schedules; it is not the package’s correction for adaptive pairing,
where later comparisons depend on earlier results. See
?fit_bt_model for the method and assumptions.
When later comparisons depend on earlier results, alpha adjustment is an option motivated by the estimation bias that adaptive scheduling can introduce. It gently pulls scores together by adding a small, equal amount of evidence in both directions for every possible pair. This differs from the conventional sirt epsilon adjustment, which depends on observed win proportions. Both methods remain available; Firth is the intended modern comparator for random schedules.
Choose alpha before examining your substantive results.
The package accepts nonnegative values, including 0.30 and 0.50, and
never chooses or tunes it for you. The bundled example below
demonstrates the API with 0.50; it does not establish which setting is
best for a particular study. This option needs no additional engine
package.
alpha_fit <- fit_bt_model(build_bt_data(example_writing_pairs),
engine = "alpha", alpha = 0.50, verbose = FALSE)
head(summarize_bt_fit(alpha_fit), 5) # includes model-based standard errors
#> # A tibble: 5 × 6
#> ID theta se rank engine reliability
#> <chr> <dbl> <dbl> <int> <chr> <dbl>
#> 1 S01 -4.20 1.07 19 alpha 0.900
#> 2 S02 -2.92 0.870 17 alpha 0.900
#> 3 S03 -4.20 1.07 20 alpha 0.900
#> 4 S04 -3.51 0.947 18 alpha 0.900
#> 5 S05 -2.38 0.815 16 alpha 0.900
head(predict(alpha_fit)) # first-sample win probabilities
#> [1] 0.21722290 0.50000000 0.33427851 0.13887040 0.08836331 0.05650570
alpha_fit$ssr # reliability and its variance components
#> $observed_variance
#> [1] 6.666692
#>
#> $mean_squared_se
#> [1] 0.6653468
#>
#> $true_score_variance
#> [1] 6.001345
#>
#> $ssr
#> [1] 0.9001984
#>
#> $n_items
#> [1] 20
#>
#> $n_finite
#> [1] 20
#>
#> $valid
#> [1] TRUE
#>
#> $status
#> [1] "ok"
alpha_fit$provenance$adjustment # exact penalty used
#> $method
#> [1] "hamilton_alpha"
#>
#> $alpha
#> [1] 0.5
#>
#> $pseudo_count
#> [1] 0.02631579
#>
#> $pairs
#> [1] "all_unordered_pairs"
#>
#> $objective
#> [1] "log_likelihood + alpha/(N-1) * sum(log(p*(1-p)))"
#>
#> $reference
#> [1] "doi:10.1111/jedm.70022, equation (3)"These standard errors describe uncertainty conditional on the
comparisons that actually occurred. They do not account for how an
adaptive schedule might change if the assessment were repeated. Alpha
adjustment does not select pairs and does not guarantee unbiased
reliability for every adaptive schedule. Schedule-aware bootstrap bias
correction is available in the bootstrap
guide. For the mathematical definition, uncertainty assumptions and
failure diagnostics, see ?fit_bt_model.
fit_bt_model(..., engine = "lapse") jointly estimates
sample strengths, a preference for the first presented sample, and a
probability of guessing. It uses the same comparison probability as the
Bayesian btl_e_b model, with unpenalized maximum
likelihood. This helps investigate whether differences come from the
probability model or from Bayesian versus frequentist estimation. It is
a prototype, not a replacement for the Bayesian fit.
A connected set of comparisons is not always enough to separate these effects. The prototype stops when numerical or identification checks fail. The error retains available estimates and diagnostic details without standard errors or reliability.
A fitted guessing probability of exactly zero can be a valid result:
the best fit needs no guessing component. When this boundary solution
passes the required checks, the fit retains scores, positional bias, and
predictions. Its convergence status is converged_boundary.
Ordinary standard errors are unavailable because the usual uncertainty
approximation does not apply at this boundary. The fit records
uncertainty status nonregular_boundary, missing score
standard errors, and no covariance matrices. You can still use
predict() and summarize_bt_fit().
Valid interior fits provide local standard errors that include uncertainty in bias and guessing. Numerical checks alone do not prove that these errors are well calibrated across repeated samples. Conventional reliability (SSR) remains unavailable for every lapse fit, and the simple-BT bootstrap rejects these fits.
The original frozen synthetic assessment recovered 324 of 450 cases
and rejected 100 boundary candidates and 26 numerical failures. With the
same cases and recovery tolerances, the follow-up recovered all 450: 352
interior fits and 98 valid zero-boundary fits. There were no numerical
or identification failures, and no valid fits failed parameter recovery.
Read the follow-up with
readLines(system.file("validation", "bt-lapse-305-boundary.txt", package = "pairwiseLLM")).
The original evidence is preserved. These synthetic results do not
establish production validity or uncertainty calibration. See
?fit_bt_model for the model, checks, numerical controls,
and diagnostic fields.
write.csv(scores, "writing_scores.csv", row.names = FALSE)
saveRDS(fit, "writing_bt_fit.rds")
# In a later session: fit <- readRDS("writing_bt_fit.rds")The CSV is convenient for viewing scores in a spreadsheet. The RDS preserves the fitted R object, including the method and settings used to produce it. Keep this file if you need to revisit or explain your analysis later. These paths are relative to your current working directory.
Start with one row per sample, a unique, non-missing ID,
and a text column. Keep IDs stable so that saved results
continue to refer to the same samples. Do not include names or other
identifying information merely to label rows.
my_samples <- data.frame(
ID = c("essay_01", "essay_02", "essay_03"),
text = c(
"Writing helps us explain our ideas to others.",
"We write to communicate, remember, and develop an argument.",
"Writing is useful. I use it at school."
)
)
samples <- read_samples_df(my_samples)For a spreadsheet saved as CSV, map its columns to the package’s names:
my_data <- read.csv("writing.csv", colClasses = "character")
samples <- read_samples_df(my_data, id_col = "student_code", text_col = "response")These three short texts illustrate the data format only. For the collection example below, use the bundled 20 samples so every object is defined:
data("example_writing_samples", package = "pairwiseLLM")
samples <- read_samples_df(example_writing_samples[, c("ID", "text")])
all_pairs <- make_pairs(samples)
nrow(all_pairs)
#> [1] 190
pairs_small <- all_pairs |>
sample_pairs(n_pairs = 10, seed = 123) |>
randomize_pair_order(seed = 99)Pair construction makes no provider calls. Twenty samples produce
choose(20, 2) = 190 unordered pairs; the total grows
quickly as you add samples. Randomizing presentation helps distribute
which sample appears first.
The ten-pair subset is a collection demonstration, not a complete ranking study. Random sampling does not guarantee that every sample appears or that all samples are connected through comparisons. The offline fit above uses the full bundled comparison set. Plan coverage and diagnostics before fitting your own collected data; adaptive pairing is an alternative that selects comparisons as evidence accumulates.
A prompt combines the trait, the judging instructions, and the two texts. Start with a built-in trait and template:
td <- trait_description("overall_quality")
tmpl <- get_prompt_template("default")
td$name
#> [1] "Overall Quality"You can inspect exactly what the model will receive before paying for a call:
prompt <- build_prompt(
template = tmpl,
trait_name = td$name,
trait_desc = td$description,
text1 = pairs_small$text1[[1]],
text2 = pairs_small$text2[[1]]
)
cat(substr(prompt, 1, 350))
#> You are a debate adjudicator. Your task is to weigh the comparative strengths of two writing samples regarding a specific trait.
#>
#> TRAIT: Overall Quality
#> DEFINITION: Overall quality of the writing, considering how well ideas are expressed,
#> how clearly the writing is organized, and how effective the language and
#> conventions are.
#>
#> SAMPLES:
#>
#> === SAMPLEThis shows only the beginning; use cat(prompt) to read
the full prompt locally. See Data
Schemas and Prompt Management for custom traits, file templates,
named registration, and required placeholders.
The remaining submission examples make cloud requests that may incur charges when you run them. They send the prompt and sample text to your chosen provider. Review its privacy and retention terms before submitting sensitive data.
OpenAI Decisions and TypeSafe Jev also return two choice probabilities. For a short example see the README; use the recovery guide for resumable live collection. These options do not use provider-managed Batch.
A backend selects the service; model
selects a model offered by that service. This example uses OpenAI. Use
an identifier available to your account; example identifiers are not a
guarantee of current availability. See Backends and Tested Model
Configurations for the provider/key table and official catalogs.
For the current R session:
has_key = TRUE means R can see a nonempty value; it does
not test whether the key is valid or the account can access a model.
Missing keys for other providers are expected. Never put a real key in a
shared script or report.
For persistent local setup, put
OPENAI_API_KEY="your-key-here" in your
~/.Renviron file and restart R. If you use
usethis, usethis::edit_r_environ() opens that
file. This is a file you choose to store; the package does not save
credentials for you. Ollama uses a local server and does not require a
provider API key.
res_list <- submit_llm_pairs(
pairs = pairs_small,
backend = "openai",
model = "gpt-4o",
trait_name = td$name,
trait_description = td$description,
prompt_template = tmpl,
parallel = FALSE,
save_path = "live_results.csv"
)The run processes pairs sequentially and saves completed results
incrementally. Use a distinct file for each study/model/trait
configuration. To continue the same interrupted run, keep its inputs and
saved file and repeat the call; matching saved pairs are skipped.
Parallel execution is optional and requires future and
future.apply; leave it off for a first run.
| Component | What it tells you |
|---|---|
results |
Comparisons with a valid winner. better_id identifies
the winning sample. |
failed_pairs |
Scheduled pairs with no valid outcome. |
failed_attempts |
Attempt-level problems, including failures followed by successful retries. |
An HTTP success alone is not a valid judgment. Inspect unresolved pairs before analysis; missing winners are not ties. The recovery guide explains retrying failures and keeping successful work.
Once you have collected an adequate comparison design and inspected
failures, prepare its successful outcomes with
build_bt_data(res_list$results) and use the same
fit-and-inspect steps as the offline example. Do not interpret the small
submission demonstration as an adequate design for all 20 samples.
| Your goal | Guide or function |
|---|---|
| More control over data and judging instructions | Data and prompts |
| Estimate token costs with a paid pilot; configure or troubleshoot providers | Provider controls and recovery |
| Submit fixed pairs for later retrieval, including resumable large jobs | Batch workflows |
| Fit Elo ratings instead of BT scores | build_elo_data() and fit_elo_model()
(optional EloChoice) |
| Estimate Bayesian scores and inspect convergence | Bayesian BTL (requires CmdStan) |
| Let the next pair depend on earlier judgments | Adaptive pairing (requires CmdStan) |
| Convert completed Bayesian scores to ordered rubric levels | Rubric calibration |
| Compare separately evaluated cohorts on a shared scale | Adaptive linking |
| Check whether presentation order affects judgments | Prompt bias testing |
Live processing is useful for interactive work. Batch processing can suit larger fixed-pair jobs when you can wait for completion; support and pricing depend on the provider. There is no universal pair-count threshold for choosing between them.
| Problem | First check |
|---|---|
The offline model says sirt is missing |
Run install.packages("sirt") once, then rerun the
fit. |
| R cannot see my key | Restart R after editing .Renviron, then use
check_llm_api_keys(). |
| The provider rejects the model or a setting | Check account access and the provider’s current catalog; start with minimal controls. |
| Some comparisons are missing | Inspect failed_pairs and failed_attempts;
keep successful results. |
| Scores look unlike familiar grades | BT scores are relative estimates; rubric scoring is a separate Bayesian workflow. |
| A batch is still pending | Poll or resume the existing job; do not submit it again merely because it is unfinished. |
For a reproducible issue report, include your code with credentials
removed, the error, provider/model, operating system, and
sessionInfo() output.
Mercer, S. H. (2026). Getting started with pairwiseLLM [R package vignette]. Comprehensive R Archive Network. https://doi.org/10.32614/CRAN.package.pairwiseLLM