• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

IDSIA / bayesRecon / #22

16 Apr 2026 09:15AM UTC coverage: 83.079% (+0.07%) from 83.013%
#22

push

travis-pro

web-flow
Merge 80e1711db into b66aa46cb

7 of 11 new or added lines in 4 files covered. (63.64%)

1036 of 1247 relevant lines covered (83.08%)

61800.22 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

90.34
/R/reconc_t.R
1
#' Estimate covariance from naive or seasonal-naive residuals
2
#'
3
#' Estimates via shrinkage the covariance matrix of the residuals of the naive 
4
#' or seasonal naive forecasts.
5
#' If the frequency of the time series is > 1, the function chooses between the
6
#' two methods series-by-series according to a selection criterion.
7
#' If the frequency is 1 or is not provided, only the naive residuals are used.
8
#'
9
#' @param y_train Multivariate time series object or numeric matrix of historical 
10
#'   observations, with dimensions `T x n` (rows are time points, columns are series).
11
#' @param freq Positive integer seasonal frequency (optional).
12
#'   If not provided and `y_train` is a multivariate time series, the frequency 
13
#'   of the data is used. 
14
#' @param criterion Character string used when `freq > 1` to choose residuals.
15
#'   Supported values are `"RSS"` (default) and `"seas-test"`.
16
#'   `"RSS"` chooses the method with the lower residual sum of squares (RSS), 
17
#'   while `"seas-test"` uses a statistical test for seasonality (requires `forecast` package).
18
#'
19
#' @return A numeric `n x n` shrinkage covariance matrix estimated with
20
#'   [schaferStrimmer_cov()].
21
#'
22
#' @seealso [schaferStrimmer_cov()], [reconc_t()]
23
#'
24
#' @keywords internal
25
#' @export
26
.compute_naive_cov = function(y_train, freq = NULL, criterion = "RSS") {
27
  
28
  n <- ncol(y_train)
13✔
29
  L <- nrow(y_train)
13✔
30
  
31
  # Compute residuals of naive
32
  res_n <- y_train[2:L, ] - y_train[1:(L - 1), ]
13✔
33
  
34
  # If freq is not provided and the data is a multivariate time series, 
35
  # set freq to the frequency of the data
36
  if (stats::is.mts(y_train)) {
13✔
37
    if (is.null(freq)) {
11✔
38
      freq <- stats::frequency(y_train)
11✔
39
    }
40
  }
41

42
  if (is.null(freq) || freq == 1) {
13✔
43
    residuals <- res_n
1✔
44
  } else {
45
    # compute residuals of seasonal naive
46
    res_seas <- y_train[(freq + 1):L, ] - y_train[1:(L - freq), ]
12✔
47

48
    if (is.null(criterion)) {
12✔
49
      criterion <- "RSS"
12✔
50
    }
51

52
    # Choose which residuals to use based on the criterion
53
    if (criterion == "seas-test") {
12✔
54
      # check if forecast package is installed
55
      if (!requireNamespace("forecast", quietly = TRUE)) {
×
56
        stop("Package 'forecast' is required for criterion = 'seas-test'. 
×
57
              Please install it using install.packages('forecast') or use criterion = 'RSS'."
×
58
        )
59
      }
60
      # Seasonality test for each time series
NEW
61
      is_seas = as.logical(apply(y_train, 2, 
×
NEW
62
                                 function(col) forecast::nsdiffs(stats::ts(col,frequency = freq))))
×
63
      
64
    } else if (criterion == "RSS") {
12✔
65
      # Compute RSS for both methods, for each series
66
      RSS_n <- colSums(res_n^2, na.rm = TRUE)
12✔
67
      RSS_seas <- colSums(res_seas^2, na.rm = TRUE)
12✔
68
      is_seas <- RSS_seas < RSS_n
12✔
69
      
70
    } else {
71
      stop("Input error: criterion must be either 'RSS' or 'seas-test'")
×
72
    }
73

74
    # No series is seasonal
75
    if (sum(is_seas) == 0) {
12✔
76
      residuals <- res_n
×
77

78
      # All series are seasonal
79
    } else if (sum(is_seas) == n) {
12✔
80
      residuals <- res_seas
2✔
81

82
      # Some series are seasonal, some are not (use criterion)
83
    } else {
84
      residuals <- matrix(NA, nrow = L - freq, ncol = n)
10✔
85
      residuals[, is_seas] <- res_seas[, is_seas]
10✔
86
      residuals[, !is_seas] <- res_n[freq:(L - 1), !is_seas]
10✔
87
    }
88
  }
89

90
  Sigma_naive <- schaferStrimmer_cov(residuals)$shrink_cov
13✔
91

92
  return(Sigma_naive)
13✔
93
}
94

