Skip to contents

Choose where computation happens

hf_embed() and hf_classify() still use hosted inference. Nothing in this workflow changes their defaults or initializes Python when you load the package.

The explicit local functions run embeddings and text classification on your computer. After the initial model download, they can work offline without sending your input text to an inference provider. There are no hosted inference charges, but you supply the disk space, memory, and compute. Public models in this example do not require an API token. Private or gated models may require a read token and acceptance of the model’s terms.

This first local workflow supports standard Transformers classifiers and Sentence Transformers embedding models with safetensors weights. It does not run arbitrary repository Python code, support every Hub model, or manage a local chat server. Embedding snapshots must include modules.json with a root Transformer followed by built-in Pooling or Normalize modules. A transformer-only cache is rejected rather than silently changing the model’s pooling behavior. Adapter and custom-module repositories are unsupported.

Set up the optional Python stack

Install reticulate once if it is not already installed:

install.packages("reticulate")

Setup declares the Python requirements; the first local operation may download a compatible Python interpreter and packages, including PyTorch, Transformers, and Sentence Transformers. This can take several minutes and substantial disk space. The default device is the CPU.

If you already select a Python environment with reticulate, that choice is respected. It must contain compatible dependencies; installing packages into a different environment will not fix the selected one. See reticulate’s environment documentation. API-only users do not need this setup.

Use matching R and Python architectures. In particular, x64 R emulation on Windows ARM can crash when loading Python-related R packages; successful-looking console output does not establish that the process finished successfully. The automated real-model runs for this workflow use native Linux CPU runners.

Download reproducible model snapshots

Pin a full commit revision rather than relying on a moving main branch. The download helper uses Hugging Face’s version-aware cache and preserves the repository’s subdirectories. It retrieves safetensors weights and standard configuration/tokenizer files, not every alternative model format.

cache_dir <- Sys.getenv(
  "HF_LOCAL_CACHE_DIR",
  unset = tools::R_user_dir("huggingfaceR", "cache")
)

embedding_id <- "sentence-transformers/all-MiniLM-L6-v2"
embedding_revision <- "1110a243fdf4706b3f48f1d95db1a4f5529b4d41"
classifier_id <- "distilbert/distilbert-base-uncased-finetuned-sst-2-english"
classifier_revision <- "714eb0fa89d2f80546fda750413ed43d93601a13"

embedding_path <- hf_download_model(
  embedding_id, revision = embedding_revision, cache_dir = cache_dir
)
classifier_path <- hf_download_model(
  classifier_id, revision = classifier_revision, cache_dir = cache_dir
)

data.frame(
  task = c("embed", "classify"),
  snapshot = c(basename(embedding_path), basename(classifier_path))
)
#>       task                                 snapshot
#> 1    embed 1110a243fdf4706b3f48f1d95db1a4f5529b4d41
#> 2 classify 714eb0fa89d2f80546fda750413ed43d93601a13

Downloading again reuses the cache. A snapshot is a directory containing the model’s files, not a single weight file. For example, the embedding model also needs its pooling configuration:

list.files(embedding_path, pattern = "config.json$", recursive = TRUE)
#> [1] "1_Pooling/config.json"     "config.json"              
#> [3] "data_config.json"          "sentence_bert_config.json"
#> [5] "tokenizer_config.json"

Load once, embed many texts

Loading from a downloaded directory uses local files only. Tokenizer and model components come from the same snapshot.

embedding_model <- hf_load_local_model(
  embedding_path, task = "embed", local_files_only = TRUE
)
embedding_model
#> <hf_local_model> embed
#>   Model: /home/runner/work/_temp/hf-model-cache/models--sentence-transformers--all-MiniLM-L6-v2/snapshots/1110a243fdf4706b3f48f1d95db1a4f5529b4d41 (local)
#>   Revision: 1110a243fdf4706b3f48f1d95db1a4f5529b4d41
#>   Path: /home/runner/work/_temp/hf-model-cache/models--sentence-transformers--all-MiniLM-L6-v2/snapshots/1110a243fdf4706b3f48f1d95db1a4f5529b4d41
#>   Device: cpu
sentences <- c(
  "The cat sat on the mat.",
  "A feline is resting on a rug.",
  "The database server needs an update."
)
embeddings <- hf_embed_local(
  sentences, embedding_model, batch_size = 2L, normalize = TRUE
)
embeddings
#> # A tibble: 3 × 3
#>   text                                 embedding   n_dims
#>   <chr>                                <list>       <int>
#> 1 The cat sat on the mat.              <dbl [384]>    384
#> 2 A feline is resting on a rug.        <dbl [384]>    384
#> 3 The database server needs an update. <dbl [384]>    384

