Breaking the Python Barrier: Building a Pure R-Native DeepAR Engine with LibTorch

[This article was first published on DataGeeek, and kindly contributed to R-bloggers]. (You can report issue about the content on this page here)
Want to share your content on R-bloggers? click here if you have a blog, or here if you don't.

Deep learning for time series forecasting in R has historically faced a major architectural hurdle: Python overhead. Frameworks like modeltime.gluonts provide interface wrappers around AWS GluonTS, but they rely on a complex execution chain passing through reticulate, virtual environments, Python serialization, and MXNet/PyTorch backends.

To overcome the performance bottlenecks and dependency friction of cross-language bridging, we engineered a pure R-native DeepAR forecasting engine. Powered by the C++ LibTorch backend via R’s torch package, this architecture offers lightweight, in-memory execution without any Python or reticulate dependencies.

Architectural Comparison: Modeltime/GluonTS vs. Native R Torch

The architectural difference between traditional wrappers and our native C++ LibTorch binding lies in data marshalling and execution depth:

Deep Dive into the Code Architecture

Our R implementation mirrors the probabilistic depth of DeepAR while maintaining computational stability and clean visual interactivity.

1. Bounded Student-t Distribution Head

Financial time series, such as the SOXX ETF, exhibit heavy-tailed return distributions (“fat tails”) and sudden volatility shocks. Gaussian models often understate extreme risks or produce over-reactive prediction bands.

We implement a 3-head architecture off the LSTM hidden state:

  • Location parameter (μ): Unconstrained linear output layer.
  • Scale parameter (σ): Softplus activation layer with numerical stability offset.
  • Degrees of freedom parameter (ν): Bounded dynamically between 4.0 and 30.0 using a scaled sigmoid:
ν=4.0+26.0sigmoid(z)\nu = 4.0 + 26.0 \cdot \text{sigmoid}(z)

Bounding ν ≥ 4.0 guarantees mathematically finite variance, preventing Monte Carlo variance explosion over multi-step autoregressive horizons.

2. Variance-Controlled Stochastic Monte Carlo Sampling

During the 10-day forecast horizon, we generate 100 autoregressive simulation paths. To align Monte Carlo variance with predicted σ, we scale Student-t samples by the theoretical variance factor:

scale_factor=ν2ν\text{scale\_factor} = \sqrt{\frac{\nu – 2}{\nu}}

yt=μ+σscale_factortνy_{t} = \mu + \sigma \cdot \text{scale\_factor} \cdot t_{\nu}

This ensures the trajectory bounds remain stable across multi-step autoregressive rollouts.

3. Granular Interactive Plotly Visualization

The frontend layer leverages ggplot2, ggtext, and plotly to deliver clean UI/UX interactivity:

  • Embedded HTML Titles: Eliminates redundant legend boxes by color-coding series names directly inside the Markdown title using ggtext::element_markdown.
  • Invisible Boundary Anchors: Invisible hover points (alpha = 0) are placed along the 95% confidence bounds (conf_hi and conf_lo). Users can inspect exact upper/lower boundary prices dynamically without cluttering the plot with extra lines.

Complete R Script

# ==============================================================================
# TITLE: Pure R-Native Torch DeepAR - Bounded Student-t Distribution Engine
# PATH: tool_nodes/forecasting/engine/evaluate_torch_deepar_student_t_bounded.R
# DEPLOYMENT TARGET: Native R Pipeline (Zero Python / Zero Reticulate Dependency)
# All code descriptions and labels are systematically maintained in English.
# ==============================================================================

if (!require("pacman")) install.packages("pacman")
pacman::p_load(tidyquant, tidyverse, timetk, torch, plotly, yardstick)

# 1. Fetch & Prepare Data from Yahoo Finance
df_dl <- tq_get("SOXX") %>%
  select(date, close) %>%
  filter(date >= last(date) - months(12)) %>%
  drop_na()

# Data Normalization Parameters
mean_close <- mean(df_dl$close)
sd_close   <- sd(df_dl$close)
df_dl      <- df_dl %>% mutate(close_scaled = (close - mean_close) / sd_close)

# Configuration Parameters
lookback_length   <- 20
prediction_length <- 10
num_paths         <- 100

train_data <- head(df_dl, nrow(df_dl) - prediction_length)
test_data  <- tail(df_dl, prediction_length)

# 2. Sequence Generator
create_sequences <- function(data_vector, lookback) {
  num_samples <- length(data_vector) - lookback
  x_mat <- matrix(0, nrow = num_samples, ncol = lookback)
  y_mat <- matrix(0, nrow = num_samples, ncol = 1)
  
  for (i in 1:num_samples) {
    x_mat[i, ]  <- data_vector[i:(i + lookback - 1)]
    y_mat[i, 1] <- data_vector[i + lookback]
  }
  
  list(
    x = torch_tensor(x_mat, dtype = torch_float())$unsqueeze(3),
    y = torch_tensor(y_mat, dtype = torch_float())
  )
}