95

96
#' Optimize degrees of freedom (nu) via LOO Cross-Validation
97
#'
98
#' @param res Matrix of residuals (n_obs x n_var).
99
#' @param prior_mean The prior mean covariance matrix (n_var x n_var).
100
#' @param trim Fraction of observations to trim (0 to 1). Default is 0.1 (10%).
101
#'
102
#' @details
103
#' TODO: check these details
104
#'
105
#' \strong{Leave-One-Out (LOO) Cross-Validation:}
106
#' This function estimates the optimal degrees of freedom \eqn{\nu} by maximizing the
107
#' out-of-sample predictive performance. This is achieved by computing the
108
#' log-density of each held-out observation \eqn{\mathbf{r}_i} given the remaining
109
#' data \eqn{\mathbf{R}_{-i}}. The total objective function is the sum of these
110
#' predictive log-densities:
111
#' \deqn{\mathcal{L}(\nu) = \sum_{i=1}^T \log f(\mathbf{r}_i | \mathbf{R}_{-i}, \nu)}
112
#'
113
#' \strong{The Log-Density Function:}
114
#' For each LOO step, the residuals are assumed to follow a
115
#' multivariate t-distribution. The density is expressed directly as a function of the
116
#' posterior sum-of-squares matrix \eqn{\Psi}, where \eqn{\Psi} scales implicitly with \eqn{\nu}:
117
#' \deqn{f(\mathbf{r}_i | \Psi, \nu) = \frac{\Gamma(\frac{\nu + T}{2})}{\Gamma(\frac{\nu + T - p}{2}) \pi^{p/2}} |\Psi|^{-1/2} \left( 1 + \mathbf{r}_i^\top \Psi^{-1} \mathbf{r}_i \right)^{-\frac{\nu + T}{2}}}
118
#' In the code, \eqn{\Psi} is constructed as:
119
#' \deqn{\Psi = (\nu - p - 1)\bar{\Sigma}_{prior} + \mathbf{R}^\top\mathbf{R}}
120
#' By using this formulation, the standard scaling factors \eqn{1/\nu} and \eqn{\nu^{-p/2}}
121
#' are absorbed into the matrix inverse and determinant, respectively.
122
#'
123
#' \strong{Efficient Computation via Sherman-Morrison:}
124
#' Rather than recomputing \eqn{\Psi_{-i}} and its inverse \eqn{T} times, the function uses
125
#' the full-sample matrix \eqn{\Psi} and adjusts it using the leverage
126
#' \eqn{h_i = \mathbf{r}_i^\top \Psi^{-1} \mathbf{r}_i}.
127
#'
128
#' Through the Matrix Determinant Lemma and the Sherman-Morrison formula, the
129
#' internal term \eqn{(1 + \mathbf{r}_i^\top \Psi_{-i}^{-1} \mathbf{r}_i)} simplifies to \eqn{(1 - h_i)^{-1}}.
130
#' The final log-density contribution used in the code is:
131
#' \deqn{\log f_i \propto \textrm{const} - \frac{1}{2}\log|\Psi| + \frac{\nu + T - 1}{2} \log(1 - h_i)}
132
#'
133
#' @return A list containing the optimization results:
134
#' * `optimal_nu`: The optimal degrees of freedom found.
135
#' * `min_neg_log_score`: The minimum negative log score achieved.
136
#' * `convergence`: The convergence status of the optimizer.
137
#' * `time_elapsed`: The time taken for the optimization.
138
#'
139
#' @import nloptr
140
#'
141
#' @export
142
multi_log_score_optimization <- function(res, prior_mean, trim = 0.1) {
143
  n_obs <- nrow(res)
13✔
144
  n_var <- ncol(res)
13✔
145

146
  # Pre-compute cross-product of the residuals (to avoid recomputing inside the loop)
147
  RRt <- crossprod(res)
13✔
148

149
  # Define the Objective Function (Negative Log Score to minimize)
150
  objective_function <- function(nu) {
13✔
151
    # Compute posterior Psi
152
    Psi <- (prior_mean * (nu - n_var - 1)) + RRt
156✔
153

154
    # Compute Cholesky decomposition
155
    Psi_chol <- tryCatch(chol(Psi), error = function(e) {
156✔
156
      return(NULL)
×
157
    })
158
    if (is.null(Psi_chol)) {
156✔
159
      return(Inf)
×
160
    } # Return Inf if Psi is not positive definite
13✔
161

162
    # Invert (using Cholesky)
163
    inv_Psi <- chol2inv(Psi_chol)
156✔
164

165
    # Calculate log-determinant
166
    log_det_Psi <- 2 * sum(log(diag(Psi_chol)))
156✔
167

168
    # Log Gamma terms and determinant
169
    const_term <- lgamma((nu + n_obs) / 2) - lgamma((nu + n_obs - n_var) / 2) - log_det_Psi / 2
156✔
170

171
    # Compute LOO Terms (using Sherman-Morrison)
172
    h_i <- rowSums((res %*% inv_Psi) * res)
156✔
173
    if (any(h_i >= 1)) {
156✔
174
      return(Inf)
×
175
    } # If h_i >= 1, the log is undefined
13✔
176
    log_adjustments <- log(1 - h_i)
156✔
177

178
    # Combine to get vector of LOO Log-Likelihoods per observation
179
    loo_log_liks <- const_term + ((nu + n_obs - 1) / 2) * log_adjustments
156✔
180

181
    # Calculate how many observations to remove (based on 'trim')
182
    n_trim <- round(trim * n_obs)
156✔
183

184
    # Sort likelihoods (ascending) and remove the lowest 'n_trim' values
185
    # Return negative sum because optimizer minimizes
186
    return(-sum(sort(loo_log_liks)[(n_trim + 1):n_obs]))
156✔
187
  }
188

189
  # Optimization Setup
190
  initial_guess <- n_var + 3
13✔
191
  lower_bound <- n_var + 2
13✔
192
  upper_bound <- max(5 * n_var, n_obs)
13✔
193

194
  opts <- list(
13✔
195
    "algorithm" = "NLOPT_LN_BOBYQA",
13✔
196
    "xtol_rel" = 1e-5,
13✔
197
    "maxeval" = 1000
13✔
198
  )
199

200
  # Run Optimization
201
  start_time <- Sys.time()
13✔
202
  results <- nloptr::nloptr(
13✔
203
    x0     = initial_guess,
13✔
204
    eval_f = objective_function,
13✔
205
    lb     = lower_bound,
13✔
206
    ub     = upper_bound,
13✔
207
    opts   = opts
13✔
208
  )
209
  end_time <- Sys.time()
13✔
210

211
  return(list(
13✔
212
    optimal_nu = results$solution,
13✔
213
    min_neg_log_score = results$objective,
13✔
214
    convergence = results$status,
13✔
215
    time_elapsed = end_time - start_time
13✔
216
  ))
217
}
218