# A small view of the actual numeric output
round(do.call(rbind, embeddings$embedding)[, 1:8], 4)
#>        [,1]    [,2]    [,3]   [,4]    [,5]    [,6]    [,7]    [,8]
#> [1,] 0.1302 -0.0158 -0.0367 0.0580 -0.0598  0.0331  0.0301  0.0289
#> [2,] 0.0827  0.0186  0.0380 0.1138 -0.0026  0.0736 -0.0191  0.0106
#> [3,] 0.0451 -0.1290 -0.0505 0.0119 -0.0449 -0.0745 -0.0271 -0.1096

The output has the same text, embedding, and n_dims columns as hf_embed(). Each embedding is a numeric vector in a list-column.

similarities <- hf_similarity(embeddings)
similarities
#> # A tibble: 3 × 3
#>   text_1                        text_2                               similarity
#>   <chr>                         <chr>                                     <dbl>
#> 1 The cat sat on the mat.       A feline is resting on a rug.            0.563 
#> 2 The cat sat on the mat.       The database server needs an update.     0.0392
#> 3 A feline is resting on a rug. The database server needs an update.     0.0373

stopifnot(
  all(embeddings$n_dims == 384L),
  similarities$similarity[1] > similarities$similarity[2]
)

These values describe this model’s representation of these sentences. They are not a general accuracy benchmark. Other embedding models may require query or document prefixes; follow the model card when using them for retrieval.

Classify text locally

classifier <- hf_load_local_model(
  classifier_path, task = "classify", local_files_only = TRUE
)
reviews <- c(
  "I loved this movie. The acting was wonderful!",
  "I hated this movie. It was a complete waste of time."
)
classification <- hf_classify_local(reviews, classifier, batch_size = 2L)
classification
#> # A tibble: 2 × 3
#>   text                                                 label    score
#>   <chr>                                                <chr>    <dbl>
#> 1 I loved this movie. The acting was wonderful!        POSITIVE 1.000
#> 2 I hated this movie. It was a complete waste of time. NEGATIVE 1.000

stopifnot(
  identical(classification$label, c("POSITIVE", "NEGATIVE")),
  all(classification$score > 0.9)
)

This English movie-review sentiment model returns one highest-scoring label per text, with the same text, label, and score columns as hf_classify(). Scores are model outputs, not a guarantee of calibrated confidence or suitability for a new domain.

Long inputs are truncated to the classifier’s tokenizer limit by default. Set truncation = FALSE to disable that behavior; an over-length input can then fail rather than being silently shortened. These helpers do not split long documents into meaning-preserving chunks.

Missing values, repeated text, and reuse

hf_classify_local(c(reviews[1], NA_character_, reviews[1]), classifier)
#> # A tibble: 3 × 3
#>   text                                          label     score
#>   <chr>                                         <chr>     <dbl>
#> 1 I loved this movie. The acting was wonderful! POSITIVE  1.000
#> 2 <NA>                                          <NA>     NA    
#> 3 I loved this movie. The acting was wonderful! POSITIVE  1.000
hf_embed_local(c(sentences[1], NA_character_, sentences[1]), embedding_model)
#> # A tibble: 3 × 3
#>   text                    embedding   n_dims
#>   <chr>                   <list>       <int>
#> 1 The cat sat on the mat. <dbl [384]>    384
#> 2 <NA>                    <NULL>          NA
#> 3 The cat sat on the mat. <dbl [384]>    384

Input order and duplicates are preserved. Missing text produces missing output, and empty or entirely missing vectors do not invoke the prediction backend. Classification also treats empty strings as missing text. Embedding models accept empty strings and produce their model-specific representation.

Keep a loaded model handle for repeated calls in the same R session. A handle contains Python objects: do not use saveRDS() to move it between sessions. Save the model ID and revision, or the snapshot directory, and load a new handle in the next session.

Work offline in another R session

Once both the Python dependencies and model snapshots are present, start a new R session with the Hub offline flag set before initializing Python:

Sys.setenv(HF_HUB_OFFLINE = "1", TRANSFORMERS_OFFLINE = "1")
library(huggingfaceR)

cache_dir <- Sys.getenv(
  "HF_LOCAL_CACHE_DIR",
  unset = tools::R_user_dir("huggingfaceR", "cache")
)
path <- hf_download_model(
  "sentence-transformers/all-MiniLM-L6-v2",
  revision = "1110a243fdf4706b3f48f1d95db1a4f5529b4d41",
  cache_dir = cache_dir,
  local_files_only = TRUE
)
model <- hf_load_local_model(path, task = "embed", local_files_only = TRUE)
hf_embed_local("This text stays on this computer.", model)

