Power / Trend / Seasonal models with pts()

Ivan Svetunkov

2026-06-25

Introduction

The muse package fits PTS (Power / Trend / Seasonal) state-space models — single-source-of-error structural time-series models whose components mirror the ETS/ADAM taxonomy. All estimation happens through a single user-facing function, pts(), which delegates to a C++ engine (Rcpp + RcppArmadillo) running a Kalman filter / smoother under a concentrated likelihood.

pts() returns an object of class c("pts", "smooth"), so all generics shared with the smooth / greybox ecosystem (forecast(), plot(), accuracy(), AIC(), BIC(), simulate(), …) work out of the box.

This vignette walks through the main features on two datasets:

library(muse)
#> Loading required package: greybox
#> Package "greybox", v2.0.9.41002 loaded.
#> Loading required package: smooth
#> This is package "smooth", v4.5.0

Quick start: AirPassengers

The simplest call lets pts() choose everything automatically:

air <- pts(AirPassengers, model = "ZZZ", h = 12, holdout = TRUE)
air
#> Time elapsed: 0.42 seconds
#> Model estimated using pts() function: PTS(2,L,T)
#> With Box-Cox lambda = 2
#> Periods: 12.0 / 6.0 / 4.0 / 3.0 / 2.4 / 2.0
#> Distribution assumed in the model: Normal
#> Loss function type: likelihood; Loss function value: 483.11
#> Variance parameters:
#>               Variance Proportion
#> Level          25270.0   0.087220
#> Slope          68160.0   0.235300
#> Seas(All) (*) 195400.0   0.674400
#> Irregular        889.8   0.003071
#> (*) concentrated out
#> 
#> Sample size: 132
#> Number of estimated parameters: 17
#> Number of degrees of freedom: 115
#> Information criteria:
#>      AIC     AICc      BIC     BICc 
#> 1000.220 1005.588 1049.228 1062.334

The model string is a compact three-character spec encoding Power / Trend / Seasonal in that order; the three Zs ask the engine to choose:

Because we asked for holdout = TRUE with h = 12, the last 12 months were withheld from estimation and stashed on the object for later accuracy evaluation.

A look at the in-sample fit and residuals:

plot(air, which = c(1, 2))

To pull the structural decomposition (level / slope / seasonal / irregular):

plot(air, which = 12)

A forecast over the holdout window:

fc <- forecast(air, h = 12)
plot(fc)

…and accuracy against the held-out values:

accuracy(air)
#>            ME           MAE           MSE           MPE          MAPE 
#>  -3.141551008  15.624135272 416.959651342  -0.011783682   0.033972940 
#>           sCE          sMAE          sMSE          MASE         RMSSE 
#>  -0.143617905   0.059522233   0.006051464   0.648735886   0.651714094 
#>          SAME          rMAE         rRMSE          rAME     asymmetry 
#>   0.130441579   0.205580727   0.198293621   0.044143574  -0.119834000 
#>          sPIS 
#>   0.873553552

The PTS model string

The model argument is a compact three-character string of the form "PTS" (Power-Trend-Seasonal): position 1 is the Box-Cox λ, position 2 the trend letter, position 3 the seasonal letter. Numeric λ values can take more than one character ("0.5LT", "-0.3LD") — the trend and seasonal letters are always read from the last two positions. The fitted object’s $model field prints in a more readable form (e.g. "PTS(-0.3, G, D)") but that pretty-printed string is not accepted as input.

Power (P)

Position 1 is the Box-Cox parameter λ. Two ways to specify it:

Value Effect
"Z" Estimate λ jointly with the state-space parameters
Numeric, e.g. "0", "0.5", "1" Fix λ to that value

The two singular points have exact-equality shortcuts: λ = 0 corresponds to a log transform, λ = 1 corresponds to no transform (identity). For every other value the standard \((y^{\lambda} - 1) / \lambda\) formula is used. In-sample residuals, innovations and the additive decomposition all live on the Box-Cox scale; fitted values and forecasts are back-transformed to the original scale.

air_log <- pts(AirPassengers, model = "0LT", h = 12, holdout = TRUE)
air_log$lambda
#> [1] 0

Trend (T)

Position 2 picks the trend component:

Letter Trend type
Z Automatic selection
N None (random walk; level only)
L Local linear trend (level + slope)
D Damped trend (level + damped slope)
G Global / deterministic trend

Seasonal (S)

Position 3 picks the seasonal component:

Letter Seasonal type
Z Automatic selection
N No seasonal component
D Discrete (harmonic selection from the seasonal period)
T Trigonometric (equal variance across all harmonics)

The seasonal period is taken from frequency(data) by default; pass lags to override.

Automatic selection

Setting any letter to Z triggers an internal search over the admissible candidates. The information criterion is set via ic (default "AICc"):

pts(AirPassengers, model = "ZZZ", h = 12, ic = "BIC")

ARMA on the irregular component

The irregular component can itself be an ARMA / SARMA process, controlled by orders:

# ARMA(2, 1) on the irregular
pts(AirPassengers, model = "ZZZ", orders = c(2, 1), h = 12)

# Seasonal SARMA(1, 1)(1, 1)_12 — non-seasonal + seasonal blocks
pts(AirPassengers, model = "ZZZ",
    orders = list(ar = c(1, 1), ma = c(1, 1), lags = c(1, 12)), h = 12)

# Automatic ARMA search up to the supplied caps
pts(AirPassengers, model = "ZZZ",
    orders = list(ar = 2, ma = 2, select = TRUE), h = 12)

When orders$select = TRUE muse runs a nested PTS-outer / ARMA-inner selection: for every structural candidate it scores the best ARMA order found on the residuals, and picks the joint (structure, ARMA) pair with the lowest IC.