219

220
#' t-Rec: reconciliation via conditioning with uncertain covariance via multivariate t-distribution
221
#'
222
#' Reconciles base forecasts in a hierarchy by conditioning on the hierarchical
223
#' constraints, specified by the aggregation matrix A.
224
#' The base forecasts are assumed to be jointly Gaussian, conditionally on the
225
#' covariance matrix of the forecast errors. 
226
#' A Bayesian approach is adopted to account for the uncertainty of the covariance matrix. 
227
#' An Inverse-Wishart prior is specified on the covariance matrix, 
228
#' leading to a multivariate t-distribution for the base forecasts.
229
#' The reconciliation via conditioning is in closed-form, yielding a multivariate t 
230
#' reconciled distribution.
231
#'
232
#' @param A Matrix (n_upp x n_bott) defining the hierarchy (u = Ab).
233
#' @param base_fc_mean Vector of base forecasts (length n = n_upp + n_bott).
234
#' @param y_train mts (or matrix) of historical training data (T x n) used for setting prior parameters.
235
#' @param residuals Matrix (T x n) of base forecast residuals.
236
#' @param ... Additional arguments for advanced usage: see details.
237
#' @param return_upper Logical; if TRUE, also returns parameters for the upper-level reconciled distribution.
238
#' @param return_parameters Logical; if TRUE, also returns prior and posterior parameters.
239
#'
240
#' @details
241
#' \strong{Standard usage.}
242
#' 
243
#' The standard workflow for this function is to provide the in-sample \code{residuals}
244
#' and the historical training data \code{y_train}.
245
#' The parameters of the Inverse-Wishart prior distribution of the covariance matrix 
246
#' are set as follows: 
247
#' \itemize{
248
#'   \item Prior scale matrix (Psi): set as the covariance of the residuals of naive (or seasonal naive,
249
#'         a criterion is used to choose between the two) forecasts computed on \code{y_train}.
250
#'   \item Prior degrees of freedom (nu): estimated via Bayesian Leave-One-Out
251
#'         Cross-Validation (LOOCV) to maximize out-of-sample performance.
252
#' }
253
#' The posterior distribution of the covariance matrix is still Inverse-Wishart.
254
#' The parameters of the posterior are computed in closed-form using the sample 
255
#' covariance of the provided \code{residuals}.
256
#'
257
#' \strong{Advanced Options.}
258
#' 
259
#' Users can bypass the automated estimation by specifying:
260
#' \enumerate{
261
#'   \item \code{prior}: a list with entries 'nu' and 'Psi'.
262
#'         This skips the LOOCV step for \eqn{\nu_0} and the covariance estimation from \code{y_train}.
263
#'         It requires \code{residuals} to compute the posterior.
264
#'   \item \code{posterior}: a list with entries 'nu' and 'Psi'.
265
#'         This skips all internal estimation and updating logic.
266
#' }
267
#' Moreover, users can specify:
268
#' \itemize{
269
#'   \item \code{freq}: positive integer, used as frequency of data for the seasonal naive forecast in the specification of the prior scale matrix.
270
#'         By default, if \code{y_train} is a multivariate time series, the frequency of the data is used; otherwise, it is set to 1 (no seasonality).
271
#'   \item \code{criterion}: either 'RSS' (default) or 'seas-test', specifying which criterior is used to choose between 
272
#'                           the naive and seasonal naive forecasts for the specification of the prior scale matrix. 
273
#'                           'RSS' computes the residual sum of squares for both methods and chooses the one with lower RSS, 
274
#'                           while 'seas-test' uses a statistical test for seasonality 
275
#'                           (currently implemented using the number of seasonal differences suggested by the `forecast` package, 
276
#'                           which must be installed). 
277
#' }
278
#' 
279
#' \strong{Reconciled distribution.}
280
#' 
281
#' The reconciled distribution is a multivariate t-distribution, 
282
#' specified by a vector of means, a scale matrix, and a number of degrees of freedom.
283
#' These parameters are computed in closed-form.
284
#' By default, only the parameters of the reconciled distribution for the bottom-level 
285
#' series are returned. See examples.
286
#'
287
#'
288
#' @references
289
#' Carrara, C., Corani, G., Azzimonti, D., & Zambon, L. (2025). Modeling the uncertainty on the covariance
290
#' matrix for probabilistic forecast reconciliation. arXiv preprint arXiv:2506.19554.
291
#' \url{https://arxiv.org/abs/2506.19554}
292
#'
293
#' @return A list containing:
294
#' \itemize{
295
#'   \item \code{bottom_rec_mean}: reconciled bottom-level mean.
296
#'   \item \code{bottom_rec_scale_matrix}: reconciled bottom-level scale matrix.
297
#'   \item \code{bottom_rec_df}: reconciled degrees of freedom.
298
#' }
299
#' If \code{return_upper} is TRUE, also returns:
300
#' \itemize{
301
#'  \item \code{upper_rec_mean}: reconciled upper-level mean.
302
#'  \item \code{upper_rec_scale_matrix}: reconciled upper-level scale matrix.
303
#'  \item \code{upper_rec_df}: reconciled upper-level degrees of freedom.
304
#'  }
305
#' If \code{return_parameters} is TRUE, also returns:
306
#' \itemize{
307
#'   \item \code{prior_nu}: prior degrees of freedom.
308
#'   \item \code{prior_Psi}: prior scale matrix.
309
#'   \item \code{posterior_nu}: posterior degrees of freedom.
310
#'   \item \code{posterior_Psi}: posterior scale matrix.
311
#' }
312
#'
313
#' @examples
314
#' 
315
#' \donttest{
316
#' library(bayesRecon)
317
#'
318
#' if (requireNamespace("forecast", quietly = TRUE)) {
319
#' 
320
#'   set.seed(1234)
321
#'   n_obs <- 100
322
#' 
323
#'   # Simulate 2 bottom series from AR(1) processes
324
#'   y1 <- arima.sim(model = list(ar = 0.8), n = n_obs)
325
#'   y2 <- arima.sim(model = list(ar = 0.5), n = n_obs)
326
#'
327
#'   y_upper <- y1 + y2   # upper series is the sum of the two bottoms
328
#'   A <- matrix(c(1, 1), nrow = 1)  # Aggregation matrix
329
#'
330
#'   # Fit additive ETS models
331
#'   fit1 <- forecast::ets(y1, additive.only = TRUE)
332
#'   fit2 <- forecast::ets(y2, additive.only = TRUE)
333
#'   fit_upper <- forecast::ets(y_upper, additive.only = TRUE)
334
#'
335
#'   # Point forecasts (h = 1)
336
#'   fc_upper <- as.numeric(forecast::forecast(fit_upper, h = 1)$mean)
337
#'   fc1 <- as.numeric(forecast::forecast(fit1, h = 1)$mean)
338
#'   fc2 <- as.numeric(forecast::forecast(fit2, h = 1)$mean)
339
#'   base_fc_mean <- c(fc_upper, fc1, fc2)
340
#'
341
#'   # Residuals and training data (n_obs x n matrices, columns in same order as base_fc_mean)
342
#'   res <- cbind(residuals(fit_upper), residuals(fit1), residuals(fit2))
343
#'   y_train <- cbind(y_upper, y1, y2)
344
#'
345
#'   # --- 1) Generate joint reconciled samples ---
346
#'   result <- reconc_t(A, base_fc_mean, y_train = y_train, residuals = res)
347
#'
348
#'   # Sample from the reconciled bottom-level t-distribution
349
#'   n_samples <- 2000
350
#'   L_chol <- t(chol(result$bottom_rec_scale_matrix))
351
#'   z <- matrix(rt(ncol(A) * n_samples, df = result$bottom_rec_df), nrow = ncol(A))
352
#'   bottom_samples <- result$bottom_rec_mean + L_chol %*% z  # 2 x n_samples
353
#'
354
#'   # Aggregate bottom samples to get upper samples
355
#'   upper_samples <- A %*% bottom_samples              
356
#'   joint_samples <- rbind(upper_samples, bottom_samples)
357
#'   rownames(joint_samples) <- c("upper", "bottom_1", "bottom_2")
358
#'
359
#'   cat("Reconciled means (from samples):\n")
360
#'   print(round(rowMeans(joint_samples), 3))
361
#' 
362
#'   cat("Reconciled standard deviations (from samples):\n")
363
#'   print(round(apply(joint_samples, 1, sd), 3))
364
#'
365
#'   # --- 2) 95% prediction intervals via t-distribution quantiles ---
366
#'   result2 <- reconc_t(A, base_fc_mean, y_train = y_train,
367
#'                       residuals = res, return_upper = TRUE)
368
#'
369
#'   alpha <- 0.05
370
#'   # Bottom series intervals
371
#'   for (i in seq_len(ncol(A))) {
372
#'     s_i <- sqrt(result2$bottom_rec_scale_matrix[i, i])
373
#'     lo  <- result2$bottom_rec_mean[i] + s_i * qt(alpha / 2,     df = result2$bottom_rec_df)
374
#'     hi  <- result2$bottom_rec_mean[i] + s_i * qt(1 - alpha / 2, df = result2$bottom_rec_df)
375
#'     cat(sprintf("Bottom %d: 95%% PI = [%.3f, %.3f]\n", i, lo, hi))
376
#'   }
377
#'   # Upper series interval
378
#'   s_u <- sqrt(result2$upper_rec_scale_matrix[1, 1])
379
#'   lo  <- result2$upper_rec_mean[1] + s_u * qt(alpha / 2,     df = result2$upper_rec_df)
380
#'   hi  <- result2$upper_rec_mean[1] + s_u * qt(1 - alpha / 2, df = result2$upper_rec_df)
381
#'   cat(sprintf("Upper:    95%% PI = [%.3f, %.3f]\n", lo, hi))
382
#' }
383
#' }
384
#' 
385
#' @seealso 
386
#' [reconc_gaussian()]
387
#'
388
#' @export
389
reconc_t <- function(A,
390
                     base_fc_mean,
391
                     y_train = NULL,
392
                     residuals = NULL,
393
                     ...,
394
                     return_upper = FALSE,
395
                     return_parameters = FALSE) {
396
  
397
  add_args <- list(...)
24✔
398
  unused_names <- setdiff(names(add_args), c("prior", "posterior", "freq", "criterion"))
24✔
399
  if (length(unused_names) > 0) {
24✔
400
    warning(paste("The following additional arguments are not used:", paste(unused_names, collapse = ", ")))
1✔
401
  }
402
  
403
  prior <- add_args$prior
24✔
404
  posterior <- add_args$posterior
24✔
405
  freq <- add_args$freq
24✔
406
  criterion <- add_args$criterion
24✔
407
  .check_input_t(A, base_fc_mean, y_train, residuals, ...)  
24✔
408

409
  ##############################################################################
410
  ### CASE 1 ###
411
  # If posterior is provided, check if is a list with entries nu and Psi and extract values
412
  if (!is.null(posterior)) {
24✔
413
    nu_post <- posterior$nu
9✔
414
    Psi_post <- posterior$Psi
9✔
415
    
416
    ### CASE 2 ###
417
    # If posterior not provided, first check that residuals are provided
418
  } else {
419
    L <- nrow(residuals) # number of residual samples (i.e., training length)
15✔
420
    n <- length(base_fc_mean) # number of series
15✔
421
    
422
    # Compute sample covariance of the residuals
423
    Samp_cov <- crossprod(residuals) / nrow(residuals) 
15✔
424
    # Shrink to diagonal for numerical reasons
425
    Samp_cov <- (1 - .L_SHRINK_RECONC_T) * Samp_cov + .L_SHRINK_RECONC_T * diag(diag(Samp_cov)) 
15✔
426
    
427
    ### CASE 2a ###
428
    # If prior is provided, check if is a list with entries nu and Psi and extract values
429
    if (!is.null(prior)) {
15✔
430
      nu_prior <- prior$nu
2✔
431
      Psi_prior <- prior$Psi
2✔
432
      
433
      ### CASE 2b ###
434
      # If prior not provided:
435
      # - compute Psi using the (shrinked) covariance matrix of the residuals of the naive
436
      #   or seasonal naive forecasts
437
      # - set nu using LOOCV
438
    } else {
439
      # Compute the covariance residuals of the naive or seasonal naive forecasts on training data
440
      cov_naive <- .compute_naive_cov(y_train, freq = freq, criterion = criterion)
13✔
441
      # Use it to set the prior
442
      bayesian_LOO <- multi_log_score_optimization(residuals, cov_naive)
13✔
443
      nu_prior <- bayesian_LOO$optimal_nu
13✔
444
      Psi_prior <- (nu_prior - n - 1) * cov_naive
13✔
445
    }
446
    # Compute posterior parameters
447
    Psi_post <- Psi_prior + L * Samp_cov
15✔
448
    nu_post <- nu_prior + nrow(residuals)
15✔
449
  }
450

451
  ##############################################################################
452
  # Reconcile via conditioning the t-distribution in closed form
453

454
  out <- .core_reconc_t(
24✔
455
    A = A, base_fc_mean = base_fc_mean, Psi_post = Psi_post, nu_post = nu_post,
24✔
456
    return_upper = return_upper)
24✔
457

458
  if (return_parameters) {
24✔
459
    if(!is.null(posterior)) {
4✔
460
      warning("Prior parameters are not returned when 'posterior' is provided, 
×
461
              as the prior is not used in this case.")
×
462
    } else {
463
      out$prior_nu <- nu_prior
4✔
464
      out$prior_Psi <- Psi_prior
4✔
465
    }
466
    out$posterior_nu <- nu_post
4✔
467
    out$posterior_Psi <- Psi_post
4✔
468
  }
469

470
  return(out)
24✔
471
}
472

473
#' Core reconciliation via multivariate t-distribution.
474
#'
475
#' Internal function that performs the core reconciliation logic for the t-distribution based
476
#' reconciliation method. This function assumes an uncertain covariance matrix with an Inverse-Wishart prior.
477
#'
478
#' @param A Matrix (n_upper x n_bottom) defining the hierarchy where upper = A %*% bottom.
479
#' @param base_fc_mean Vector of length (n_upper + n_bottom) containing the base forecast means
480
#'   for both upper and bottom levels (upper first, then bottom).
481
#' @param Psi_post Scale matrix (n_upper + n_bottom x n_upper + n_bottom) of the posterior
482
#'   multivariate t-distribution.
483
#' @param nu_post Degrees of freedom of the posterior multivariate t-distribution.
484
#' @param return_upper Logical, whether to return the reconciled parameters for the upper variables (default is FALSE).
485
#'
486
#' @return A list containing:
487
#' \itemize{
488
#'   \item \code{bottom_rec_mean}: reconciled bottom-level mean.
489
#'   \item \code{bottom_rec_scale_matrix}: reconciled bottom-level scale matrix.
490
#'   \item \code{bottom_rec_df}: reconciled degrees of freedom.
491
#' }
492
#' If \code{return_upper} is TRUE, also returns:
493
#' \itemize{
494
#'  \item \code{upper_rec_mean}: reconciled upper-level mean.
495
#'  \item \code{upper_rec_scale_matrix}: reconciled upper-level scale matrix.
496
#'  \item \code{upper_rec_df}: reconciled upper-level degrees of freedom.
497
#'  }
498
#'
499
#' @keywords internal
500
#' @export
501
.core_reconc_t <- function(A, base_fc_mean, Psi_post, nu_post, return_upper = FALSE,
502
                           return_parameters = FALSE) {
503
  # Indices for Upper and Bottom
504
  k <- nrow(A)
24✔
505
  m <- ncol(A)
24✔
506
  idx_u <- 1:k
24✔
507
  idx_b <- (k + 1):(k + m)
24✔
508

509
  # Extract Psi blocks
510
  Psi_U <- Psi_post[idx_u, idx_u, drop = FALSE]
24✔
511
  Psi_B <- Psi_post[idx_b, idx_b, drop = FALSE]
24✔
512
  Psi_UB <- Psi_post[idx_u, idx_b, drop = FALSE]
24✔
513

514
  # Extract means
515
  u_hat <- base_fc_mean[idx_u]
24✔
516
  b_hat <- base_fc_mean[idx_b]
24✔
517

518
  # Compute Q = Psi_U - (Psi_UB %*% t(A)) - (A %*% t(Psi_UB)) + (A %*% Psi_B %*% t(A))
519
  Psi_UB_At <- tcrossprod(Psi_UB, A)
24✔
520
  A_Psi_B <- A %*% Psi_B
24✔
521
  Q <- Psi_U - Psi_UB_At - t(Psi_UB_At) + tcrossprod(A_Psi_B, A)
24✔
522

523
  # Invert Q using Cholesky (should be p.d.)
524
  Q_chol <- tryCatch(chol(Q), error = function(e) NULL)
24✔
525
  # If Cholesky fails, stop
526
  if (is.null(Q_chol)) {
24✔
NEW
527
    stop("A numerical error occured during a Cholesky decomposition. 
×
NEW
528
         This may be due to redundant aggregated series.")
×
529
  }
530
  inv_Q <- chol2inv(Q_chol)
24✔
531

532
  # Incoherence
533
  delta <- (A %*% b_hat) - u_hat
24✔
534

535
  # Lambda = Psi_UB^T - Psi_B A^T
536
  Lambda <- t(Psi_UB) - tcrossprod(Psi_B, A)
24✔
537
  Lambda_invQ <- Lambda %*% inv_Q
24✔
538

539
  # Compute b_tilde = b_hat + Lambda * Q^{-1} * delta
540
  b_tilde <- b_hat + (Lambda_invQ %*% delta)
24✔
541

542
  # Compute nu_tilde = nu' - n_b + 1
543
  nu_tilde <- nu_post - m + 1
24✔
544

545
  # Compute C = (1 + delta^T Q^{-1} delta) / nu_tilde
546
  mahalanobis_term <- sum(delta * (inv_Q %*% delta)) # efficient x^T A x
24✔
547
  C <- (1 + mahalanobis_term) / nu_tilde
24✔
548

549
  # Compute Sigma_B_tilde = C * [ Psi_B - Lambda * Q^{-1} * Lambda^T ]
550
  Sigma_tilde_B <- as.numeric(C) * (Psi_B - tcrossprod(Lambda_invQ, Lambda))
24✔
551

552
  # Prepare output
553
  out <- list(
24✔
554
    bottom_rec_mean = as.vector(b_tilde),
24✔
555
    bottom_rec_scale_matrix = Sigma_tilde_B,
24✔
556
    bottom_rec_df = nu_tilde
24✔
557
  )
558
  if (return_upper) {
24✔
559
    # Compute the parameters of the uppers using closure property
560
    u_tilde <- A %*% b_tilde
4✔
561
    Sigma_tilde_U <- A %*% Sigma_tilde_B %*% t(A)
4✔
562
    out$upper_rec_mean <- as.vector(u_tilde)
4✔
563
    out$upper_rec_scale_matrix <- Sigma_tilde_U
4✔
564
    out$upper_rec_df <- nu_tilde
4✔
565
  }
566

567
  return(out)
24✔
568
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc