library(tidyfinance)
library(tidymodels)
library(butcher)
library(httr2)
library(readr)
library(base64enc)Sharing Trained Models via Hugging Face Hub
A workflow for fitting, shrinking, uploading, and retrieving model objects
Motivation
When collaborating on empirical research or peer-reviewing assignments, it is often useful to share fitted model objects. This allows a reviewer to load your model and verify predictions without re-running potentially expensive training code.
This post walks through a minimal end-to-end workflow:
- Fit a model
- Reduce its size
- Upload it to Hugging Face Hub
- Download and generate predictions from a fresh session
Setup
We load six packages. tidyfinance provides access to financial data. tidymodels handles the full modelling workflow. butcher trims fitted model objects. httr2 sends HTTP requests to the Hugging Face API. readr reads and writes .rds files. base64enc encodes binary files as base64 text for the upload API.
We import six libraries. tidyfinance provides the same financial data as the R version. pandas and numpy handle data manipulation. sklearn covers preprocessing, modelling, and cross-validation. joblib serialises model objects to disk. huggingface_hub uploads and downloads files from Hugging Face.
import tidyfinance as tf
import pandas as pd
import numpy as np
from sklearn.linear_model import Ridge
from sklearn.preprocessing import StandardScaler
from sklearn.pipeline import Pipeline
from sklearn.model_selection import TimeSeriesSplit, GridSearchCV
from sklearn.metrics import root_mean_squared_error, r2_score, mean_absolute_error
import joblib
from huggingface_hub import HfApi, hf_hub_downloadA simple financial application
Suppose we want to predict monthly S&P 500 excess returns using macroeconomic predictors. The target variable is rp_div, the excess return on the S&P 500. The dataset also contains a set of macro predictors, all already lagged.
download_data() fetches monthly macro predictors directly into a tibble.
data <- download_data("macro_predictors", "monthly")tf.download_data() returns the same dataset as a pandas DataFrame.
data = tf.download_data(
domain="macro_predictors",
dataset="monthly",
)Fit a model
Split the data
Because the data are a time series, we use initial_time_split() rather than a random split. This puts the first 80% of observations (sorted by date) into the training set and the remaining 20% into the test set. A random split would let future observations leak into training, which would give an overly optimistic picture of model performance.
split <- initial_time_split(data, prop = 0.8)
train_data <- training(split)
test_data <- testing(split)We sort by date first to guarantee chronological order, then slice at the 80% mark. We also separate the feature matrix X from the target vector y for both splits, dropping date from the features since it carries no predictive signal.
data = data.sort_values("date").reset_index(drop=True)
split_idx = int(len(data) * 0.8)
train_data = data.iloc[:split_idx]
test_data = data.iloc[split_idx:]
feature_cols = [c for c in data.columns if c not in ["date", "rp_div"]]
X_train = train_data[feature_cols]
y_train = train_data["rp_div"]
X_test = test_data[feature_cols]
y_test = test_data["rp_div"]Preprocess
A recipe defines the preprocessing steps applied to the data before modelling. We tell it to predict rp_div from all other columns, then remove date, and then standardise each predictor to zero mean and unit variance. Standardisation matters here because ridge regression shrinks coefficients towards zero and the amount of shrinkage depends on the scale of each variable.
rec <- recipe(rp_div ~ ., data = train_data) |>
step_rm(date) |>
step_normalize(all_predictors())In sklearn, preprocessing and modelling combine into a Pipeline. We chain a StandardScaler (which standardises to zero mean and unit variance) with a Ridge model. The pipeline applies the scaler to training data and carries those parameters forward when transforming new data.
pipe = Pipeline([
("scaler", StandardScaler()),
("ridge", Ridge())
])Tune and fit
We fix mixture = 0, which gives a pure ridge regression, and tune only the penalty parameter via cross-validation. Because the data are a time series, we use rolling_origin() to create folds that always train on past observations and validate on future ones, preserving temporal order throughout the tuning process. Each fold uses an initial window of 120 months for training and assesses on the following 60 months, with cumulative = FALSE so the training window does not grow as more data become available. Setting skip = 59 advances the window by 60 months between folds rather than one month at a time, which reduces the total number of folds from several hundred to around twelve and keeps computation tractable.
tune_grid() fits the model for each of 30 candidate penalty values on every fold and records RMSE, traditional R-squared, and MAE. select_best() picks the penalty with the lowest average RMSE across folds. finalize_workflow() plugs that value back into the workflow, and fit() trains the final model on the full training set.
enet_spec <- linear_reg(penalty = tune(), mixture = 0) |>
set_engine("glmnet")
enet_wf <- workflow() |>
add_recipe(rec) |>
add_model(enet_spec)
rolling_folds <- rolling_origin(
train_data,
initial = 120,
assess = 60,
cumulative = FALSE,
skip = 59
)
tuned <- tune_grid(
enet_wf,
resamples = rolling_folds,
grid = 30,
metrics = metric_set(rmse, rsq_trad, mae)
)
enet_wf_final <- enet_wf |>
finalize_workflow(select_best(tuned, metric = "rmse")) |>
fit(train_data)TimeSeriesSplit creates 12 folds with an expanding training window, so each fold always trains on earlier data and validates on later data. This is the sklearn equivalent of rolling_origin() with cumulative = TRUE. We search over 30 candidate values of alpha (the ridge penalty) on a log scale. GridSearchCV fits the full pipeline (scaler plus ridge model) for each combination and selects the value with the best cross-validated RMSE. The best estimator is already fitted on the last fold’s training data; we refit it on the full training set to use all available information.
tscv = TimeSeriesSplit(n_splits=12)
param_grid = {"ridge__alpha": np.logspace(-4, 0, 30)}
search = GridSearchCV(
pipe,
param_grid,
cv = tscv,
scoring = "neg_root_mean_squared_error",
refit = True
)
search.fit(X_train, y_train)
best_model = search.best_estimator_Reduce model size
A fitted workflow in R carries a lot of extra baggage: the training data, the call environment, and other objects that are only needed during fitting. butcher() strips those away. The result is a much smaller file that still produces identical predictions.
enet_wf_final |> butcher() |> write_rds("model_ridge.rds")sklearn pipelines are already compact. joblib.dump() serialises the fitted pipeline to disk. joblib is preferred over pickle for sklearn objects because it handles large numpy arrays more efficiently.
joblib.dump(best_model, "model_ridge.pkl")Upload to Hugging Face Hub
You need a Hugging Face access token with write permissions. Store it as an environment variable so that it is not exposed in your code.
You can add HF_TOKEN=hf_your_token_here to your .Renviron file (run usethis::edit_r_environ() to open it) so the token is available across sessions.
Sys.setenv(HF_TOKEN = "hf_your_token_here")Set the token as an environment variable before running the upload code. The huggingface_hub library reads it automatically via HfApi().
import os
os.environ["HF_TOKEN"] = "hf_your_token_here"Step 1: Create a repository
We read the token from the environment and send a POST request to the Hugging Face API. This creates a new public model repository under your account.
hf_token <- Sys.getenv("HF_TOKEN")
request("https://huggingface.co/api/repos/create") |>
req_auth_bearer_token(hf_token) |>
req_body_json(list(
name = "ma2-model-1",
type = "model",
private = FALSE
)) |>
req_perform()HfApi.create_repo() creates a new public model repository under your account in a single call.
api = HfApi()
api.create_repo(repo_id="your-username/ma2-model-1", repo_type="model", private=False)Step 2: Upload the model file
Hugging Face stores files via a commit API. We read the .rds file as raw bytes, encode it to base64 (a text-safe representation of binary data), and send it as the body of a commit request. Replace your-username with your Hugging Face username.
hf_username <- "your-username"repo_name <- "ma2-model-1"
file_name <- "model_ridge.rds"
model_bytes <- readBin(file_name, "raw", file.size(file_name))
model_base64 <- base64enc::base64encode(model_bytes)
request(
paste0(
"https://huggingface.co/api/models/",
hf_username,
"/",
repo_name,
"/commit/main"
)
) |>
req_auth_bearer_token(hf_token) |>
req_body_json(list(
summary = "Upload ridge model",
files = list(
list(
path = file_name,
content = model_base64,
encoding = "base64"
)
)
)) |>
req_perform()upload_file() handles the commit workflow internally. We point it at the local file and specify where to put it in the repository. Replace your-username with your Hugging Face username.
api.upload_file(
path_or_fileobj = "model_ridge.pkl",
path_in_repo = "model_ridge.pkl",
repo_id = "your-username/ma2-model-1",
repo_type = "model"
)After this step, your model is publicly available at: https://huggingface.co/<your-username>/ma2-model-1
Download and predict from a fresh session
A peer reviewer only needs your Hugging Face username.
download.file() saves the file to disk, read_rds() loads it back into R, and augment() attaches the model predictions to the test data as a new column .pred. The metrics() call then computes RMSE, traditional R-squared, and MAE against the true values.
model_url <- paste0(
"https://huggingface.co/",
hf_username,
"/ma2-model-1/resolve/main/model_ridge.rds"
)
download.file(model_url, destfile = "model_ridge.rds", mode = "wb")
loaded_model <- read_rds("model_ridge.rds")
augment(loaded_model, test_data) |>
metrics(truth = rp_div, estimate = .pred)hf_hub_download() fetches the file and returns its local path. We load it with joblib, generate predictions with predict(), and compute the same three metrics as in R.
model_path = hf_hub_download(repo_id="your-username/ma2-model-1", filename="model_ridge.pkl")
loaded_model = joblib.load(model_path)
y_pred = loaded_model.predict(X_test)
print(f"RMSE: {root_mean_squared_error(y_test, y_pred):.4f}")
print(f"R2: {r2_score(y_test, y_pred):.4f}")
print(f"MAE: {mean_absolute_error(y_test, y_pred):.4f}")Summary
| Step | Function | Package |
|---|---|---|
| Fit model | fit() |
tidymodels |
| Reduce size | butcher() |
butcher |
| Save to disk | write_rds() |
readr |
| Upload to HF Hub | req_perform() |
httr2 |
| Download | download.file() |
base R |
| Load and predict | read_rds() + augment() |
readr / tidymodels |
| Step | Function | Package |
|---|---|---|
| Fit model | fit() |
sklearn |
| Save to disk | joblib.dump() |
joblib |
| Upload to HF Hub | upload_file() |
huggingface_hub |
| Download | hf_hub_download() |
huggingface_hub |
| Load and predict | joblib.load() + predict() |
joblib / sklearn |