Forecasting

forecast() is the workhorse for producing point forecasts and prediction intervals. The mean is always returned on the original scale (back-transformed from Box-Cox); intervals are built by endpoint-transforming the BC-scale bounds.

fc <- forecast(air, h = 12, interval = "prediction", level = c(0.80, 0.95))
plot(fc)

Three interval types are supported:

interval Source
"prediction" Default; total uncertainty from the filter
"confidence" Mean-only uncertainty (subtracts observation variance)
"simulated" Empirical quantiles from simulate() paths
"none" Point forecast only

cumulative = TRUE collapses the forecast horizon into a single total — exact for "simulated", an approximation otherwise.

Outlier detection

pts() can detect and absorb outliers in a single call, mirroring the adam-style outliers / level API:

y <- AirPassengers
y[100] <- 3 * y[100]            # inject an obvious additive spike

m_out <- pts(y, model = "ZZZ", outliers = "use", level = 0.99)
m_out$outliersDetected
#>   time type
#> 1   23   AO
#> 2  100   AO

The detected events are classified as:

level controls the underlying z-score threshold via qnorm((1 + level) / 2) — at 0.99 ≈ 2.576. Detected outliers are injected as fixed dummy regressors (AO<t>, LS<t>, SC<t>); their coefficients show up in coef(m_out) and print() reports a one-line summary of how many of each type were absorbed.

Setting outliers = "ignore" (the default) skips the detection step entirely.

External regressors

pts() accepts a data.frame / matrix / mts whose first column is the response and whose remaining columns are external regressors. On the Seatbelts dataset we can use kms, PetrolPrice, and law (the 1983 seat-belt law indicator) as regressors for monthly drivers (drivers killed or seriously injured):

sb <- Seatbelts[, c("drivers", "kms", "PetrolPrice", "law")]
m_sb <- pts(sb, model = "ZZZ", h = 12, holdout = TRUE)
m_sb
#> Time elapsed: 0.81 seconds
#> Model estimated using pts() function: PTS(0.5,N,D)
#> With Box-Cox lambda = 0.5
#> Distribution assumed in the model: Normal
#> Loss function type: likelihood; Loss function value: 1144.596
#> Variance parameters:
#>           Variance Proportion
#> Level        1.243     0.2260
#> Seas (*)     1.243     0.2260
#> Irregular    3.012     0.5479
#> (*) concentrated out
#> 
#> Regressor coefficients:
#>    Beta(1)    Beta(2)    Beta(3) 
#>  2.163e-04 -1.190e+02 -9.816e+00 
#> 
#> Sample size: 180
#> Number of estimated parameters: 21
#> Number of degrees of freedom: 159
#> Information criteria:
#>      AIC     AICc      BIC     BICc 
#> 2331.191 2337.040 2398.244 2413.428

For point forecasting with regressors you must supply newdata covering the forecast horizon:

# Holdout values of the regressors are stashed on $holdout for accuracy
fc_sb <- forecast(m_sb, h = 12, newdata = tail(Seatbelts, 12))
plot(fc_sb)

A pure structural-break example

The law column makes Seatbelts a natural showcase for engine-side outlier detection: the 1983 intervention should look like a level shift to a structural model that does not have it as a regressor.

m_sb_out <- pts(Seatbelts[, "drivers"], model = "ZZZ",
                outliers = "use", level = 0.99)
m_sb_out$outliersDetected
#> [1] time type
#> <0 rows> (or 0-length row.names)

The engine is expected to flag a level shift around February 1983 (observation 170 of 192).

Holdout evaluation and accuracy

The combination of holdout = TRUE plus h > 0 reserves the tail of the series for evaluation. accuracy() then refits-free scores the forecast against the holdout:

accuracy(air)
#>            ME           MAE           MSE           MPE          MAPE 
#>  -3.141551008  15.624135272 416.959651342  -0.011783682   0.033972940 
#>           sCE          sMAE          sMSE          MASE         RMSSE 
#>  -0.143617905   0.059522233   0.006051464   0.648735886   0.651714094 
#>          SAME          rMAE         rRMSE          rAME     asymmetry 
#>   0.130441579   0.205580727   0.198293621   0.044143574  -0.119834000 
#>          sPIS 
#>   0.873553552

The metrics come from greybox::measures() so the full suite (MAE, MASE, RMSE, MAPE, sCE, …) is available.

Simulation

simulate() produces fan trajectories from the fitted model:

set.seed(42)
sim <- simulate(air, nsim = 200, h = 12)
str(sim, max.level = 1)
#> List of 5
#>  $ data : Time-Series [1:132, 1:200] from 1949 to 1960: 112 125 125 149 107 ...
#>   ..- attr(*, "dimnames")=List of 2
#>  $ model: chr "PTS(2,L,T)"
#>  $ nsim : int 200
#>  $ obs  : int 132
#>  $ seed : NULL
#>  - attr(*, "class")= chr [1:2] "pts.sim" "smooth.sim"

In-sample replay starts from the initial state estimated by the filter; out-of-sample paths propagate forward with state and observation disturbances drawn from the fitted noise distribution.

Inspecting the fit

A range of standard accessors works on pts objects:

summary(air)         # Coefficient table + variance proportions
coef(air)            # Estimated parameter vector
vcov(air)            # Parameter covariance matrix
confint(air)         # Wald confidence intervals
logLik(air); AIC(air); BIC(air)
fitted(air); residuals(air)
rstandard(air); rstudent(air)
nparam(air); nobs(air); sigma(air)
modelType(air); orders(air); lags(air); errorType(air)

Further reading

mirror server hosted at Truenetwork, Russian Federation.