vitals is Posit’s package
for evaluating LLM applications: a Task runs a solver over
a dataset, grades each answer with a scorer, and writes a log you can
browse. This page shows how to evaluate a dsprrr module as a vitals
solver, choose a scorer, read the results, and use vitals scorers as
dsprrr metrics. The code uses gpt-6-luna; the outputs below
are recorded responses from gpt-4o-mini, replayed when the
site is built.
Evaluate a module
as_vitals_task() turns a module and a data frame into a
vitals Task. The data needs one column per signature input
plus a target column with the expected answer:
classifier <- signature("input -> label: enum('positive', 'negative')") |>
module()
test_data <- tibble(
input = c(
"I love this product!",
"Terrible experience, waste of money"
),
target = c("positive", "negative")
)
task <- as_vitals_task(
module = classifier,
dataset = test_data,
scorer = detect_match(),
name = "sentiment",
dir = tempdir(),
.llm = chat_openai(model = "gpt-6-luna")
)
task$eval()
#> ℹ Solving
#> ✔ Solving [1.8s]
#>
#> ℹ Scoring
#> ✔ Scoring [92ms]
#>
task$get_samples()
#> # A tibble: 2 × 9
#> input target id result solver_chat score scorer_metadata
#> <list> <chr> <int> <chr> <list> <ord> <list>
#> 1 <tibble [1 × 1]> positive 1 positive <Chat> C <named list [2]>
#> 2 <tibble [1 × 1]> negative 2 negative <Chat> C <named list [2]>
#> # ℹ 2 more variables: scorer_explanation <chr>, scorer <chr>$eval() runs the module on every row, scores the answers
and writes a log to dir; vitals_view(dir)
opens the log viewer. In $get_samples(),
result holds the module’s output. When the signature has
one string or enum output, that is the plain value; otherwise it is all
output fields as JSON. score is C (correct) or
I (incorrect). Leave out scorer and you get
model_graded_qa().
Evaluate on held-out data
To check a compiled module, compile on training rows and evaluate on
rows it has not seen. LabeledFewShot(k = 2L) adds two
randomly sampled training rows to the prompt as examples:
qa_data <- tibble(
question = c(
"What is the capital of France?",
"Who wrote Romeo and Juliet?",
"What is 2 + 2?",
"What color is the sky?",
"Who painted the Mona Lisa?",
"What is the largest planet?"
),
target = c(
"Paris",
"Shakespeare",
"4",
"Blue",
"Leonardo da Vinci",
"Jupiter"
)
)
set.seed(42)
train_idx <- sample(nrow(qa_data), 4)
trainset <- qa_data[train_idx, ]
testset <- qa_data[-train_idx, ]
qa_module <- signature("question -> answer") |>
module()
optimized <- compile(
qa_module,
LabeledFewShot(k = 2L),
trainset = trainset
)
test_task <- as_vitals_task(
module = optimized,
dataset = testset,
scorer = detect_match(),
name = "qa-test",
dir = tempdir(),
.llm = chat_openai(model = "gpt-6-luna")
)
test_task$eval()
#> ℹ Solving
#> ✔ Solving [802ms]
#>
#> ℹ Scoring
#> ✔ Scoring [63ms]
#>
test_task$get_samples()[c("target", "result", "score")]
#> # A tibble: 2 × 3
#> target result score
#> <chr> <chr> <ord>
#> 1 Shakespeare William Shakespeare C
#> 2 4 4 CThe model answered “William Shakespeare” and still passed.
detect_match() looks for the target at the end of the
answer by default (location = "end"), ignoring case, so
“Shakespeare wrote it” would have failed. Use
location = "exact", "begin" or
"any" when that matters.
Choose a scorer
| Scorer | Marks an answer correct when | Good for |
|---|---|---|
detect_match() |
the target appears at the end of the answer (see
location) |
short factual answers and labels |
detect_includes() |
the target appears anywhere in the answer | a word or phrase that must be present |
detect_pattern() |
text captured by a regular expression matches the target | pulling a number or code out of longer text |
model_graded_qa() |
a grader model judges that the answer meets the target | open-ended answers |
The examples above use detect_match(). For explanations,
model_graded_qa() asks a model whether the answer meets the
target, treated as a criterion. Without a scorer_chat it
grades with a fresh copy of the solver’s Chat, so the model grades its
own answers. chain_of_thought() adds a
reasoning output, so result is JSON with both
fields and the grader sees the reasoning too:
explainer <- signature("topic -> explanation") |>
chain_of_thought()
task <- as_vitals_task(
module = explainer,
dataset = tibble(
topic = c("Why is the sky blue?", "How do plants make food?"),
target = c(
"Light scattering in atmosphere",
"Photosynthesis using sunlight"
)
),
scorer = model_graded_qa(),
name = "explanations",
dir = tempdir(),
.llm = chat_openai(model = "gpt-6-luna")
)
task$eval()
#> ℹ Solving
#> ✔ Solving [771ms]
#>
#> ℹ Scoring
#> ✔ Scoring [294ms]
#>
task$get_samples()
#> # A tibble: 2 × 10
#> input target id result solver_chat solver_metadata score scorer_chat
#> <list> <chr> <int> <chr> <list> <list> <ord> <list>
#> 1 <tibble> Light sca… 1 "{\"r… <Chat> <named list> C <Chat>
#> 2 <tibble> Photosynt… 2 "{\"r… <Chat> <named list> C <Chat>
#> # ℹ 2 more variables: scorer_metadata <list>, scorer <chr>detect_includes() checks that the target text appears
anywhere. It is literal: the prompt below never says which language to
use, the model wrote Python (def add_numbers), and the
first row fails because “function” is missing:
coder <- signature("task -> code") |> module()
task <- as_vitals_task(
module = coder,
dataset = tibble(
task = c(
"Write a function to add two numbers",
"Create a loop that prints 1 to 5"
),
target = c("function", "for")
),
scorer = detect_includes(),
name = "code-gen",
dir = tempdir(),
.llm = chat_openai(model = "gpt-6-luna")
)
task$eval()
#> ℹ Solving
#> ✔ Solving [784ms]
#>
#> ℹ Scoring
#> ✔ Scoring [67ms]
#>
task$get_samples()
#> # A tibble: 2 × 9
#> input target id result solver_chat score scorer_metadata
#> <list> <chr> <int> <chr> <list> <ord> <list>
#> 1 <tibble [1 × 1]> function 1 "def add_nu… <Chat> I <named list>
#> 2 <tibble [1 × 1]> for 2 "for i in r… <Chat> C <named list>
#> # ℹ 2 more variables: scorer_explanation <chr>, scorer <chr>Read the results
$get_samples() returns one row per sample.
input is a list column holding each row’s signature inputs,
so pull fields out of it to build a readable table:
sentiment <- signature(
"text -> sentiment: enum('positive', 'negative', 'neutral')"
) |>
module()
dataset <- tibble(
text = c(
"Best purchase ever!",
"Complete garbage",
"It's okay I guess",
"Amazing quality!",
"Never buying again"
),
target = c("positive", "negative", "neutral", "positive", "negative")
)
task <- as_vitals_task(
module = sentiment,
dataset = dataset,
scorer = detect_match(),
name = "sentiment-analysis",
dir = tempdir(),
.llm = chat_openai(model = "gpt-6-luna")
)
task$eval()
#> ℹ Solving
#> ✔ Solving [1.9s]
#>
#> ℹ Scoring
#> ✔ Scoring [115ms]
#>
samples <- task$get_samples()
tibble(
text = vapply(samples$input, \(x) x$text, character(1)),
target = samples$target,
result = samples$result,
score = samples$score
)
#> # A tibble: 5 × 4
#> text target result score
#> <chr> <chr> <chr> <ord>
#> 1 Best purchase ever! positive positive C
#> 2 Complete garbage negative negative C
#> 3 It's okay I guess neutral neutral C
#> 4 Amazing quality! positive positive C
#> 5 Never buying again negative negative C
mean(samples$score == "C")
#> [1] 1Filter on score == "I" to list the failures.
Compare two modules
Run the same data through each variant and compare the share of
correct answers. Here a plain module and a chain-of-thought module
answer two word problems, graded by model_graded_qa():
sig <- signature("question -> answer")
basic <- module(sig)
cot <- chain_of_thought(sig)
test_data <- tibble(
question = c(
"If a train travels 60 mph for 2 hours, how far does it go?",
"What is 15% of 80?"
),
target = c("120 miles", "12")
)
llm <- chat_openai(model = "gpt-6-luna")
results <- list()
for (name in c("basic", "cot")) {
mod <- if (name == "basic") basic else cot
task <- as_vitals_task(
module = mod,
dataset = test_data,
scorer = model_graded_qa(),
name = paste0("math-", name),
dir = tempdir(),
.llm = llm
)
task$eval()
results[[name]] <- mean(task$get_samples()$score == "C")
}
#> ℹ Solving
#> ✔ Solving [756ms]
#>
#> ℹ Scoring
#> ✔ Scoring [229ms]
#>
#> ℹ Solving
#> ✔ Solving [777ms]
#>
#> ℹ Scoring
#> ✔ Scoring [272ms]
#>
tibble(variant = names(results), accuracy = unlist(results))
#> # A tibble: 2 × 2
#> variant accuracy
#> <chr> <dbl>
#> 1 basic 1
#> 2 cot 1Both variants get both problems right, so two questions can’t separate them. Use enough rows that a difference would show.
Repeat samples with epochs
A model can answer the same prompt differently from run to run.
epochs repeats every row so you can see how stable the
scores are. Pass .cache = FALSE (forwarded to
run_dataset()) so each repeat makes a new request; with
dsprrr’s response cache on, the repeats would replay the first
answer:
classifier <- signature(
"text -> category: enum('tech', 'sports', 'politics')"
) |>
module()
test_data <- tibble(
text = c(
"New iPhone released today",
"Lakers win championship",
"Senate passes new bill"
),
target = c("tech", "sports", "politics")
)
task <- as_vitals_task(
module = classifier,
dataset = test_data,
scorer = detect_match(),
name = "news-classification",
epochs = 3L,
dir = tempdir(),
.llm = chat_openai(model = "gpt-6-luna"),
.cache = FALSE
)
task$eval()
#> ℹ Solving
#> ✔ Solving [3s]
#>
#> ℹ Scoring
#> ✔ Scoring [172ms]
#>
scores <- task$get_samples()
nrow(scores)
#> [1] 9
table(scores$target, scores$score)
#>
#> I C
#> politics 0 3
#> sports 0 3
#> tech 0 3Three rows times three epochs gives nine samples, and each headline got the same label every time.
For larger datasets, add
.concurrency = concurrency_control(backend = "ellmer", max_active = 4L)
to as_vitals_task() to run rows in parallel. Concurrent
rows skip dsprrr’s response cache.
Build the Task yourself
as_vitals_solver() wraps a module as a vitals solver,
for when you want to call Task$new() directly. A module
whose only input is named input reads vitals’
input column as is; for other or multiple inputs, use
as_vitals_task(), which packs them into that column for
you.
triage <- module(
signature("input -> priority: enum('low', 'medium', 'high')")
)
tickets <- tibble(
input = c("Checkout fails for every customer", "Typo on the About page"),
target = c("high", "low")
)
task <- Task$new(
dataset = tickets,
solver = as_vitals_solver(triage, .llm = chat_openai(model = "gpt-6-luna")),
scorer = detect_match(location = "exact"),
dir = tempdir()
)
task$eval()Use vitals scorers as dsprrr metrics
metric_detect_match(),
metric_detect_includes() and
metric_detect_pattern() wrap the matching vitals scorers as
dsprrr metrics, so evaluate(), optimize_grid()
and compile() can use them. They take the expected answer
from the data’s target column, return 1 or 0, and work with
modules that have a single output field:
ends_with_target <- metric_detect_match()
exact_target <- metric_detect_match(location = "exact")
row <- tibble(question = "Who wrote Hamlet?", target = "Shakespeare")
ends_with_target(list(answer = "William Shakespeare"), row)
#> [1] 1
exact_target(list(answer = "William Shakespeare"), row)
#> [1] 0In an evaluation, pass the metric like any other:
evaluate(
qa_module,
testset,
metric = metric_detect_match(),
.llm = chat_openai(model = "gpt-6-luna")
)as_dsprrr_metric() adapts other vitals-style scorers the
same way. metric_model_graded_qa() and
metric_model_graded_fact() exist too, but they currently
show the grader the prediction as R list syntax and no question unless
the data has an input column; for model grading, use
model_graded_qa() in a vitals Task as shown above. For how
metrics drive optimization, see Metrics and evaluation.