seqs <- create_sequences(train_data$close_scaled, lookback_length)

# 3. Native Torch DeepAR Architecture with Bounded Student-t Head
deepar_student_net <- nn_module(
  "DeepARStudentNetBounded",
  initialize = function(input_size = 1, hidden_size = 32, num_layers = 2) {
    self$lstm     <- nn_lstm(input_size = input_size, hidden_size = hidden_size, 
                             num_layers = num_layers, batch_first = TRUE)
    self$fc_mu    <- nn_linear(hidden_size, 1)
    self$fc_sigma <- nn_linear(hidden_size, 1)
    self$fc_v     <- nn_linear(hidden_size, 1)
  },
  forward = function(x) {
    out <- self$lstm(x)
    last_hidden <- out[[1]][, dim(out[[1]])[2], ]
    
    mu    <- self$fc_mu(last_hidden)
    sigma <- nnf_softplus(self$fc_sigma(last_hidden)) + 1e-4
    
    # Bound degrees of freedom v between 4.0 and 30.0 to prevent explosive tails
    v     <- 4.0 + 26.0 * torch_sigmoid(self$fc_v(last_hidden))
    
    list(mu = mu, sigma = sigma, v = v)
  }
)

model     <- deepar_student_net()
optimizer <- optim_adam(model$parameters, lr = 0.003)

# Stable Student-t Negative Log-Likelihood Loss
student_t_nll_loss <- function(mu, sigma, v, y) {
  term1 <- torch_lgamma((v + 1) / 2)
  term2 <- torch_lgamma(v / 2)
  term3 <- 0.5 * torch_log(v * pi)
  term4 <- torch_log(sigma)
  
  residual <- (y - mu) / sigma
  term5 <- ((v + 1) / 2) * torch_log(1 + (residual$pow(2) / v))
  
  - (term1 - term2 - term3 - term4 - term5)
}

# 4. Training Loop
model$train()
for (epoch in 1:40) {
  optimizer$zero_grad()
  preds <- model(seqs$x)
  loss  <- student_t_nll_loss(preds$mu, preds$sigma, preds$v, seqs$y)$mean()
  loss$backward()
  
  # Gradient clipping for numerical stability
  nn_utils_clip_grad_norm_(model$parameters, max_norm = 1.0)
  optimizer$step()
}

# 5. Stochastic Monte Carlo Trajectory Sampling (Variance Variance-Controlled)
model$eval()
price_paths <- matrix(0, nrow = num_paths, ncol = prediction_length)
initial_input_seq <- tail(train_data$close_scaled, lookback_length)

with_no_grad({
  for (s in 1:num_paths) {
    curr_seq <- initial_input_seq
    
    for (t in 1:prediction_length) {
      curr_tensor <- torch_tensor(matrix(curr_seq, nrow = 1), dtype = torch_float())$unsqueeze(3)
      pred <- model(curr_tensor)
      
      mu    <- as.numeric(pred$mu)
      sigma <- as.numeric(pred$sigma)
      v_val <- as.numeric(pred$v)
      
      # Scaled Student-t sampling to strictly align variance with sigma
      scale_factor <- sqrt((v_val - 2) / v_val)
      sampled_scaled <- mu + sigma * scale_factor * rt(1, df = v_val)
      
      price_paths[s, t] <- sampled_scaled * sd_close + mean_close
      
      # Autoregressive slide
      curr_seq <- c(curr_seq[-1], sampled_scaled)
    }
  }
})

# 6. Extract Quantiles & Prepare Tidy Evaluation Data Frame
predicted_prices <- colMeans(price_paths)
lower_bound      <- apply(price_paths, 2, quantile, probs = 0.025)
upper_bound      <- apply(price_paths, 2, quantile, probs = 0.975)

df_eval <- tibble(
  date     = test_data$date,
  actual   = test_data$close,
  pred     = predicted_prices,
  conf_lo  = lower_bound,
  conf_hi  = upper_bound
)

# 7. Tidymodels / Yardstick Metric Engine
eval_metrics <- metric_set(mape, rmse, rsq)

metrics_summary <- df_eval %>%
  eval_metrics(truth = actual, estimate = pred) %>%
  select(.metric, .estimate) %>%
  rename(Metric = .metric, Value = .estimate)

print(metrics_summary)

mape_val <- metrics_summary %>% 
  filter(Metric == "mape") %>% 
  pull(Value)


# 8. Modern Interactive Plotly Visualization (Clean Lines & Clear Ribbon)

if (!require("pacman")) install.packages("pacman")
pacman::p_load(tidyquant, tidyverse, plotly, scales, glue, ggtext)

