suppressPackageStartupMessages({
library(readr)
library(dplyr)
library(tibble)
library(stringr)
library(rsample)
library(recipes)
library(textrecipes)
library(parsnip)
library(workflows)
library(yardstick)
library(vetiver)
library(pins)
library(glmnet)
})
source("R/inaugural-corpus.R")
paragraphs <- inaugural_paragraphs()
study <- read_csv(
"data/inaugural/split-study.csv",
na = c("", "NA"),
col_types = cols(
task = col_character(),
split_scheme = col_character(),
replicate = col_integer(),
accuracy = col_double(),
bal_accuracy = col_double(),
train_majority_accuracy = col_double(),
test_majority_rate = col_double(),
length_rule_accuracy = col_double(),
test_rows = col_integer(),
test_speeches = col_integer()
)
)
set.seed(4301)
era_split <- group_initial_split(paragraphs, group = speech_id, prop = 0.75)
era_training <- training(era_split)
era_testing <- testing(era_split)
era_recipe <- recipe(era ~ paragraph, data = era_training) |>
step_tokenize(paragraph) |>
step_stopwords(paragraph) |>
step_tokenfilter(paragraph, max_tokens = 500) |>
step_tfidf(paragraph)
era_model <- logistic_reg(penalty = 0.01, mixture = 0) |>
set_engine("glmnet")
era_workflow <- workflow() |>
add_recipe(era_recipe) |>
add_model(era_model)
set.seed(4302)
era_fitted <- fit(era_workflow, data = era_training)
era_predictions <- predict(era_fitted, era_testing) |>
bind_cols(era_testing |> select(era))
live_accuracy <- accuracy(
era_predictions,
truth = era,
estimate = .pred_class
) |>
pull(.estimate)
training_majority <- era_training |>
count(era, sort = TRUE) |>
slice(1) |>
pull(era)
live_majority_accuracy <- mean(era_testing$era == training_majority)
speech_summary <- study |>
filter(task == "era", split_scheme == "speech") |>
summarise(
model_accuracy = mean(accuracy),
train_majority_accuracy = mean(train_majority_accuracy),
length_rule_accuracy = mean(length_rule_accuracy),
.groups = "drop"
)
evaluation_date <- as.Date("2026-08-29")
deployment_record <- tibble(
record = c(
"data source",
"selected candidate",
"selected penalty",
"selection resampling",
"split scheme",
"training paragraphs",
"test paragraphs",
"live test accuracy",
"live train-majority baseline",
"speech-split mean accuracy",
"speech-split mean train-majority baseline",
"speech-split mean length baseline",
"evaluation date"
),
value = c(
"inaugural paragraphs",
"ridge logistic regression",
"0.01",
"five folds grouped by speech inside training",
"speech_id group split",
as.character(nrow(era_training)),
as.character(nrow(era_testing)),
as.character(round(live_accuracy, 4)),
as.character(round(live_majority_accuracy, 4)),
as.character(round(speech_summary$model_accuracy, 4)),
as.character(round(speech_summary$train_majority_accuracy, 4)),
as.character(round(speech_summary$length_rule_accuracy, 4)),
as.character(evaluation_date)
)
)Deploying models
Create a callable service file for a fitted workflow
A data team has sufficient evaluation evidence to examine a model before deployment. Another system now needs to submit a paragraph and receive a label.
Deployment packages the fitted object, expected input structure, and calling code. It does not improve the model.
The deployment record also needs the evaluation evidence that justifies the modelβs operation. An API does not establish that its answers are correct.
This lesson shows how to:
- wrap a fitted tidymodels workflow with vetiver.
- pin the model to a local board.
- examine the prototype input saved with the model.
- write the Plumber API file without starting a server.
- name the evaluation details that must accompany a deployed model.
Fit the model to be packaged
A deployment needs the exact fitted object evaluated. Here the object is the era classifier from a speech-held-out split with seed 4301 and fit seed 4302.
Production teams often refit an evaluated model design on all available labelled data. This lesson instead packages the held-out fit. That choice links the stored object to its exact live score and makes its provenance easier to examine. A full-data refit creates a new object. Its evaluation evidence comes from earlier fits with the same design rather than a score for that new object.
| Record item | Value |
|---|---|
| data source | inaugural paragraphs |
| selected candidate | ridge logistic regression |
| selected penalty | 0.01 |
| selection resampling | five folds grouped by speech inside training |
| split scheme | speech_id group split |
| training paragraphs | 1042 |
| test paragraphs | 335 |
| live test accuracy | 0.8209 |
| live train-majority baseline | 0.4179 |
| speech-split mean accuracy | 0.8457 |
| speech-split mean train-majority baseline | 0.6282 |
| speech-split mean length baseline | 0.6827 |
| evaluation date | 2026-08-29 |
Deployment records a specific model version. The record names the objectβs data split, score, and comparison baseline. The repeated speech-split study provides additional context because one split can have an unusual class mix.
Pin the fitted workflow
vetiver_model() combines the fitted workflow with metadata. A prototype is the empty input table that records the modelβs expected columns. A local pins board stores versioned objects. This lesson uses a project-local scratch board and removes it after reading the generated files.
make_api_artifacts <- function(fitted_model) {
board_dir <- file.path("9_model_development", ".lesson-46-board")
api_file <- file.path("9_model_development", ".lesson-46-api.R")
unlink(c(board_dir, api_file), recursive = TRUE, force = TRUE)
dir.create(board_dir, recursive = TRUE, showWarnings = FALSE)
on.exit(unlink(c(board_dir, api_file), recursive = TRUE, force = TRUE), add = TRUE)
vetiver_object <- suppressMessages(
vetiver_model(fitted_model, "inaugural-era-workflow")
)
board <- board_folder(board_dir, versioned = TRUE)
capture.output(
suppressMessages(vetiver_pin_write(board, vetiver_object))
)
versions <- pin_versions(board, "inaugural-era-workflow")
pinned_model <- vetiver_pin_read(
board,
"inaugural-era-workflow",
version = versions$version[[1]]
)
suppressMessages(
vetiver_write_plumber(
board,
"inaugural-era-workflow",
version = versions$version[[1]],
file = api_file
)
)
api_lines <- read_lines(api_file)
board_path <- str_match(api_lines[str_detect(api_lines, "board_folder")], 'path = "([^"]+)"')[, 2]
if (length(board_path) == 0L || is.na(board_path[[1]])) {
board_path <- ""
}
display_lines <- api_lines
if (nzchar(board_path)) {
display_lines <- display_lines |>
str_replace_all(stringr::fixed(board_path), "<local board path>")
}
display_lines <- display_lines |>
str_replace_all(stringr::fixed(versions$version[[1]]), "<pinned version>")
list(
vetiver_object = vetiver_object,
versions = versions,
pinned_model = pinned_model,
api_lines = api_lines,
display_lines = display_lines
)
}
api_artifacts <- make_api_artifacts(era_fitted)
vetiver_object <- api_artifacts$vetiver_object
versions <- api_artifacts$versions
pinned_model <- api_artifacts$pinned_model
prototype_table <- tibble(
columns = paste(names(vetiver_object$prototype), collapse = ", "),
rows = nrow(vetiver_object$prototype)
)| Prototype columns | Prototype rows |
|---|---|
| paragraph | 0 |
The version lets a later system ask for this exact object. The prototype says the API expects a table with a paragraph column. The pinned board is the place the API reads from when it starts.
Write the API file without starting it
vetiver_write_plumber() writes an R file for a Plumber API. This lesson only writes and reads that file. It does not start a server and it makes no network request.
api_lines <- api_artifacts$api_lines
display_lines <- api_artifacts$display_lines
cat("```r\n", paste(display_lines, collapse = "\n"), "\n```\n", sep = "")# Generated by the vetiver package; edit with care
library(pins)
library(plumber)
library(rapidoc)
library(vetiver)
# Packages needed to generate model predictions
if (FALSE) {
library(glmnet)
library(parsnip)
library(recipes)
library(stopwords)
library(textrecipes)
library(workflows)
}
b <- board_folder(path = "<local board path>")
v <- vetiver_pin_read(b, "inaugural-era-workflow", version = "<pinned version>")
#* @plumber
function(pr) {
pr |> vetiver_api(v)
}vetiver generates the file shown above. The display replaces the local board path and version string with placeholders so the rendered lesson remains stable. The assertions examine the generated file before those replacements.
A deployment record needs the selected candidate, settings, data source, split scheme, score, baseline, and date. This example uses ridge logistic regression with penalty 0.01 on inaugural paragraphs. Its split puts whole speeches in the held-out set.
The live accuracy is 0.8209. The same-split training-majority baseline is 0.4179. The evaluation date is 2026-08-29. The fixed seeds are 4301 and 4302.
The party model with this recipe and penalty did not pass evaluation. Do not deploy it through an API. Packaging makes access easier but does not correct a failed measurement.
What to remember
- Deployment packages a fitted object so another system can call it.
- A vetiver prototype records the input columns the model expects.
- A pins board stores model versions.
- Do not deploy a model that did not pass evaluation.
- Keep the candidate, settings, data, split, score, baseline, and date with the deployed object.
An API lets another system request model predictions. Its documentation must also preserve the modelβs limits.