pairwiseLLM is a R package that provides a unified,
extensible framework for generating, submitting, and modeling pairwise
comparisons of writing quality using large language models (LLMs).
It includes:
pairwiseLLM generally forwards the model
identifier to the selected provider; it does not maintain an exhaustive
model allowlist. Four separate questions matter: whether a backend is
implemented, whether a model accepts the request shape used by an
endpoint, whether maintainers tested that exact configuration, and
whether the provider currently offers the model.
The dated, machine-readable compatibility record is described in Backends and Tested Model Configurations. Absence from that record does not imply incompatibility. Preview identifiers and reasoning controls can change independently of the package.
The backend matrix is provider-specific:
| Backend | Provider Surface | Live | Batch | API Key Surface |
|---|---|---|---|---|
| openai | OpenAI | ✅ | ✅ | OPENAI_API_KEY |
| anthropic | Anthropic | ✅ | ✅ | ANTHROPIC_API_KEY |
| gemini | Gemini Developer API | ✅ | ✅ | GEMINI_API_KEY |
| vertex | Vertex AI Gemini API | ✅ | ❌ | VERTEX_API_KEY |
| together | Together.ai | ✅ | ❌ | TOGETHER_API_KEY |
| ollama | Ollama (local) | ✅ | ❌ | none |
backend = "gemini" means the Gemini Developer API only.
backend = "vertex" means the Vertex AI Gemini API only.
Vertex is live-only in this series, so generic batch wrappers reject
backend = "vertex" explicitly instead of falling back to
Gemini batch mode.
Use official provider catalogs to check current availability: OpenAI, Anthropic, Gemini Developer API, Vertex AI, and Together AI. Ollama tags are local, environment-dependent identifiers.
Unless you supply temperature or top_p,
pairwiseLLM omits those sampling fields so the selected model/provider
defaults apply. Explicit values are still forwarded where the endpoint
supports them. Provider-required constraints, such as Anthropic extended
thinking’s temperature = 1, remain enforced.
pairwiseLLM is available on CRAN, install with:
install.packages("pairwiseLLM")To install the development version from GitHub:
# install.packages("pak")
pak::pak("shmercer/pairwiseLLM")Load the package:
library(pairwiseLLM)Bayesian BTL and adaptive workflows also require CmdStan. Install
cmdstanr and its C++ toolchain, then install CmdStan
once:
# install.packages(
# "cmdstanr",
# repos = c("https://stan-dev.r-universe.dev", getOption("repos"))
# )
cmdstanr::check_cmdstan_toolchain(fix = TRUE)
cmdstanr::install_cmdstan()
cmdstanr::cmdstan_version()See the CmdStanR installation guide for platform-specific compiler prerequisites. Ordinary pairing, provider, BT, and Elo workflows do not require CmdStan.
pairwiseLLM reads keys only from environment
variables.
Keys are never printed, never stored,
and never written to disk. Configure only the key for
the cloud backend you plan to use. Local Ollama does not require a
provider API key.
Cloud comparisons transmit the prompt and sample text you supply to the selected third-party provider. Review that provider’s privacy and retention terms before submitting sensitive, confidential, student, or personal data. Enabling raw-response or reasoning retention can also write returned text to an output path you select.
You can verify which providers are available using:
check_llm_api_keys()This returns a tibble showing whether R can see the required keys for:
Gemini Developer API and Vertex use separate API-key surfaces:
GEMINI_API_KEY is not reused for Vertex, and
VERTEX_API_KEY is not reused for Gemini Developer API.
You may set keys temporarily for the current R session:
Sys.setenv(OPENAI_API_KEY = "your-key-here")
Sys.setenv(ANTHROPIC_API_KEY = "your-key-here")
Sys.setenv(GEMINI_API_KEY = "your-key-here")
Sys.setenv(VERTEX_API_KEY = "your-key-here")
Sys.setenv(TOGETHER_API_KEY = "your-key-here")…but it is strongly recommended
to store them in your ~/.Renviron file.
~/.RenvironOpen your .Renviron file:
usethis::edit_r_environ()Add the following lines:
OPENAI_API_KEY="your-openai-key"
ANTHROPIC_API_KEY="your-anthropic-key"
GEMINI_API_KEY="your-gemini-key"
VERTEX_API_KEY="your-vertex-key"
TOGETHER_API_KEY="your-together-key"
Save the file, then restart R.
You can confirm that R now sees the keys:
check_llm_api_keys()At a high level, pairwiseLLM workflows follow this
structure:
{TRAIT_NAME}, {TRAIT_DESCRIPTION},
{SAMPLE_1}, {SAMPLE_2}.The package provides helpers for each step.
See Data Schemas and Prompt Management for the exact transitions between these schemas.
Start with the introductory workflow, then choose a practical guide or design article for your task.
pairwiseLLM includes an adaptive pairing workflow for
ranking writing samples using pairwise comparisons. Instead of
allocating comparisons uniformly at random, the within-set controller
uses current rank, uncertainty, coverage, and degree information to
choose each next pair.
To get started, see:
pairwiseLLM includes:
register_prompt_template()list_prompt_templates()
#> [1] "default" "test1" "test2" "test3" "test4" "test5"tmpl <- get_prompt_template("default")
cat(substr(tmpl, 1, 400), "...\n")
#> You are a debate adjudicator. Your task is to weigh the comparative strengths of two writing samples regarding a specific trait.
#>
#> TRAIT: {TRAIT_NAME}
#> DEFINITION: {TRAIT_DESCRIPTION}
#>
#> SAMPLES:
#>
#> === SAMPLE_1 ===
#> {SAMPLE_1}
#>
#> === SAMPLE_2 ===
#> {SAMPLE_2}
#>
#> EVALUATION PROCESS (Mental Simulation):
#>
#> 1. **Advocate for SAMPLE_1**: Mentally list the single strongest point of evidence that makes SAMPLE_1 the ...register_prompt_template("my_template", "
Compare two essays for {TRAIT_NAME}…
{TRAIT_NAME} is defined as {TRAIT_DESCRIPTION}.
SAMPLE 1:
{SAMPLE_1}
SAMPLE 2:
{SAMPLE_2}
<BETTER_SAMPLE>SAMPLE_1</BETTER_SAMPLE> or
<BETTER_SAMPLE>SAMPLE_2</BETTER_SAMPLE>
")Use it in a submission:
tmpl <- get_prompt_template("my_template")Traits define what “quality” means.
trait_description("overall_quality")
#> $name
#> [1] "Overall Quality"
#>
#> $description
#> [1] "Overall quality of the writing, considering how well ideas are expressed,\nhow clearly the writing is organized, and how effective the language and\nconventions are."You can also provide custom traits:
trait_description(
custom_name = "Clarity",
custom_description = "How understandable, coherent, and well structured the ideas are."
)Use the unified API for direct API calls. The
submit_llm_pairs() function supports parallel
processing and incremental output saving for
all live backends (OpenAI, Anthropic, Gemini Developer API, Vertex AI
Gemini API, Together.ai, and Ollama).
llm_compare_pair() — compare one pairsubmit_llm_pairs() — compare many pairs at onceKey Features:
parallel = TRUE and
workers = n to speed up processing.save_path (e.g.,
"results.csv"). The function writes results as they finish.
If interrupted, running the command again will automatically skip pairs
already present in the file.$results
(successful comparisons) and $failed_pairs (scheduled pairs
with no observed outcome) plus $failed_attempts
(attempt-level failures, including retry/timeout/parse issues), ensuring
one bad request doesn’t crash the whole job.Example:
data("example_writing_samples")
pairs <- example_writing_samples |>
make_pairs() |>
sample_pairs(10, seed = 123) |>
randomize_pair_order()
td <- trait_description("overall_quality")
tmpl <- get_prompt_template("default")
# Run in parallel with incremental saving
res_list <- submit_llm_pairs(
pairs = pairs,
backend = "openai",
model = "gpt-4o",
trait_name = td$name,
trait_description = td$description,
prompt_template = tmpl,
parallel = TRUE,
workers = 2,
save_path = "live_results.csv"
)
# Inspect successes
head(res_list$results)
# Inspect failures (if any)
if (nrow(res_list$failed_pairs) > 0) {
print(res_list$failed_pairs)
}
# Inspect attempt-level failures (if any)
if (nrow(res_list$failed_attempts) > 0) {
print(res_list$failed_attempts)
}service_tier is provider-specific. OpenAI, Gemini
Developer API, and Vertex validate and encode it separately rather than
sharing one transport rule.
| Backend | Public Values | Notes |
|---|---|---|
gemini |
"standard", "flex",
"priority" |
Gemini Developer API; available on live and batch paths. |
vertex |
"standard", "flex",
"priority" |
Vertex AI Gemini API; live only and encoded via the Vertex request header. |
openai |
provider-specific | "flex" requests lower-cost, slower Flex processing when
the selected model supports it; capacity can be unavailable. It is not
priority routing. |
Example Vertex live request with a Vertex-specific API key surface:
res_vertex <- submit_llm_pairs(
pairs = pairs,
backend = "vertex",
model = "gemini-3.8-flash",
trait_name = td$name,
trait_description = td$description,
prompt_template = tmpl,
service_tier = "flex"
)Batch helpers are available for OpenAI, Anthropic, and Gemini
Developer API. Vertex batch is intentionally unsupported in this series,
and llm_submit_pairs_batch(backend = "vertex", ...) aborts
explicitly.
For large-scale runs use:
llm_submit_pairs_batch()llm_download_batch_results()Example:
batch <- llm_submit_pairs_batch(
backend = "gemini",
model = "gemini-3.8-flash",
pairs = pairs,
trait_name = td$name,
trait_description = td$description,
prompt_template = tmpl,
service_tier = "priority"
)
results <- llm_download_batch_results(batch)Before running a large live or batch job, you can estimate token
usage and cost with estimate_llm_pairs_cost(). The
estimator:
n_test pairs (live calls) to
observe prompt_tokens and
completion_tokensbudget_quantile of
usable pilot output tokens to estimate the remaining calls, then adds
the observed pilot token totals without applying a batch discount to
those live pilot calls.data("example_writing_samples", package = "pairwiseLLM")
pairs <- example_writing_samples |>
make_pairs() |>
sample_pairs(n_pairs = 200, seed = 123) |>
randomize_pair_order(seed = 456)
td <- trait_description("overall_quality")
tmpl <- set_prompt_template()
# Estimate cost using a small pilot run (live calls).
# If your provider offers discounted batch pricing, set batch_discount accordingly.
est <- estimate_llm_pairs_cost(
pairs = pairs,
backend = "openai",
model = "gpt-4.1",
endpoint = "chat.completions",
trait_name = td$name,
trait_description = td$description,
prompt_template = tmpl,
mode = "batch",
batch_discount = 0.5, # e.g., batch costs 50 percent of live
n_test = 10, # number of paid pilot calls
budget_quantile = 0.9, # "budget" uses p90 output tokens
cost_per_million_input = 3.00, # set these to your provider pricing
cost_per_million_output = 12.00
)
est
est$summaryBy default, the estimator returns the original pilot output object and the pairs not selected for the pilot. This lets you run the pilot once, then submit only the remaining pairs. The estimator does not merge pilot judgments into a later submission result automatically:
# Pairs not included in the pilot:
remaining_pairs <- est$remaining_pairs
# Submit remaining pairs using your preferred workflow (live):
res_live <- submit_llm_pairs(remaining_pairs, backend = "openai", model = "gpt-4.1", ...)
# For batch:
batch <- llm_submit_pairs_batch(
backend = "openai",
model = "gpt-4.1",
pairs = remaining_pairs,
trait_name = td$name,
trait_description = td$description,
prompt_template = tmpl)
results <- llm_download_batch_results(batch)For very large jobs or when you need to restart polling after an interruption, pairwiseLLM provides two convenience helpers that wrap the low–level batch APIs:
llm_submit_pairs_multi_batch() — divides a table of
pairwise comparisons into multiple batch jobs, uploads the input JSONL
files, creates the batches, and optionally writes a
registry CSV containing all batch IDs and file paths.
You can split by specifying either n_segments (number of
jobs) or batch_size (maximum number of pairs per job).llm_resume_multi_batches() — polls all unfinished
batches, downloads and parses the results as soon as each job completes,
and optionally writes per‑job result CSVs and a single
combined CSV with the merged results.Use these helpers when your dataset is large or if you anticipate having to pause and resume the job.
data("example_writing_samples", package = "pairwiseLLM")
# construct 100 pairs and a trait description
pairs <- example_writing_samples |>
make_pairs() |>
sample_pairs(n_pairs = 100, seed = 123) |>
randomize_pair_order(seed = 456)
td <- trait_description("overall_quality")
tmpl <- set_prompt_template()
# 1. Submit the pairs as 10 separate batches and write a registry CSV to disk.
multi_job <- llm_submit_pairs_multi_batch(
pairs = pairs,
backend = "openai",
model = "gpt-5.2",
trait_name = td$name,
trait_description = td$description,
prompt_template = tmpl,
n_segments = 10,
output_dir = "directory_name/",
write_registry = TRUE,
include_thoughts = TRUE
)
# 2. Later (or in a new session), resume polling and download results.
res <- llm_resume_multi_batches(
jobs = multi_job$jobs,
interval_seconds = 60,
write_results_csv = TRUE,
write_combined_csv = TRUE,
keep_jsonl = FALSE
)
head(res$combined)The registry CSV contains all batch IDs and file paths, allowing you
to resume polling with llm_resume_multi_batches() even if
the R session is interrupted.
LLMs often show a first-position or second-position bias.
pairwiseLLM includes explicit tools for testing this.
pairs_fwd <- make_pairs(example_writing_samples)
pairs_rev <- sample_reverse_pairs(pairs_fwd, reverse_pct = 1.0)Submit:
# Submit forward pairs
out_fwd <- submit_llm_pairs(pairs_fwd, model = "gpt-4o", backend = "openai", ...)
# Submit reverse pairs
out_rev <- submit_llm_pairs(pairs_rev, model = "gpt-4o", backend = "openai", ...)Compute bias:
cons <- compute_reverse_consistency(out_fwd$results, out_rev$results)
bias <- check_positional_bias(cons)
cons$summary
bias$summary
# Descriptive position-1 selection proportion (not a hypothesis test):
with(bias$summary, total_pos1_wins / total_comparisons)prop_consistent measures agreement on the underlying
winner after reversal. It is distinct from positional preference.
p_sample1_overall is an exact paired test among
inconsistent pairs; a non-significant result is not evidence that
positional preference is absent.
Five included templates have been tested across different backend providers. Complete details are presented in Prompt Template Positional Bias Testing.
# Using the example writing pairs (fully offline; no LLM calls)
data("example_writing_pairs")
# build_bt_data() converts (ID1, ID2, better_id) into the 0/1 format.
bt_ex <- build_bt_data(example_writing_pairs)
# Result has:
# - object1: ID of the first item
# - object2: ID of the second item
# - result : 1 if object1 wins, 0 if object2 wins
head(bt_ex)
bt_fit <- fit_bt_model(bt_ex)
summarize_bt_fit(bt_fit)data("example_writing_pairs")
elo_data <- build_elo_data(example_writing_pairs)
elo_fit <- fit_elo_model(elo_data, runs = 5)
elo_fit$elo
elo_fit$reliability
elo_fit$reliability_weightedpairwiseLLM fits rankings using Bayesian
Bradley–Terry–Luce (BTL) models. These models estimate a latent quality
parameter for each item based on pairwise comparison outcomes, while
providing uncertainty estimates and principled stopping diagnostics.
The package supports four closely related BTL variants, differing in how they model LLM judge behavior.
All models estimate one latent quality parameter per item. They differ only in whether they include:
| Model | Lapse | Position bias | Description |
|---|---|---|---|
btl |
✗ | ✗ | Standard Bradley–Terry–Luce |
btl_e |
✓ | ✗ | BTL with lapse (random responding) |
btl_b |
✗ | ✓ | BTL with position bias |
btl_e_b |
✓ | ✓ | BTL with both lapse and position bias (default) |
Recommended default: btl_e_b This is
the most robust option when the judge is an LLM or other noisy
rater.
Lapse (ε) Useful when judgments
occasionally appear random or inconsistent. The lapse parameter absorbs
these errors without distorting item-level quality estimates.
Position bias (b) Useful when the
judge systematically prefers the first or second item presented. This is
especially important when prompts present items in a fixed
order.
If you are confident that neither effect is present, you can use the
simpler btl model.
You can fit a Bayesian BTL model directly from pairwise comparison data, without using adaptive pairing.
data("example_writing_results")
# Generate a vector of all unique sample IDs
ids <- sort(unique(c(example_writing_results$A_id, example_writing_results$B_id)))
fit <- fit_bayes_btl_mcmc(
results = example_writing_results,
ids = ids,
model_variant = "btl_e_b"
)This fits the model using MCMC via cmdstanr and returns
posterior samples and summaries.
Posterior summaries for items can be extracted using helper functions:
item_summary <- summarize_items(fit)
head(item_summary)Typical outputs include:
You can also inspect convergence and diagnostics:
summarize_refits(fit)This reports:
When using adaptive pairing (adaptive_rank()), the same
Bayesian BTL models are fit intermittently during the run:
Pair selection is guided by the fast TrueSkill model.
Bayesian BTL refits provide:
You can therefore:
For a full tutorial on adaptive pairing, see:
For a detailed description of the current within-set Bayesian and adaptive algorithms, see:
| Workflow | Use Case | Functions |
|---|---|---|
| Live | small or interactive runs | submit_llm_pairs, llm_compare_pair |
| Batch | large jobs, cost control | llm_submit_pairs_batch,
llm_download_batch_results |
Mercer, S., & Reed, D. K. (2026). Validity of large language model comparative judgment for universal writing screening [Preprint]. EdArXiv. https://osf.io/preprints/edarxiv/4k9r8_v2
Contributions to pairwiseLLM are very welcome!
If you encounter a problem:
Run:
devtools::session_info()Include:
Open an issue at:
https://github.com/shmercer/pairwiseLLM/issues
MIT License. See LICENSE.
Mercer, S. H. (2026). pairwiseLLM: Pairwise writing quality comparisons with large language models (Version 1.3.1) [R package; Computer software]. https://github.com/shmercer/pairwiseLLM