# 1. Prepare Dedicated Hover Text Layers
df_plot_actual <- df_eval %>% 
  select(date, actual) %>% 
  mutate(text_actual = glue::glue("<b>Actual Price:</b> ${round(actual, 2)}\n<b>Date:</b> {format(date, '%b %d, %Y')}"))

df_plot_pred <- df_eval %>% 
  select(date, pred) %>% 
  mutate(text_pred = glue::glue("<b>DeepAR Pred:</b> ${round(pred, 2)}\n<b>Date:</b> {format(date, '%b %d, %Y')}"))

df_plot_hi <- df_eval %>% 
  select(date, conf_hi) %>% 
  mutate(text_hi = glue::glue("<b>95% Upper Bound:</b> ${round(conf_hi, 2)}\n<b>Date:</b> {format(date, '%b %d, %Y')}"))

df_plot_lo <- df_eval %>% 
  select(date, conf_lo) %>% 
  mutate(text_lo = glue::glue("<b>95% Lower Bound:</b> ${round(conf_lo, 2)}\n<b>Date:</b> {format(date, '%b %d, %Y')}"))

# 2. Build GGPlot Spec with Invisible Boundary Anchors
p <- ggplot() +
  # Clean Background Ribbon
  geom_ribbon(
    data = df_eval,
    aes(x = date, ymin = conf_lo, ymax = conf_hi),
    fill  = "#808080",
    alpha = 0.20
  ) +
  # Invisible Upper Bound Hover Points (No Lines, Pure Hover)
  geom_point(
    data = df_plot_hi,
    aes(x = date, y = conf_hi, text = text_hi),
    color = "transparent",
    alpha = 0,
    size  = 3
  ) +
  # Invisible Lower Bound Hover Points (No Lines, Pure Hover)
  geom_point(
    data = df_plot_lo,
    aes(x = date, y = conf_lo, text = text_lo),
    color = "transparent",
    alpha = 0,
    size  = 3
  ) +
  # Actual Price: Solid Dark Line & Hover Points
  geom_line(
    data = df_plot_actual,
    aes(x = date, y = actual),
    color = "#2c3e50",
    linewidth = 1.2
  ) +
  geom_point(
    data = df_plot_actual,
    aes(x = date, y = actual, text = text_actual),
    color = "#2c3e50",
    size  = 2
  ) +
  # DeepAR Forecast: Dashed Red Line & Clean Hover Points
  geom_line(
    data = df_plot_pred,
    aes(x = date, y = pred),
    color = "#e74c3c",
    linetype = "dashed",
    linewidth = 1.2
  ) +
  geom_point(
    data = df_plot_pred,
    aes(x = date, y = pred, text = text_pred),
    color = "#e74c3c",
    size  = 2
  ) +
  # Formatting & Theme
  scale_y_continuous(labels = dollar_format(accuracy = 1)) +
  labs(
    x = "",
    y = "",
    title = paste0(
      "SOXX ETF <span style = 'color:#2c3e50'>Actual Prices</span> vs ",
      "<span style = 'color:#e74c3c'>Torch DeepAR Forecast</span><br>",
      "<span style='font-size:12px; color:#555555;'>10-Day Horizon | MAPE: ", round(mape_val, 2), "%</span>"
    )
  ) +
  theme_minimal() +
  theme(
    plot.title = element_markdown(
      hjust = 0.5, 
      face  = "bold"
    ),
    plot.background  = element_rect(fill = "#ffffff", color = NA),
    panel.background = element_rect(fill = "#ffffff", color = NA),
    panel.grid.minor = element_blank()
  )

# 3. Render Interactive Plotly Spec
font_family <- list(family = "Roboto Slab, Sans-Serif", size = 16)
label_font  <- list(font = list(family = "Roboto Slab, Sans-Serif", size = 13))

ggplotly(p, tooltip = "text") %>% 
  style(hoverlabel = label_font) %>% 
  layout(font = font_family) %>% 
  config(displayModeBar = FALSE)

Conclusion

By implementing DeepAR directly in R via torch (LibTorch), we achieve a low-latency, zero-Python architecture that fits naturally into existing tidymodels workflows. The resulting pipeline delivers high-precision probabilistic predictions (achieving a MAPE of ~2.17% on a 10-day SOXX forecast horizon) with fast, in-memory performance suitable for production deployment.

To leave a comment for the author, please follow the link and comment on their blog: DataGeeek.

R-bloggers.com offers daily e-mail updates about R news and tutorials about learning R and many other topics. Click here if you're looking to post or find an R/data-science job.
Want to share your content on R-bloggers? click here if you have a blog, or here if you don't.

Never miss an update!
Subscribe to R-bloggers to receive
e-mails with the latest R posts.
(You will not see this message again.)

Click here to close (This popup will not appear again)