An incomplete or missing cache fails explicitly; local prediction never falls back to paid hosted inference. Cached files must remain available at the same path. Offline model loading does not install missing Python dependencies.

Batch size and memory

batch_size controls the model’s inference batches, not the size of the complete result returned to R. Embedding output is first transferred as a matrix and then placed in a tibble list-column. At 100,000 texts and 384 dimensions, each double-precision representation needs about 293 MiB, before model memory and other allocations.

For larger corpora, reuse the loaded handle but process smaller R-side chunks. Write each result to disk or pass it to a downstream consumer instead of retaining every embedding in memory. This small example only records row counts:

indices <- split(seq_along(sentences), ceiling(seq_along(sentences) / 2L))
vapply(indices, function(index) {
  batch <- hf_embed_local(sentences[index], embedding_model)
  # Process or persist this batch here before moving to the next chunk.
  nrow(batch)
}, integer(1))
#> 1 2 
#> 2 1

Profiling the local workflow

The developer profiler compares an installed baseline with the refactored package on the same runner, using the same cached weights, Python environment, and two PyTorch CPU threads. It warms the models before measurement, records seven inference repetitions for batches of 1, 32, and 256 texts, and checks that the public API, prediction code, and output values agree.

The Python-native timing excludes transfer to R and tidy result construction. R allocation measurements do not include Python/PyTorch memory and are not peak process memory. Shared-runner timings are descriptive, not a promise of performance on another machine or evidence of a statistically established speedup. The profiling artifacts also contain R-only result-shaping measurements and Rprof() call summaries.

profile_dir <- Sys.getenv("HF_LOCAL_PROFILE_DIR")
if (nzchar(profile_dir)) {
  timing <- read.csv(file.path(profile_dir, "comparison.csv"))
  timing <- timing[timing$method %in% c("wrapper", "cached_hub_load"), ]
  data.frame(
    task = timing$task,
    operation = timing$method,
    texts = timing$n_texts,
    baseline_ms = round(1000 * timing$median_s_baseline, 2),
    refactored_ms = round(1000 * timing$median_s_refactored, 2)
  )
}
#>       task       operation texts baseline_ms refactored_ms
#> 1 classify cached_hub_load     0       80.62         70.94
#> 2 classify         wrapper     1       22.68         22.72
#> 3 classify         wrapper   256     2140.16       2214.27
#> 4 classify         wrapper    32      273.54        275.09
#> 5    embed cached_hub_load     0       75.88         68.04
#> 6    embed         wrapper     1       11.46         11.22
#> 7    embed         wrapper   256      517.78        517.74
#> 8    embed         wrapper    32       81.46         82.70

scripts/profile-local-models.R writes the measurements, environment metadata, and profiles. scripts/compare-local-profiles.R checks the two runs and produces the comparison table. The CI workflow pins the baseline commit explicitly; change that reference deliberately when adopting a new performance baseline.

Reproduce this document

Rendering is opt-in so ordinary package checks do not install Python or download models. From an installation containing these functions, set HF_RUN_LOCAL_EXAMPLES=true and render this source:

Sys.setenv(HF_RUN_LOCAL_EXAMPLES = "true")
rmarkdown::render("vignettes/local-models.Rmd")

The repository’s scripts/test-local-models.R independently exercises downloads, real predictions, output shapes, missing values, and cached offline reuse in a fresh R process. It also rejects an incomplete embedding snapshot. Its online and offline modes compare numeric outputs.

metadata <- reticulate::import("importlib.metadata")
python_packages <- c(
  "torch", "transformers", "sentence-transformers", "huggingface-hub", "numpy"
)
data.frame(
  component = c("R", "huggingfaceR", "reticulate", python_packages),
  version = c(
    as.character(getRversion()),
    as.character(packageVersion("huggingfaceR")),
    as.character(packageVersion("reticulate")),
    vapply(python_packages, metadata$version, character(1))
  )
)
#>               component   version
#> 1                     R     4.6.1
#> 2          huggingfaceR     2.3.0
#> 3            reticulate    1.47.0
#> 4                 torch 2.6.0+cpu
#> 5          transformers    4.57.6
#> 6 sentence-transformers     5.7.0
#> 7       huggingface-hub    0.36.2
#> 8                 numpy     2.5.2
cat(sprintf(
  "Executed: %d embeddings with %d dimensions; %d classifications.\n",
  nrow(embeddings), embeddings$n_dims[1], nrow(classification)
))
#> Executed: 3 embeddings with 384 dimensions; 2 classifications.