Getting Started with pairwiseLLM

What you will do

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.

Install the tools for this example

Use R 4.4 or later. Install the package and the optional sirt modeling engine once, then load pairwiseLLM in each new R session:

install.packages(c("pairwiseLLM", "sirt"))
library(pairwiseLLM)

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.

Your first result: scores from bundled comparisons

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   S01

ID1 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.836

If 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.

Read the scores

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.

Read the reliability summary

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:

fit$reliability
#> [1] 0.6223254

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.

An option for comparisons chosen in advance

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.04301231

The 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.

An option for comparisons chosen adaptively

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.

An experimental comparison with the Bayesian likelihood

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.

Save an analysis

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.

Prepare your own writing samples

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.

Prepare a judging prompt

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:
#> 
#> === SAMPLE

This 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.

Collect a small set of new judgments

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.

Configure one provider

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:

Sys.setenv(OPENAI_API_KEY = "your-key-here")
check_llm_api_keys()

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.

Submit the pairs

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.

Check what succeeded

head(res_list$results)
res_list$failed_pairs
res_list$failed_attempts
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.

Choose the next workflow

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.

Common first-run problems

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.

Citation

Mercer, S. H. (2026). Getting started with pairwiseLLM [R package vignette]. Comprehensive R Archive Network. https://doi.org/10.32614/CRAN.package.pairwiseLLM