tsgcAbstract
This paper documents the Time Series Growth Curves (tsgc)
package for R, which is designed for forecasting epidemic
trajectories, including the detection of new waves and turning points.
The package implements dynamic Gompertz state-space methods, together
with a leading-indicator extension that uses a related series moving
ahead of the variable of interest to improve short-horizon forecasts,
with lag selection supported by rolling-origin cross-validation. The
state-space framework applies equally to daily, weekly, monthly,
quarterly, or annual data. Estimation and forecasting are defined purely
in terms of integer-position (idx_series) data; calendar
time is reintroduced only as an optional, purely cosmetic layer for
plotting. The package is illustrated with COVID-19 confirmed cases,
hospitalisation data, a cross-country leading-indicator comparison, and
non-epidemic applications such as the diffusion of new products and the
adoption of online services. The models are estimated with Kalman-filter
techniques and are suitable for short-horizon forecasting of variables
whose cumulative form is subject to an unknown saturation level.
Outbreaks of infectious diseases with epidemic potential require real-time responses by public health authorities. Accurate real-time forecasting of the trajectory of the epidemic over the near future is of great value in this regard.
The R package, tsgc, is intended for use in
monitoring and forecasting the progress of an epidemic, including the
detection of new waves and turning points. It develops
and implements time series growth curve methods first reported in Harvey and Kattuman (2020) (hereinafter referred
to as HK). HK develop a class of time series models for predicting
future values of a variable which, when cumulated, is subject to an
unknown saturation level. In a single wave of an epidemic, as more and
more people get infected, the pool of susceptible individuals dwindles.
This results in the decline of new infections, and the cumulative number
of infections approaches its saturation level. The model can take
account of deviations relative to this canonical trajectory due to
changes in social behavior and policy. Models in this family are
relevant for many other disciplines, such as marketing (when estimating
the demand for new products). While attention here is focused on the
spread of epidemics and the applications used for illustration relate to
coronavirus, this package is designed with a view to wider
applicability.
Given the number of different modeling approaches for epidemics,
there are many notable packages that can be used for monitoring
epidemics. For the most part, these seek to model explicitly the
mechanism by which the disease spreads through the population. For
example, EpiModel (Jenness et al.
2018), EpiEstim (Cori et al.
2013), epinowcast (Abbott and
Monticone 2021) to name a few, can be categorized as belonging to
the class of mechanistic models in the language of philosophy
of science, in that they require structural knowledge of the disease
spread mechanism in order to obtain predictions.
In contrast, the empirical approach implemented in tsgc
falls into the class of models described as phenomenological.
Although it is motivated by the archetypal pattern in the dynamics of
disease spread, it does not rely on structural assumptions derived from
epidemiological theory. There are advantages to not requiring
assumptions about values of parameters relating to, inter alia, disease
infectiousness, disease severity, or contact patterns, which are
difficult to pin down with sufficient precision, especially in real-time
during an epidemic. Our approach makes minimal assumptions and merely
requires past observations of the epidemic variable of interest, to
which we apply time-series methods to provide predictions over short
future time horizons. The model can be estimated quickly and
straightforwardly, and subjected to standard diagnostic tests. A
statistical model of this type is a useful complement to mechanistic
models that attempt to describe the epidemic in terms of underlying
processes.
Section 2 sets out the state space formulation of the dynamic
Gompertz growth curve and the way nowcasts and forecasts are obtained
from predictive recursions. It is then shown how these numbers translate
into estimates of the instantaneous reproduction number \(R_t\). Section 3 explains how multiple
waves can be accommodated by reinitializing the series at the start of
new waves. The start of a new wave is not obvious in real time but a
rule for triggering reinitialization that works well in practice is
presented. Section 4 describes the functionality of tsgc,
including the leading-indicator extension. Section 5 sets out a full
working example of the package, beginning with COVID-19 data for Gauteng
province in South Africa and continuing with the leading-indicator
model, a comparison against the dynamic Gompertz model, lag selection by
rolling cross-validation, the forecast accuracy measures used to
evaluate the package’s models, and the package’s applicability to data
of non-daily frequency and non-epidemological nature. Section 6
concludes.
Our model is based on the sigmoidal growth curve pattern that characterizes epidemics. We start by assuming that the cumulative number of cases follows a Gompertz curve, which is a parsimonious model for the canonical sigmoid shape of cumulative case numbers in a one-wave epidemic. Over the course of a wave, the number of new infected cases increases up to a peak before declining to zero as the pool of susceptible individuals declines. Specifically, if the cumulative number of cases at time \(t\), \(\mu(t)\), follows a Gompertz curve, we can write \[\mu(t) = \bar{\mu}\exp\{\gamma_0 e^{\gamma t}\},\] where \(\bar{\mu}\) is the unknown saturation level for the cumulative number of cases, \(\gamma_0 < 0\) is a parameter related to \(\mu(0)\) and \(\gamma < 0\) is the growth rate parameter. Defining \(\dot{\mu}(t) \equiv d\mu(t)/dt\) and \(g(t) \equiv {\dot\mu(t)}/{\mu(t)}\), it is straightforward to show that
\[\begin{align} \ln g(t) &= \delta + \gamma t, & (1) \end{align}\]where \(\delta = \ln \gamma_0 \gamma\).
The observational model needs to be specified in discrete, rather than continuous, time. This is straightforward. Let \(Y_t\) be the observed cumulative number of cases on day \(t\) and \(y_t = Y_t-Y_{t-1}\) be the number of daily new cases.1 We can then define the growth rate of \(Y_t\) as \(g_t = y_t/Y_{t-1}\) and replace \(\ln g(t)\) with \(\ln y_{t}-\ln Y_{t-1}\).
The deterministic trend implied by (1) is too inflexible for practical time-series modeling of an epidemic. Replacing it with a stochastic trend allows the model to adapt to changes in dynamics during the course of the epidemic. We call this stochastic-trend counterpart of (1) the dynamic Gompertz model. It is a local linear trend model specified as
\[\begin{align} \ln g_{t}&=\delta_{t}+\varepsilon_{t}, \;\;\;\;\;\varepsilon_{t}\sim NID(0,\sigma_{\varepsilon }^{2}),\;t=2,...,T, & (2) \end{align}\] where \(\ln g_{t}=\ln y_{t}-\ln Y_{t-1}\) and
\[\begin{align} \delta_{t} &=\delta_{t-1}+\gamma_{t-1}, & (3)\\ \gamma_{t} &=\gamma_{t-1}+\zeta_{t},\;\;\; \zeta_{t}\sim NID(0,\sigma_{\zeta }^{2}), & (4) \end{align}\] where the disturbances \(\varepsilon_{t}\) and \(\zeta_{t}\) are mutually independent, and \(NID(0,\sigma^2)\) denotes normally and independently distributed with mean zero and variance \(\sigma^2\). Note that the larger the signal-to-noise ratio, \(q_{\zeta }=\sigma_{\zeta }^{2}/\sigma_{\varepsilon }^{2}\), the faster the estimate of the slope parameter, \(\gamma_t\), which can be interpreted as the growth rate of the growth rate of cumulative cases, changes in response to new observations. Conversely, a lower signal-to-noise ratio induces more smoothness to the estimates. When \(\sigma_{\zeta }^{2}=0\), the trend is deterministic as in (1).
It is convenient to write the dynamic Gompertz model in general state space form: \[\begin{align*} \ln g_t &= Z\alpha_t + \varepsilon_t & \varepsilon_{t} \sim NID(0,\sigma^2_{\varepsilon})\\ \alpha_{t+1} &= T\alpha_{t} + R \eta_t & \eta_{t} \sim NID(0,Q) \end{align*}\] with \[\begin{equation*} \alpha_t = \left(\delta_t, \gamma_t\right)', \; Z = \left(1, 0\right), \; \eta_t = \left(0, \zeta_t\right)', \; T = \begin{pmatrix} 1 & 1 \\ 0 & 1 \end{pmatrix}, \; R = \begin{pmatrix} 0 & 0 \\ 0 & 1 \end{pmatrix}, \; Q = \begin{pmatrix} 0 & 0 \\ 0 & \sigma^2_{\zeta} \end{pmatrix}. \end{equation*}\] This model can be estimated using techniques based on the Kalman filter once a prior is specified. The prior is
\[\begin{equation*} (\delta_1, \gamma_1)' \sim N(a_1,P_1), \end{equation*}\]
where \(a_1\) is a \(2\times 1\) vector of prior means and \(P_1\) a \(2\times
2\) prior variance matrix. We use a diffuse prior due to the
absence of prior information about the epidemic when the model is first
estimated: i.e., we set \(a_1 =
(0,0)'\), \(P_1=\kappa I\),
and let \(\kappa \to \infty.\) Model
estimation, including implementation of the diffuse prior, is carried
out using the KFAS package Helske
(2017).
The Kalman filter outputs estimates of the state vector \((\delta_{t},\gamma_{t})^{\prime }.\) The estimates at time \(t\) conditional on information up to and including time \(t\) are denoted \((\hat\delta_{t \mid t},\hat\gamma_{t \mid t})^{\prime }\) and given by the contemporaneous filter; the predictive filter estimates the state at time \(t+1\) from the same information set, outputting \((\hat\delta_{t+1 \mid t},\hat\gamma_{t+1 \mid t})^{\prime }.\)
It may be useful to review past movements of the state vector \((\delta_{t},\gamma_{t})^{\prime }.\) This can be done using the smoothed estimates \((\hat\delta_t,\hat\gamma_t)^{\prime}\), which denotes the estimates of the state vector at time \(t\) based on all \(T\) observations in the series.
Estimation of the unknown variance parameters (\(\sigma^2_{\varepsilon}\) and \(\sigma^2_{\zeta}\)) is by maximum
likelihood (ML) and is carried out using KFAS following the
procedure described in Helske (2017). We
retain the option of either estimating the signal-to-noise ratio \(q_{\zeta}\), or of fixing it at a plausible
value. In practice, for coronavirus applications, we set the value of
\(q_{\zeta}\) based on experience and
judgment, reducing the number of parameters to be estimated by one.
Tests for normality and residual serial correlation are based on the
standardized innovations, that is one-step ahead prediction errors,
\(v_{t}=\ln g_{t}-\delta_{t \mid
t-1},\) \(t=3,...,T.\)
Daily effects, which are generally quite pronounced in the coronavirus data, can be included in the model as described in the Appendix.
Forecasts of future observations are obtained from the predictive recursions
\[\begin{align} \nonumber \widehat{g}_{T+\ell \mid T} &=\exp (\hat\delta_{T\mid T}+\hat\gamma _{T\mid T}\ell ),\;\;\;\;\ell =1,2,...\\ \widehat{\mu }_{T+\ell \mid T} &=\widehat{\mu }_{T+\ell -1\mid T}(1+ \widehat{g}_{T+\ell \mid T}) \end{align}\] so that \[\begin{align} \widehat{y}_{T+\ell \mid T}&=\widehat{g}_{T+\ell \mid T}\widehat{\mu } _{T+\ell -1\mid T} = Y_T \exp\hat\delta_{T+\ell|T} \prod_{j=1}^{\ell-1}(1+\exp \hat\delta_{T+j|T}) & (5) \end{align}\] and \(\widehat{Y}_{T+\ell \mid T}=\widehat{\mu }_{T+\ell \mid T};\) the initial value is \(\widehat{\mu }_{T\mid T}=Y_{T}.\)
We construct state-implied forecast bands for \(y_t\) by propagating the predictive distribution of the latent state vector. In the one-step-ahead case the implied distribution for \(y_{T+\ell}\) is lognormal, but beyond one step the cumulative structure of the model means that the forecast path is path-dependent. The bands should therefore be interpreted as state-implied forecast bands rather than as calibrated prediction or confidence intervals.2
The filtered growth rate \(\hat{g}_{y,t\mid t}\) of new cases \(y_{t}\), can be extracted from the continuous-time incidence curve: \(\mu^{\prime}(t) = g(t) \mu(t)\), where \(\mu(t)\) is the growth curve and \(g(t)\) is its growth rate. Taking logarithms and differentiating we get
\[\begin{align} \hat{g}_{y,t\mid t}&=\hat{g}_{t\mid t}+\hat\gamma_{t\mid t}, & (6) \end{align}\]
where \(\hat{g}_{t\mid t}=\exp \hat\delta_{t\mid t}.\) The sampling variability of \(\hat{g}_{t\mid t}\) is dominated by that of \(\hat\gamma_{t\mid t}\) (see Harvey and Kattuman (2021)). Therefore when constructing confidence intervals for \(\hat{g}_{t\mid t}\) we treat \(\hat{g}_{y,t}\) as if it has a normal distribution centered on \(\hat{g}_{y,t\mid t}\) with variance \(\text{Var}(\hat\gamma_{t\mid t})\).
Even when the nowcast \(\hat{g}_{y,T\mid T}\) is positive and daily cases are growing, there will be a saturation level for the cumulative total, \(Y_{t},\) so long as \(\hat\gamma_{T\mid T}\) is negative. The nowcasts of \(y_{t}\) peak when \(\hat{g}_{y,t\mid t}=0,\) which requires \(\hat\gamma_{t\mid t}\) to be sufficiently negative to outweigh \(\hat{g}_{t\mid t},\) which is, of course, always positive. This can be seen from the expression for the growth rate of daily cases: \[\begin{align} \hat{g}_{y,T\mid T}&=\exp \hat\delta_{T\mid T}+\hat\gamma_{T\mid T}=\hat{g}_{T\mid T}+\hat\gamma_{T\mid T}. & (7) \end{align}\]
When \(\hat\gamma_{T\mid T}\) is negative, there is a flattening of the curve and a signaling of an upcoming peak in the trend of \(y_{t}.\) As shown in [HK, p10], the peak in the trend is predicted to be \(\ell _ {T}\) days ahead where3
\[\ell_{T}=\frac{\ln (-\hat\gamma_{T\mid T})-\hat\delta_{T\mid T}}{\hat\gamma_{T\mid T}}=\frac{\ln (-\hat\gamma_{T\mid T}/\hat{g}_{T\mid T})}{\hat\gamma_{T\mid T}},\;\;\;\; -\hat{g}_{T\mid T}<\hat\gamma_{T\mid T}<0. \]
The generation of forecasts is demonstrated in Section 4.
The path of the epidemic is best tracked by nowcasts and forecasts of \(g_{y,t}\), the growth rate of \(y_t\), which are constructed by HK from the filtered estimates in the state space model, (2), (3) and (4). Wallinga and Lipsitch (2007) describe how the estimates of \(g_{y,t}\) can be translated into estimates of the instantaneous reproduction number \(R_{t}.\) Harvey and Kattuman (2021) propose \[\begin{align} \widetilde{R}_{t,\tau}&=1+\tau g_{y,t\mid t}\;\;\;\text{or}\;\;\;\widetilde{R}_{\tau,t}^{e} =\exp(\tau g_{y,t\mid t}),\;\;\; & (8) \end{align}\] where \(\tau\) is the generation interval – the typical number of days between an infected person becoming infected and them transmitting the disease to someone else. We construct approximate pointwise uncertainty bands for \(\widetilde{R}_{t,\tau}\) and \(\widetilde{R}_{\tau,t}^{e}\) by substituting the upper and lower bounds of the pointwise uncertainty bands for \(g_{y,t}\) into (8). These are approximate bands for the latent reproduction-number summaries implied by the fitted state-space model, conditional on the chosen generation interval \(\tau\); they are not Bayesian credible intervals. See Harvey et al. (2021) for an application.
The estimates of \(R_{t}\) can be used for tracking and forecasting the epidemic. The nowcasts of \(y_{t}\) peak when \(\hat{g}_{y,t\mid t}=0\), corresponding to \(\widetilde{R}_{t,\tau}=\widetilde{R}_{\tau,t}^{e}=1.\) Based on (7), predictions of \(g_{y,t}\) are given by
\[\begin{align} \hat{g}_{y,T+\ell\mid T} & =\exp\hat\delta_{T+\ell\mid T}+\hat\gamma_{T+\ell\mid T}=\exp(\hat\delta_{T\mid T}+\hat\gamma_{T\mid T}\ell)+\hat\gamma_{T\mid T},\;\;\;\;\;\ell=1,2,... & (9) \end{align}\]
We can then obtain predictions of \(R_{t},\) as in (8). If \(\hat\gamma_{T\mid T}\) is zero, the estimated growth of \(y_{t}\) is exponential and it is helpful to characterize it by the doubling time, \(\ln2/\hat{g}_{y,T\mid T} =0.693\exp(-\hat\delta_{T\mid T}).\)
When \(\exp \hat\delta _{T\mid T}+\hat\gamma _{T\mid T}>0\), the nowcast \(\hat{g}_{y,T\mid T}\) is positive and the estimate of \(R_{t}\) given by (8) is greater than one. So long as \(\hat\gamma _{T\mid T}\) is negative, then as \(T\rightarrow \infty ,\) \(\widetilde{R}_{\tau ,T+\ell \mid T}^{e}\rightarrow \exp (\tau \hat\gamma _{T\mid T})<1,\) and a saturation level for \(Y\) appears on the horizon.
We now turn to case where \(\gamma _{t}\) potentially turns positive in a typically short-lived phase, as a new wave emerges.
The coronavirus pandemic was characterized by multiple waves punctuated by plateaus. At the beginning of a new wave the growth rate of daily cases, \(g_{y,{t}}\), turns positive. The initial surge may be explosive to the point where the growth is super exponential. In this case, \(\gamma _{t}\), the growth rate of \(g_t\) (which is the growth rate of cumulative cases) can also turn positive, with no peak in prospect for \(y_{t}.\) Such a phase can be expected to be transient, with \(\gamma _{t}\) dropping back to zero (exponential growth in infection, accompanied by an upcoming peak in \(y_{t}\)), and then falling below zero (sub-exponential growth in infection).
From the point-of-view of forecasting an epidemic, a peak must be in prospect even if it can only be expected some way into the future. There is thus a need for a solution to the problem of the estimated \(\hat\gamma _{t\mid t}\) rising to positive values as it adapts to the upward surge in \(y_{t}\), and remaining positive for any protracted period. This upward shift in \(\hat\gamma _{t\mid t}\) can be averted by reinitializing the \(\ln g_{t}\) series at the start of a new wave. This involves setting the cumulative total of cases \(Y_t\) back to zero at, or around, the start of a new wave and setting \(\gamma_t\) to zero so as to impose exponential growth. From the point-of-view of the relationship \(g_{y_{t}} = g_{t} + \gamma _{t},\) the re-initialization effectively shifts surplus \(\gamma_t\) emanating from super-exponential growth, into \(\delta_t\) and therefore into \(g_t\) (since \(g_t = \exp \delta_t\)). Note that, on the date of the re-initialization, \(g_{y,t} = g_t\), since \(\gamma_t=0\), and both \(g_{y,t}\) and \(g_t\) will be high because a new wave is taking off.
In practice the reinitialization rule is implemented in two stages, following a real-time trigger condition. Let \(T\) denote the current forecast origin and let \(\mathcal{F}_T\) denote the information available up to and including date \(T\). Write \(P_{\gamma,T\mid T} = Var(\gamma_T \mid \mathcal{F}_T)\) for the relevant element of \(P_{T\mid T}\), so that \(\sqrt{P_{\gamma,T\mid T}}\) is the conditional standard deviation of the latent slope \(\gamma_T\) given the information available at \(T\). Define the standardised signal \[z_{\gamma,T} = \frac{\hat\gamma_{T\mid T}}{\sqrt{P_{\gamma,T\mid T}}}.\] The reinitialization warning is triggered when \[z_{\gamma,T} > c_{reinit}, \qquad c_{reinit} = 2,\] that is, when \(\hat\gamma_{T\mid T}\) exceeds zero by approximately two conditional standard deviations. This threshold is an operational rule of thumb rather than a fixed statistical decision rule, and can be adjusted depending on the noisiness of the data and the desired trade-off between early warning and false reinitialization.
Let \(d\) denote the first forecast origin at which this trigger condition holds. The reset date \(r\) is then defined as the start of the current positive spell in the smoothed slope estimate computed on the sample ending at \(d\): \[r = \min\{t \le d : \hat\gamma^S_{u\mid d} > 0 \text{ for all } u=t,\ldots,d\},\] where \(\hat\gamma^S_{u\mid d}\) denotes the smoothed estimate of the slope at date \(u\), using observations only up to and including date \(d\). Thus the trigger date \(d\) and the reset date \(r\) are distinct: \(d\) is the date on which the warning first fires in real time, whereas \(r\) is the earlier date at which the cumulative series is reinitialized. In the notation below, \(t=r\) denotes the reset date and \(r_0\) the date at which the cumulative series is set to 0. Then:
\[\begin{align} \ln g_t &= \ln y_t - \ln Y_{t-1} & t=1, \ldots, r & \notag \\ \ln g_t^r &= \ln y_t - \ln Y_{t-1}^r & t=r+1, \ldots, T & (10)\\ Y_{t}^{r}&=Y_{t-1}^{r}+y_{t} & t=r,\ldots,T & (11) \end{align}\]
where \(Y_{t}^{r}\) denotes the cumulative cases after re-initialization. We set \(Y_{r-1}^{r}=0\), so that the growth rate of cumulative cases is available from \(t=r+1\) onwards. Note that \(Y_{t}^r = Y_{t} - Y_{r_0}\).
The gap between the two series becomes apparent by writing
\[\begin{equation} \ln g_t^r = \ln g_t + \ln \frac{Y_{t-1}}{Y_{t-1}^r} = \ln g_t + \ln \frac{Y_{t-1}}{Y_{t-1} - Y_{r_0}} \;\;\;\; t=r+1, \ldots, T \;\;\;\;\;\;\;\; (12) \end{equation}\]
In the next section, where we illustrate the working of the program, it can be seen that in contrast to the original \(\ln g_t\) series, which continues to increase, the reinitialized \(\ln g_t\) series begins to decrease from the reinitialization date. The reinitialization enforces the canonical Gompertz curve with the log of growth rate of cumulative cases sloping down.
We reinitialize the model by specifying the appropriate prior distribution for the initial states: \(\alpha^r_1 \sim N(a^r_1,P^r_1)\) with \(a_1^r = (a_{\delta,1}^r,a_{\gamma,1}^r)'\) and \(P_1^r\) defined as follows. Let \(\mathcal{F}_t = \ln g_t, \ln g_{t-1}, \ldots, \ln g_1\) and define \(a_t = E(\alpha_t|\mathcal{F}_{t-1})\), \(a_{t|t} = E(\alpha_t|\mathcal{F}_t)\), \(P_t = Var(\alpha_t|\mathcal{F}_{t-1})\), \(P_{t|t} = Var(\alpha_t|\mathcal{F}_t).\) Then,
\[\begin{align*} a^r_{\delta,1} &= a_{\delta,r+1} + \ln(Y_r/y_r) \\ a^r_{\gamma,1} &= 0\\ P^r_1 &= P_{r+1}, \end{align*}\]
where \(a_{\delta,r+1}\) and \(P_{r+1}\) are obtained from the non-reinitialized model estimated over \(t=1, \ldots, r\) via the usual Kalman filter recursions. Adding \(\ln(Y_r/y_r)\) to \(a_{\delta,r+1}\) corrects for the shift down in the log cumulative cases caused by reinitializing the cumulative case series. Setting \(a^r_{\gamma,1} = 0\) ensures the model starts off with exponential, rather than super-exponential, growth.
We reinitialize the model through the priors in this way rather than simply re-estimating the model from scratch for two reasons. First, it allows us to impose a proper (rather than diffuse) prior centered on zero for \(\gamma\), so that the starting point is exponential growth. Second, it enables us to make use of data from before the reinitialization date. One needs a reasonable sample size for the estimated model and forecasts to be reliable, but if a new wave is taking off, forecasts need to be generated quickly. This was particularly true with the emergence of the Omicron variant, which caused an explosive increase in infection over a short period of time.
We do not re-estimate the \(\sigma^2_{\varepsilon}\) or \(\sigma^2_{\zeta}\) parameter in the reinitialized model. Rather, we use the values estimated in the original model over \(t=1, \ldots, r\). The one-step-ahead prediction error at \(t=r\) is the same in both the initialized and reinitialized models, but after \(t=r\), the prediction errors diverge.
The reinitialization procedure is very similar in the case where we have seasonal terms. If we let \(\alpha_{s,t}\) be the vector of seasonal states and maintain an analogous notation to that above, the prior mean of the seasonal components in the reinitialized model is \[a^r_{s,1} = a_{s,r+1}.\] The prior variance of \(\alpha^r_1\) remains \(P_{r+1}\) where \(P_{r+1}\) is appropriately re-defined to include the seasonal term, as described in the Appendix.
tsgcThe main Gompertz class in tsgc is
SSModelDynamicGompertz, which implements the models
described in (2)-(4), with or without reinitialization. A model object
is constructed with SSModelDynamicGompertz$new(...) or
tsgc::SSModelDynamicGompertz(...) and its unknown
parameters are estimated with the estimate() method,
invoked as estimate(model), which returns an object of the
FilterResults class. This class wraps the underlying
KFAS KFS object, together with additional
methods for prediction, and an optional idx_calendar (see
below) attached.
The SSModelDynamicGompertz needs only a cumulative
series Y as an input, supplied as an
idx_series object – an integer-position representation of a
time series, consisting of a vector or matrix of values together with a
start position. In our application, Y is the
cumulative number of new coronavirus cases. The estimation window is
specified directly on the model object via the start and
end arguments, which are integer positions in
Y rather than calendar dates. There is an option to specify
the signal-to-noise ratio \(q_{\zeta}\), rather than estimate it, and
an option to specify the model to have a seasonal component through the
sea.period argument. The period of a seasonal component is
specified through the sea.period option.
idx_series positions carry no calendar meaning of their
own: they are plain integers, and the model, its estimation, and its
forecasts are defined purely in terms of them. Real calendar time is
reintroduced only as an optional, purely cosmetic layer for plotting,
via an idx_calendar object passed through the
calendar argument. An idx_calendar records an
anchor position and the corresponding calendar date (or other real-world
reference point), together with the step size and unit between
consecutive positions; it is never consulted during estimation or
filtering. Supplying a calendar therefore changes nothing
about the fitted model – only whether its plots are labelled with dates
or with raw integer positions. Two helper functions,
xts_to_idx() and idx_to_pos(), are provided to
ease the transition from calendar-time data: the former converts an
xts/Date-indexed series into an
idx_series plus a matching idx_calendar in one
step, and the latter translates a calendar date back into the integer
position idx_series expects for
start/end/reinit.idx. Both are
demonstrated in the Illustration section below. Every calendar used in
this vignette has a step size that is a single, uniform
amount/unit pair; idx_calendar
also supports compound and heterogeneous step sizes for series that need
them, described in the Appendices “Compound calendar steps” and
“Heterogeneous repeating cycles” below.
The SSModelDynamicGompertz class allows the model to be
estimated for a new wave without losing information from prior waves. It
will accept the reinitialization position specified by
reinit.idx (an integer position) or a
FilterResults object from which it can extract the initial
values. If the user wishes to reinitialize the model without using prior
information (i.e. treat the new wave as an entirely separate epidemic),
a reinitialization position can be specified through the
reinit.idx option and use.presample.info can
be set to FALSE.
The FilterResults class contains prediction methods
which can be applied to estimated dynamic Gompertz curve models (both
reinitialized and non-reinitialized). get_growth_y()
returns filtered or smoothed estimates of the growth rate of new cases
(\(g_t\)), while
get_gy_ci() returns the same with uncertainty bands.
Forecasts of the incidence variable (new cases, \(y_t\)) can be obtained with the
predict_level() call, and forecasts of all the states can
be obtained with the predict_all() call.
Several functions are available to generate plots of smoothed and
filtered estimates and forecasts. plot_log_forecast() plots
the forecast and realised values of \(\ln(g_t)\).
plot_gy_components() and plot_gy_ci() can be
used to plot the smoothed or filtered growth rate, its components, and
uncertainty bands, respectively. Forecasts of the incidence variable
(\(y_t\)) and forecast intervals can be
plotted using plot_forecast(), while
plot_holdout() adds plots of prediction intervals and of
realized outcomes over a holdout period to help evaluate forecast
accuracy. plot_compare_forecast() overlays the forecasts
from a list of fitted models – for example a reinitialized and
non-reinitialized fit, or a dynamic Gompertz model against a
leading-indicator model – on the same axes for visual comparison.
Finally, the reinitialise_dataframe() function can be used
to reinitialise a data series at a given reinit.idx.
Every one of these plotting functions accepts an axis
argument, of class idx_axis_opts, which controls how
integer positions are translated to the x-axis and is described in full
in the Appendix “Controlling the plot x-axis” below. Left at its default
(NULL), the x-axis uses real calendar dates whenever a
suitable calendar is attached to the model or results
object, and plain integer positions otherwise.
More details on how to use the methods and functions described are presented in the following section.
The basic dynamic Gompertz model uses only the cumulative target series. A useful extension is the leading-indicator model, in which a second series is used to improve forecasts of an outcome of interest. The motivation is simple: many indicators move earlier than the target, so their information can be exploited to improve short-horizon forecasts. In the epidemic setting, for example, hospital admissions often respond with a lag to the underlying growth in cases, while in marketing applications early sales of related products may foreshadow demand for a new item. The package therefore allows the target series to be modeled jointly with a lagged auxiliary series rather than treating the target in isolation.
Let \(\ln g_{1,t-k}\) denote the log
growth rate of the leading series and \(\ln
g_{2,t}\) the log growth rate of the target series. The
leading-indicator specification in tsgc is
\[\begin{align} \ln g_{1,t-k} &= \delta_t + \psi_t + \varepsilon_{1t}, & t = k+1, \ldots, T+k, & (16)\\ \ln g_{2,t} &= \delta_t + \varepsilon_{2t}, & t = k+1, \ldots, T, & (17) \end{align}\]
where \(k\) is the user-specified
lag, \(\delta_t\) is the common trend
shared by both series and evolves exactly as in the dynamic Gompertz
model (equations (3)-(4)), \(\psi_t\)
is a target-independent deviation specific to the leading equation, and
\(\varepsilon_{1t}\) and \(\varepsilon_{2t}\) are mutually independent
Gaussian observation disturbances. The lag \(k\) is an alignment parameter: after
shifting the leading series by \(k\)
periods, the lead and target growth rates are treated as two noisy
measurements of a common latent growth component, rather than the
leading series entering as an exogenous regressor. Estimation is by
maximum likelihood using the Kalman filter, exactly as for the
univariate model, and the resulting object is a
FilterResultsLI object, which supports the same
forecasting, plotting, and diagnostic methods as the
FilterResults class used for the dynamic Gompertz
model.
The role of n.lag is therefore to align the information
in the leading series with the target series, in integer positions
rather than calendar time. If the indicator is known to anticipate the
target by several periods, a larger lag is appropriate. The package also
allows the user to supply exogenous covariates through
xpred_lead and xpred_targ at estimation time,
and their future values by setting the xpred_lead.new and
xpred_targ.new fields directly on the fitted
FilterResultsLI object before forecasting, making the model
suitable for settings where both the lead and target series are
influenced by observed external drivers.
The empirical benefit of this extension is that it can improve short-horizon predictions when the relationship between the two series is stable. In practice, one usually compares several lag values and evaluates them using a rolling-origin experiment, because the appropriate lead-lag relationship is seldom known in advance. Worked examples of the leading-indicator model, including the England cases-hospitalisations application, a comparison against the dynamic Gompertz model, lag selection by rolling cross-validation, and extensions to other data frequencies, are given in the Illustration section below.
Forecast accuracy can be evaluated with a variety of scale-dependent
and scale-free measures. In the notation of the package, the mean
absolute percentage error is \[\mathrm{MAPE}=\frac{100}{n}\sum_{t=1}^n
\left|\frac{y_t-\hat y_t}{y_t}\right|,\] while the symmetric
MAPE, as implemented in the package (scaled 0-100 rather than the more
common 0-200 convention that includes a factor of 2 in the numerator),
is defined as \[\mathrm{sMAPE}=\frac{100}{n}\sum_{t=1}^n
\frac{|y_t-\hat y_t|}{|y_t|+|\hat y_t|},\] and the mean absolute
error and root mean squared error are given by \[\mathrm{MAE}=\frac{1}{n}\sum_{t=1}^n |y_t-\hat
y_t|, \qquad \mathrm{RMSE}=\sqrt{\frac{1}{n}\sum_{t=1}^n (y_t-\hat
y_t)^2}.\] sMAPE is reported as the primary percentage-based
comparison measure in the empirical illustrations above, since MAPE can
become unstable when observed values are close to zero. The
rolling-origin procedure used by cross_val() re-estimates
the model at successive evaluation positions, holds back a fixed
forecast horizon, and computes the chosen loss metric over that
out-of-sample window, so that forecast accuracy is assessed under a
design that matches the practical forecasting problem.
tsgc packageIn this section we provide a full working example of the
tsgc package in R which implements the
modeling framework for time series growth curves-based epidemic
forecasting.
tsgc comes with two example data sets relating to
COVID-19: one for Gauteng province in South Africa (sourced from South
Africa’s official coronavirus online news and information portal) and another for England
(sourced from the official UK government dashboard for data and
insights on coronavirus). In the example that follows, we use the data
on confirmed cases in Gauteng. The data series is in cumulative form and
ships with the package as an xts object with a
Date index, as follows.
The modelling code works with idx_series
(integer-position data) rather than directly with xts
objects, so that estimation and forecasting are defined purely in terms
of position, independent of calendar frequency or gaps. Calendar time is
reintroduced only for plotting, via an optional
idx_calendar that records how integer positions map back to
real dates. xts_to_idx() performs this conversion in one
step for the common case of a series with no gaps, returning both the
idx_series and a matching idx_calendar;
idx_to_pos() is its companion for translating a calendar
date into the integer position that
start/end/reinit.idx expect. A
minimal example of converting a CSV-derived data frame to
this representation is given in the Appendix; the pattern below extends
directly to that case.
conv <- xts_to_idx(gauteng)
gauteng_idx <- conv$series
gauteng_cal <- conv$calendar
gauteng_cal$anchor_name <- "first recorded case"New COVID-19 cases reported for Gauteng province and their centered 7-day moving average presented in Figure 1 show a sequence of four waves over the period between 10 March 2020 and 5 January 2022.
mod_all <- SSModelDynamicGompertz(Y = gauteng_idx, calendar = gauteng_cal)
plot(mod_all, series.name = "daily cases", MA_period = 7)We begin by specifying a number of options for the forecasting exercise, as defined below.
Y is the data, in the form of an
idx_series of cumulative confirmed cases. In this example
the object holding this series is called gauteng_idx, with
gauteng_cal the accompanying
idx_calendar.
estimation.pos.start is the integer position of the
first observation in the sample to be used for estimating the model,
obtained here from a calendar date via idx_to_pos(). By
default, if start is left unspecified when constructing the
model, it is the first position in Y.
estimation.pos.end is the integer position of the
last observation in the sample to be used for estimating the model,
again obtained via idx_to_pos(). By default, if
end is left unspecified, it is the last position in
Y.
n.forecasts is the number of days or periods for
which forecasts are to be made. E.g., if n.forecasts = 14,
forecasts will be generated for up to 14 positions following
estimation.pos.end.
q is the signal-to-noise ratio, which controls the
smoothness of the estimated trend. A lower value will lead to more
smoothness. By default, we use q = NULL, so that it is
estimated. Alternatively, we can set q. For example, in our
experience, q = 0.005 ensures a good balance between the
smoothness of the trend and the speed with which changes in estimates
respond to new observations.
confidence.level sets the coverage used to construct
the state-implied forecast bands for \(\ln(g_t)\), which are then used to generate
the forecast bands for the forecasts themselves. Here, we use 0.68,
corresponding to the probability that the forecast lies within one
standard deviation of the point forecast under the (approximating)
Normal distribution used to construct the bands – see the note on
interpretation in the Theory section above.
plt.length sets a truncation position to enhance the
clarity of plots, e.g. showing only the last 30 positions of the
estimation sample. The range for plotting can be set as
plt.length positions up to
estimation.pos.end.
In this example the data is the cumulative confirmed cases time
series for Gauteng. The start and end positions
(estimation.pos.start and estimation.pos.end)
that define the sample used for estimation are chosen as appropriate for
the exercise, translated from calendar dates via
idx_to_pos(). In this example, we begin with the sample
period set from 1 February to 3 May 2021. This marks the beginning of
the third wave in Gauteng as can be seen in Figure 1. The options are
specified as below.
We begin by selecting the data series (Y) for the
defined sample period.
The estimation period is specified directly when creating the
SSModelDynamicGompertz object using the start
and end arguments (integer positions in Y), so
no manual subsetting of the data is required. Passing
calendar = cal attaches the idx_calendar to
the resulting model and, in turn, to its FilterResults
output, so that later plots are automatically labelled with real dates
rather than raw positions.
We then estimate the model using a diffuse prior distribution for the initial state vector. The signal-to-noise ratio can be left as a free parameter to be estimated, as in the code below.
model_q <- SSModelDynamicGompertz(Y = Y, sea.period = 7,
start = estimation.pos.start,
end = estimation.pos.end,
calendar = cal)
res_q <- estimate(model_q)In the rest of this example we estimate the model setting the signal-to-noise ratio at \(0.005.\) As mentioned, in our experience this value strikes a useful balance between the smoothness of the estimate of the slope parameter \(\gamma\), and the speed with which it adapts to new observations.
Before looking at forecasts, it is worth checking that the model
actually fitted sensibly. summary() on a
FilterResults/ FilterResultsLI object reports
the estimation window, model states and their standard errors, and the
estimated variance parameters (including the signal-to-noise ratio);
tsgc::print_model_diagnostics() supplements this with the
fitted log-likelihood and simple recursive-residual diagnostics.
## Summary of FilterResults Object
## Model Details:
## - Estimation start position: 329
## - Estimation end position: 420
## - Model States and Standard Errors
## Smoothed values of states and standard errors at time n = 92:
## Estimate Std. Error
## level -6.854409 0.114903
## slope 0.059714 0.033423
## sea_trig1 -0.085890 0.030406
## sea_trig*1 0.466831 0.030605
## sea_trig2 0.206475 0.030267
## sea_trig*2 0.173606 0.030246
## sea_trig3 0.148638 0.030259
## sea_trig*3 0.000876 0.030200
##
## - Variance parameter estimates
## Observation equation noise: 0.04129
## State transition equation noise: 0.0002064
## Signal-to-Noise Ratio (q): 0.005
## Seasonality noise: 1.333e-07
## ---- Model diagnostics ----
## Log-likelihood: -15.6996
## Recursive residuals: mean = 0.1073 , sd = 1.179
## ----------------------------
We can now plot the forecast of \(\ln
g_t\) – the log of the growth rate of \(Y\), the cumulative cases – which is the
transformation of the series that is taken to the model, and we can
compare these forecasts to the actual \(\ln
g_t\) series. We do this by passing the output (res)
of the estimation step along with an evaluation sample to a plotting
function tsgc::plot_log_forecast.
tsgc::plot_log_forecast(
res = res,
Y = Y,
n.ahead = n.forecasts,
plt.start = tail(res$index, 1) - plt.length
)From these results we can recover the forecasts of new cases from 4 May 2021, with their forecast bands.
tsgc::plot_forecast(
res = res,
n.ahead = n.forecasts,
confidence.level = confidence.level,
plt.start = tail(res$index, 1) - plt.length,
series.name = "cases"
)To assess accuracy, we plot these forecasts against the actual new
cases, that have been held back from the estimation sample, using the
plot_holdout function. The model forecasts are compared
with the first differences of Y, the cumulative series,
over the forecast window.
# Update estimation end position
estimation.pos.end <- idx_to_pos(cal, "2021-04-19")
# Reestimate the model
model <- SSModelDynamicGompertz$new(Y = Y, q = q, sea.period = 7,
start = estimation.pos.start,
end = estimation.pos.end,
calendar = cal)
res_eval <- estimate(model)
tsgc::plot_holdout(
res = res_eval,
Y = Y,
n.ahead = 14,
confidence.level = 0.68,
series.name = "cases"
)Figure 4 shows that the forecasts were accurate over the first seven days, with a mean absolute percentage error (MAPE) of 13.9%. Note that reported cases were unusually low on 27 April due to the fact that it is Freedom Day and a public holiday in South Africa. That day aside, over the six days from 28 April the MAPE was 12.8%. Over the full 14 days of the forecasts, the MAPE was 27%.
As discussed earlier, the reproduction numbers \(R_t\) and their 68% approximate pointwise
uncertainty bands can be calculated using estimate_r0(). It
returns a data frame containing the integer position, fitted \(R_t\), and lower and upper bounds. When the
fitted result has a calendar, as here, the corresponding date is
included as well. The estimates can be plotted directly with
plot_r0(), as shown in Figure 5.
## Date Position fit lower upper
## 1 2021-04-27 414 0.8309205 0.7274680 0.9490848
## 2 2021-04-28 415 0.9449307 0.8272860 1.0793050
## 3 2021-04-29 416 1.0433477 0.9134504 1.1917171
## 4 2021-04-30 417 1.1241605 0.9842048 1.2840181
## 5 2021-05-01 418 1.1274117 0.9870550 1.2877267
## 6 2021-05-02 419 1.1937255 1.0451162 1.3634661
## 7 2021-05-03 420 1.2751620 1.1164161 1.4564804
plot_r0(res, gen_int = gen_int, n.ahead = 7,
title = "Reproduction numbers for the 7-day period to 3 May 2021")The dated data frame printed above also makes the \(R_t\) values and their confidence interval available directly, without having to read them from the plot.
A CSV file (y-forecast) is written to the directory
specified. The forecast options specified earlier are retained.
In all countries, the coronavirus pandemic was characterized by a series of recurring waves due to a combination of biological, behavioral, and environmental reasons. In an epidemic, the onset of a new wave is signalled when the slope parameter \(\gamma\), which measures of the growth rate of the growth rate of new cases, rises above zero for a sustained period. Such a super-exponential phase of the epidemic in which the growth rate of new cases is itself increasing over time is typically short.
This section illustrates the reinitialization procedure which allows us to apply the model to the new wave as it begins, without jettisoning information from the wave that has just ended. We extend the estimation window to 25 June 2021, by which date the third wave is well on course with its peak within sight (see Figure 1). All other options remain the same.
As set out above, reinitialization is triggered at the first forecast origin \(T\) at which the standardised signal \(z_{\gamma,T} = \hat\gamma_{T\mid T}/\sqrt{P_{\gamma,T\mid T}}\) exceeds the threshold \(c_{reinit}=2\), that is, when the estimate of the slope parameter, \(\gamma_t\), breaches two standard errors above zero. Reinitialization is then backdated to \(r\), the start of the current positive spell in the smoothed slope estimate computed on the sample ending at the trigger position. In applying this rule there is a choice between the filtered slope estimate and the smoothed slope estimate. Experimental results suggest that the greater noise in the filtered estimate of \(\gamma_t\) often triggers reinitialization too early. The smoothed estimate is more reliable, and is what we use to compute \(z_{\gamma,T}\) below.
Figure 6 shows that for the third wave in Gauteng, the smoothed slope estimate exceeded twice its standard error on 1 May 2021, having risen above zero on 21 April 2021, giving trigger date \(d =\) 1 May 2021 and reset date \(r =\) 21 April 2021.
# Re-estimate model
model <- SSModelDynamicGompertz$new(Y = Y, q = q, sea.period = 7,
start = estimation.pos.start,
end = estimation.pos.end,
calendar = cal)
res <- estimate(model)
# Extract the smoothed slope and its standard deviation as idx_series,
# then convert to a data.frame with real dates for plotting.
smoothed.slope.full <- idx_series(res$output$alphahat[, "slope"], start = res$index[1])
V.smoothed <- get_V(res$output)
i.slope <- grep("slope", colnames(res$output$alphahat))
smoothed.P.slope <- idx_series(V.smoothed[i.slope, i.slope, ], start = res$index[1])
common_pos <- idx_positions(smoothed.slope.full)
d2 <- idx_series(
cbind(
smoothed.slope = idx_values(smoothed.slope.full),
sd.smoothed.slope = sqrt(idx_values(smoothed.P.slope)),
sd.smoothed.slope.1.5 = 1.5 * sqrt(idx_values(smoothed.P.slope)),
sd.smoothed.slope.2 = 2 * sqrt(idx_values(smoothed.P.slope))
),
start = common_pos[1]
)
d2.mat <- as.matrix(idx_values(d2))
d2.df <- data.frame(
Date = idx_to_date(cal, idx_positions(d2)),
smoothed.slope = d2.mat[, "smoothed.slope"],
sd.smoothed.slope = d2.mat[, "sd.smoothed.slope"],
sd.smoothed.slope.1.5 = d2.mat[, "sd.smoothed.slope.1.5"],
sd.smoothed.slope.2 = d2.mat[, "sd.smoothed.slope.2"]
)
d2.df <- d2.df[d2.df$Date >= as.Date("2021-02-15"), ]
# zt = smoothed slope minus its own two-standard-error threshold (the
# lower bound of the two-SE interval).
d2.df$zt <- d2.df$smoothed.slope - d2.df$sd.smoothed.slope.2
trigger.df <- d2.df %>%
mutate(prev_zt = lag(zt)) %>%
filter(zt > 0 & prev_zt <= 0)
# Triggered when the two-SE lower bound of the smoothed slope crosses zero
reinit_zero.df <- d2.df %>%
mutate(prev_smoothed.slope = dplyr::lag(smoothed.slope)) %>%
filter(Date < min(trigger.df$Date) &
(smoothed.slope > 0 & prev_smoothed.slope < 0)) %>%
arrange(desc(Date)) %>%
slice(1)
# Reinitialisation on April 21.
# Create plot
ggplot(data = d2.df,
aes(x = Date)) +
geom_line(aes(y = smoothed.slope,
color = "smoothed.slope"), linewidth=.5) +
geom_line(aes(y = sd.smoothed.slope,
color = "sd.smoothed.slope"),
linetype = "solid",
linewidth=.25) +
geom_line(aes(y = sd.smoothed.slope.1.5,
color = "sd.smoothed.slope.1.5"),
linetype = "solid",
linewidth=.25) +
geom_line(aes(y = sd.smoothed.slope.2,
color = "sd.smoothed.slope.2"),
linetype = "solid",
linewidth=.5) +
scale_y_continuous(n.breaks = 10) +
geom_hline(yintercept = 0,
linetype = "solid",
color = "black",linewidth = 1)+
geom_vline(data = trigger.df,
aes(xintercept = Date),
linetype = "solid", linewidth = .5,
color = "black")+
ylab("Slope") +
scale_x_date(date_breaks = "10 days") +
scale_color_manual(name='',
values=c('smoothed.slope'='red',
'sd.smoothed.slope'='blue',
'sd.smoothed.slope.1.5'='green',
'sd.smoothed.slope.2'='black'))+
theme_light(base_size = 11)+
#theme(legend.position = c(.88, 0.25))+
theme(legend.title=element_text(size=2),
legend.text=element_text(size=6))+
theme(axis.text.x = element_text(angle = 45, hjust = 1, size = 8),
plot.title = element_text(face = "bold"))The position for reinitialization is set accordingly, translating the
reset date to an integer position via idx_to_pos().
A reinitialized SSModelDynamicGompertz uses the same
arguments as the non-reinitialized model, with the addition of the
reinit.idx argument, an integer position.
model <- SSModelDynamicGompertz$new(
Y = Y, q = q, sea.period = 7,
start = estimation.pos.start,
end = estimation.pos.end,
reinit.idx = reinit.pos,
calendar = cal
)
res.reinit <- estimate(model)We generate the reinitialized data series by setting cumulative cases to \(0\) at the appropriate point, as discussed in the Theory section, and extract the evaluation sample from the reinitialized series as below.
Estimating the model with the reinitialized series, the actual and forecast \(\ln(g_t)\) can be plotted as in Figure 7, which is analogous to Figure 2 for the non-reinitialized series.
tsgc::plot_log_forecast(
res = res.reinit,
Y = Y,
n.ahead = n.forecasts,
plt.start = tail(res.reinit$index, 1) - plt.length,
title='Forecast of log growth rate after reinitialization.'
)Comparing forecasts, without reinitialization the 14-day forecast MAPE is 41.9% (see Figure 8). With reinitialization, it falls to 20.2% (see Figure 9). The corresponding MAPE figures for 7-day forecasts is 15.2% without reinitialization and 9.5% with reinitialization.
tsgc::plot_holdout(
res = res,
Y = Y,
n.ahead = 14,
confidence.level = 0.68,
series.name = "cases"
)tsgc::plot_holdout(
res = res.reinit,
Y = Y,
n.ahead = 14,
confidence.level = 0.68,
series.name = "cases"
)We can also compare the standard (no reinitialization) and reinitalized forecasts in a single plot:
Just as for the standard (non-reinitialized) model, the returned
estimation results contained in res.reinit are a
FilterResults object, and can be written to CSV using the
write_results function.
We now illustrate the leading-indicator model described above. We
load the England data, which contains daily cumulative COVID-19 cases
and hospital admissions, convert it to
idx_series/idx_calendar form, and specify an
estimation window running from 30 April 2021 to 24 July 2021, with cases
as a four-day leading indicator for hospitalisations.
data(england, package = "tsgc")
conv <- xts_to_idx(england[, 1:2])
eng <- conv$series
eng_cal <- conv$calendar
est.start.eng <- idx_to_pos(eng_cal, "2021-04-30")
est.end.eng <- idx_to_pos(eng_cal, "2021-07-24")
n.lag.eng <- 4
n.forecasts.eng <- 7
plt.length.eng <- 14A preliminary plot of the two series on the log scale is useful for inspecting comovement before formal estimation.
mod.eng.plot <- SSModelLeadingIndicator$new(eng, n.lag = n.lag.eng, calendar = eng_cal)
plot(
mod.eng.plot,
title = "Daily COVID cases and hospitalisations (England)",
series.name.lead = "Cases",
series.name.target = "Hospitalisations",
take.log = TRUE
)We then estimate the baseline leading-indicator model, with cases as
the lead series (LeadIndCol = 1) and weekly
seasonality.
mod.eng <- SSModelLeadingIndicator$new(
Y = eng,
n.lag = n.lag.eng,
LeadIndCol = 1,
sea.period = 7,
start = est.start.eng,
end = est.end.eng,
calendar = eng_cal
)
res.eng <- estimate(mod.eng)FilterResultsLI’s summary() prints the same
kind of diagnostics as FilterResults$summary() above, but
its observation-equation noise H is a \(2\times2\) matrix (one variance per
equation, for the lead and target series) rather than a single scalar;
it is printed as a matrix accordingly.
## Summary of FilterResultsLI Object
## Model Details:
## - Estimation start position: 407
## - Estimation end position: 492
## - Model States and Standard Errors
## Smoothed values of states and standard errors at time n = 86:
## Estimate Std. Error
## level -6.227326 0.039235
## slope 0.072775 0.184027
## level.LDLlead 1.323149 0.068104
## sea_trig1.LDLlead 0.161333 0.041468
## sea_trig*1.LDLlead 0.103834 0.041468
## sea_trig2.LDLlead -0.019902 0.019065
## sea_trig*2.LDLlead -0.074915 0.019092
## sea_trig3.LDLlead -0.039680 0.016282
## sea_trig*3.LDLlead 0.008766 0.016482
## sea_trig1.LDLtarg -0.093289 0.036842
## sea_trig*1.LDLtarg 0.004641 0.036842
## sea_trig2.LDLtarg -0.019120 0.011347
## sea_trig*2.LDLtarg 0.009455 0.011347
## sea_trig3.LDLtarg -0.006348 0.007297
## sea_trig*3.LDLtarg -0.010262 0.007297
##
## - Variance parameter estimates
## Observation equation noise:
## [,1] [,2]
## [1,] 0.0077 0
## [2,] 0.0000 0
## State transition equation noise: 0.03233
## Seasonality noise: 0.005768
## ---- Model diagnostics ----
## Log-likelihood: 42.3087
## Recursive residuals: mean = -0.03584 , sd = 1.236
## ----------------------------
The fitted object res.eng can be used to generate the
same forecast and holdout panels as for the dynamic Gompertz model.
tsgc::plot_log_forecast(
res.eng,
Y = eng,
n.ahead = n.forecasts.eng,
plt.start = est.end.eng - plt.length.eng,
title = "Forecast of log growth rate of hospitalisations (England)"
)tsgc::plot_forecast(
res.eng,
n.ahead = n.forecasts.eng,
plt.start = est.end.eng - plt.length.eng,
series.name = "Hospitalisations",
title = "Forecast of hospitalisations (England)"
)tsgc::plot_holdout(
res.eng,
Y = eng,
n.ahead = n.forecasts.eng,
series.name = "Hospitalisations",
title = "Accuracy: forecast of hospitalisations (England)"
)The same specification can be enriched with exogenous predictors. Here we add contemporaneous weather variables (wind speed, relative humidity, temperature, and maximum temperature) to both the lead and target equations, and then supply their future values before forecasting.
data(england_weather_2021, package = "tsgc")
conv <- xts_to_idx(england_weather_2021[, 1:4],
start.pos = idx_to_pos(eng_cal, zoo::index(england_weather_2021)[1]))
england_weather_idx <- conv$series
xpred_lead <- xpred_targ <- england_weather_idx
mod.eng.x <- SSModelLeadingIndicator$new(
eng,
n.lag = n.lag.eng,
xpred_lead = xpred_lead,
xpred_targ = xpred_targ,
sea.period = 7,
start = est.start.eng,
end = est.end.eng,
calendar = eng_cal
)
res.eng.x <- estimate(mod.eng.x)
res.eng.x$xpred_lead.new <- england_weather_idx
res.eng.x$xpred_targ.new <- england_weather_idxtsgc::plot_forecast(
res.eng.x,
n.ahead = n.forecasts.eng,
plt.start = est.end.eng - plt.length.eng,
title = "Forecast of hospitalisations\nwith regressors (weather, oracle/realised)",
series.name = "Hospitalisations"
)In practice, the recommended sequence is to establish a sensible lag and seasonal specification first, inspect the resulting forecasts, and only then augment the model with regressors if additional covariates are available and future paths can be supplied.
We now compare a UK-only dynamic Gompertz model with a leading-indicator model that uses Italian case growth as a lead for UK case growth, using daily COVID-19 cases in Italy and the UK from the first-wave period. Italy experienced the first large European outbreak earlier than the UK, making it a natural candidate for a short-horizon lead series during the first wave.
data(ukitaly, package = "tsgc")
conv <- xts_to_idx(ukitaly)
ukitaly_idx <- conv$series
ukitaly_cal <- conv$calendar
n.forecasts.uk <- 14
plt.length.uk <- 30
q.uk <- 0.005
est.start.uk <- idx_to_pos(ukitaly_cal, "2020-02-25")
est.end.uk <- idx_to_pos(ukitaly_cal, "2020-04-01")
Yuk <- idx_series(idx_values(ukitaly_idx)[, "UK"], start = ukitaly_idx$start)mod.ukit.plot <- SSModelLeadingIndicator$new(ukitaly_idx, n.lag = 4, calendar = ukitaly_cal)
plot(
mod.ukit.plot,
title = "Daily COVID cases in UK and Italy",
series.name.lead = "Italy",
series.name.target = "UK",
take.log = FALSE
)The UK-only dynamic Gompertz model is estimated on UK daily cases
alone, and the leading-indicator model uses Italy as the lead series
with a 14-day lag, motivated by the observed timing of the first-wave
evolution in Italy relative to the UK. Both models are estimated with
weekly seasonality (sea.period = 7, the package default for
daily data), specified explicitly here so that the two specifications
are directly comparable; a fuller factorial comparison across seasonal
and other tuning choices is beyond the scope of this vignette.
mod.uk.gomp <- SSModelDynamicGompertz$new(
Y = Yuk, q = q.uk, sea.period = 7,
start = est.start.uk, end = est.end.uk,
calendar = ukitaly_cal
)
res.uk.gomp <- estimate(mod.uk.gomp)n.lag.uk <- 14
mod.uk.lead <- SSModelLeadingIndicator$new(
Y = ukitaly_idx, n.lag = n.lag.uk, sea.period = 7,
start = est.start.uk, end = est.end.uk,
calendar = ukitaly_cal
)
res.uk.lead <- estimate(mod.uk.lead)tsgc::plot_compare_forecast(
list(res.uk.gomp, res.uk.lead),
actual = Yuk,
n.ahead = n.forecasts.uk
)tsgc::plot_holdout(
res.uk.lead, Y = ukitaly_idx, n.ahead = n.forecasts.uk,
title = "Accuracy: leading-indicator forecast of UK cases",
series.name = "UK cases"
)In this first-wave window, the leading-indicator model gives substantially smaller holdout errors than the UK-only dynamic Gompertz model.
The baseline lag \(k=14\) is
motivated by the observed first-wave timing. To avoid selecting the lag
purely by inspection, the package provides a rolling cross-validation
utility, cross_val(), which evaluates candidate
specifications over a rolling origin and reports a chosen accuracy
criterion. Because SSModelLeadingIndicator uses a single
alignment lag per fitted model, this should be read as model selection
over alternative alignments, not as estimation of a distributed-lag
response.
The following cross-validation loop tests lags up to \(k=21\). We must take care to set the estimation end date in the selection part to give us a sufficiently long window to estimate the models with longer lags in the cross-validation selection part. This estimation window ends later than the estimation window used for this data earlier in this vignette.
est.end.cv.uk <- idx_to_pos(ukitaly_cal, "2020-04-15")
cv_models <- list()
cv_models[["Naive_last_value"]] <- SSModelDynamicGompertz$new(
Y = Yuk, q = 0, sea.period = 7,
start = est.start.uk, end = est.end.cv.uk,
calendar = ukitaly_cal
)
cv_models[["RW_growth"]] <- SSModelDynamicGompertz$new(
Y = Yuk, q = q.uk, sea.period = 0,
start = est.start.uk, end = est.end.cv.uk,
calendar = ukitaly_cal
)
cv_models[["Vanilla_q"]] <- SSModelDynamicGompertz$new(
Y = Yuk, q = q.uk, sea.period = 7,
start = est.start.uk, end = est.end.cv.uk,
calendar = ukitaly_cal
)
cv_models[["Vanilla_ar1"]] <- SSModelDynamicGompertz$new(
Y = Yuk, sea.period = 7,
start = est.start.uk, end = est.end.cv.uk, ar1 = TRUE,
calendar = ukitaly_cal
)
for (i in c(7, 10, 14, 16, 18, 21)) {
cv_models[[paste0("Lag", i)]] <- SSModelLeadingIndicator$new(
Y = ukitaly_idx, sea.period = 7,
start = est.start.uk, end = est.end.cv.uk, n.lag = i,
calendar = ukitaly_cal
)
}cross_val() is run twice with disjoint sets of forecast
origins, spaced by the forecast horizon itself so evaluation windows do
not overlap: an earlier selection block used only to choose the lag, and
a later reporting block used only to score the selected lag against the
benchmarks. Using the same folds for both selection and reporting would
overstate the selected model’s accuracy.
n.ahead.cv <- 5
gap.cv <- n.ahead.cv # non-overlapping horizons
n.select.cv <- 2
n.report.cv <- 1
report.end.cv <- est.end.cv.uk - (n.report.cv - 1) * gap.cv - n.ahead.cv
# The last SELECTION origin is select.end.cv + (n.select.cv-1)*gap.cv,
# with its forecast horizon reaching + n.ahead.cv further. Requiring
# that to finish strictly before the REPORTING origin (report.end.cv)
# means subtracting n.select.cv * gap.cv.
select.end.cv <- report.end.cv - n.select.cv * gap.cv - n.ahead.cv
stopifnot(select.end.cv > est.start.uk)
# Explicit disjointness check: the last SELECTION fold's forecast
# horizon must end strictly before the REPORTING block's origin.
last_selection_origin <- select.end.cv + (n.select.cv - 1) * gap.cv
last_selection_horizon_end <- last_selection_origin + n.ahead.cv
stopifnot(last_selection_horizon_end < report.end.cv)
# Feasibility check for the highest lag tested: SSModelLeadingIndicator's
# estimate() needs at least n.lag + 2 usable positions from est.start.uk
# before the SELECTION block's origin (n.lag positions consumed by the
# forward lag-shift, plus at least 1-2 more lost to differencing/NA
# trimming).
max_lag_cv <- 21
stopifnot(select.end.cv - est.start.uk > max_lag_cv + 1)
cv_selection <- cross_val(
Y = ukitaly_idx,
model_list = cv_models,
est.end = select.end.cv,
criterion = "smape",
n.ahead = n.ahead.cv,
n.estimate = n.select.cv,
gap = gap.cv
)
cv_selection## Model 31 36
## 1 Naive_last_value 11.37 20.21
## 2 RW_growth 8.45 8.17
## 3 Vanilla_q 5.01 13.41
## 4 Vanilla_ar1 3.24 10.86
## 5 Lag7 9.79 6.24
## 6 Lag10 4.79 9.01
## 7 Lag14 31.58 4.51
## 8 Lag16 3.90 4.30
## 9 Lag18 3.59 7.71
## 10 Lag21 6.15 4.05
lag_rows <- cv_selection[grepl("^Lag", cv_selection$Model), ]
lag_rows$mean_smape <- rowMeans(lag_rows[, -1, drop = FALSE])
selected_lag_name <- lag_rows$Model[which.min(lag_rows$mean_smape)]
selected_lag_name## [1] "Lag16"
cv_report_models <- cv_models[c(
"Naive_last_value", "RW_growth", "Vanilla_q", "Vanilla_ar1", selected_lag_name
)]
cv_reporting <- cross_val(
Y = ukitaly_idx,
model_list = cv_report_models,
est.end = report.end.cv,
criterion = "smape",
n.ahead = n.ahead.cv,
n.estimate = n.report.cv,
gap = gap.cv
)
cv_reporting## Model 46
## 1 Naive_last_value 27.88
## 2 RW_growth 10.80
## 3 Vanilla_q 4.99
## 4 Vanilla_ar1 7.18
## 5 Lag16 2.16
For each candidate model, cross_val() evaluates
forecasting performance over a rolling-origin scheme, re-estimating the
model at each origin and recording the chosen accuracy criterion over
the forecast horizon. Naive_last_value and
RW_growth are naive benchmarks; the Vanilla
rows are UK-only dynamic Gompertz benchmarks (fixed \(q_\zeta = 0.005\) and the AR(1) extension
respectively); the remaining rows are Italy-to-UK leading-indicator
models at different lags. cv_reporting is the accuracy
figure that should be quoted for the selected lag, since it comes from
origins that played no role in choosing it; cv_selection
should not itself be read as an accuracy claim about the winning lag.
Note that this comparison still uses only a handful of origins from one
epidemic wave and realised (not archived) values throughout, so it
remains illustrative of the cross_val() API rather than a
general validation of any one lag choice.
The package is not restricted to daily epidemic data. The same
state-space machinery can be applied to quarterly, monthly, or annual
data when the underlying series is cumulative and exhibits saturation;
what changes with the sampling frequency is only the interpretation of
the integer position, forecast horizon, lag length, and seasonal period,
not the underlying estimation procedure. For example, the quarterly
Nintendo sales data can be used with a seasonal period of 4 and a lagged
leading indicator, while monthly trading-app downloads can be treated in
the same way with a seasonal period of 12. In both cases,
idx_calendar carries the quarterly/monthly step size and
unit, and idx_to_pos() still translates calendar dates to
the integer positions that
start/end/n.lag require.
data(nintendo_sales, package = "tsgc")
y_q_xts <- nintendo_sales[, c("wii", "switch_all")]
nintendo_idx <- idx_series(zoo::coredata(y_q_xts), start = 1L)
nintendo_cal <- idx_calendar(
anchor = as.Date(zoo::index(y_q_xts)[1]),
anchor_pos = 1L,
amount = 1, unit = "quarters",
posixct = TRUE
)
n.lag.q <- idx_to_pos(nintendo_cal, "2017-01-01") - idx_to_pos(nintendo_cal, "2006-10-01")
mod.q <- SSModelLeadingIndicator$new(
Y = nintendo_idx,
sea.period = 4,
n.lag = n.lag.q,
start = idx_to_pos(nintendo_cal, "2017-01-01"),
end = idx_to_pos(nintendo_cal, "2019-10-01"),
calendar = nintendo_cal
)
res.q <- estimate(mod.q)tsgc::plot_log_forecast(
res.q, Y = nintendo_idx, n.ahead = 8,
title = "Log forecasts of Switch sales"
)data(etrading_apps, package = "tsgc")
y_m_xts <- etrading_apps[, c("DEGIRO", "AvaTrade")]
etrading_idx <- idx_series(zoo::coredata(y_m_xts), start = 1L)
etrading_cal <- idx_calendar(
anchor = as.Date(zoo::index(y_m_xts)[1]),
anchor_pos = 1L,
amount = 1, unit = "months",
posixct = TRUE
)
n.lag.m <- idx_to_pos(etrading_cal, "2017-07-01") - idx_to_pos(etrading_cal, "2017-01-01")
mod.m <- SSModelLeadingIndicator$new(
Y = etrading_idx,
sea.period = 12,
n.lag = n.lag.m,
start = idx_to_pos(etrading_cal, "2017-07-01"),
end = idx_to_pos(etrading_cal, "2021-02-01"),
calendar = etrading_cal
)
res.m <- estimate(mod.m)tsgc::plot_forecast(
res.m, n.ahead = 4,
title = "AvaTrade monthly downloads",
series.name = "Downloads"
)Both examples use the same workflow as the daily illustrations above: select an estimation window, fit the model, choose a forecast horizon, and evaluate the resulting forecasts. No change to the state-space specification or estimation procedure is required when moving between frequencies.
The tsgc package is based on a dynamic Gompertz curve
model for the log of the growth rate of cumulative cases in an epidemic,
with seasonal terms that capture day-of-the-week effects. The estimation
is carried out using KFAS, a package for state-space
modeling in R.
The Kalman filter is used to estimate the state vector at each time point. The filter is initialized using a diffuse prior for the initial state vector. We allow the signal-to-noise ratio to either be estimated or fixed at some value based on experience and judgment. Future observations are forecast using predictive recursions.
Epidemics are often characterized by multiple waves. A natural problem in this context is that there is very little data pertinent to the new wave available in its initial stages. The package employs a reinitialization method using priors in a way that allows data from before the beginning of the new wave to be used in estimation. The same framework can be extended to a leading-indicator formulation for outcomes that are preceded by an observable correlate, and it can be applied to data measured at different frequencies once the relevant seasonal period and lag structure are chosen.
The package is demonstrated using COVID-19 data from South Africa and England, but it can be used to model and forecast any time series variable where a growth curve-like trajectory is expected. Examples might include sales of a new product, innovation adoption or website traffic. The approach is phenomenological rather than mechanistic: it is designed for short-horizon forecasting and exploratory analysis rather than for causal identification or structural epidemiological inference. The forecast bands are state-implied summaries of the latent-state uncertainty and should not be interpreted as calibrated prediction or confidence intervals in the usual sense. In addition, the implied lead–lag relationship between a leading indicator and a target series can break down over time, so the model should be monitored and re-estimated as conditions change.
We thank The Cambridge Centre for Health Leadership & Enterprise, University of Cambridge Judge Business School, and Public Health England/UK Health Security Agency for generous support. We are indebted to Thilo Klein and Stefan Scholtes for constructive comments. Andrew Harvey’s work was carried out as part of the University of Cambridge Keynes Fund project `Forecasting and Policy in Environmental Econometrics’.
When we add a seasonal term to the model, the observation equation in the dynamic Gompertz curve (2) becomes \[\ln g_{t}=\delta_{t}+\nu_t+\varepsilon_{t},\;\;\;\;\;\varepsilon_{t}\sim NID(0,\sigma_{\varepsilon }^{2}),\;\;\;\;\;t=s,...,T,\] where \(\nu_t\) is the seasonal component, \(\delta_t\) remains defined by (3), and \(\varepsilon_t\) remains the iid Normal disturbance.
There are two options for specifying the evolution of the seasonal component. We can either use a trigonometric seasonal or a dummy variable seasonal. In our application, we use a trigonometric seasonal, although the two specifications are closely related. Indeed, Proietti (2000) shows that, under certain conditions, the two approaches are equivalent.
In the trigonometric seasonal approach, the seasonal pattern is captured by a set of trigonometric terms at the seasonal frequencies \(\lambda_j = \frac{2 \pi j}{s}\) for \(j=1, \ldots s^*\), where \(s^*=\frac{s}{2}\) if \(s\), the periodicity of the seasonal effect, is even, and \(s^*=\frac{s-1}{2}\) if \(s\) is odd Durbin and Koopman (2012). Our applications use daily data and we set \(s=7\) to capture day-of-the-week effects.
Letting \(\nu_{j,t}\) be the effect of season \(j\) at time \(t\), the seasonal terms evolve according to
\[\begin{align} \nu_{t} &= \sum_{j=1}^{s^{*}} \nu_{j,t}, & (13) \end{align}\]
where \[\begin{align} \nu_{j,t} &= \nu_{j,t-1} \cos \lambda_j + \nu^*_{j,t-1} \sin \lambda_j + \omega_{j,t} & (14)\\ \nu^*_{j,t} &= -\nu_{j,t-1} \sin \lambda_j + \nu^*_{j,t-1} \cos \lambda_j + \omega^*_{j,t}, \;\;\;\;\; j = 1, \ldots, s^*, & (15) \end{align}\] and \(\omega_{j,t}\) and \(\omega^*_{j,t}\) are mutually independent, iid \(N(0,\sigma^2_{\omega})\) variables.
When reinitializing the model with seasonal terms, \(P^r_1\) is a block-diagonal matrix based on \(P_{r+1}\) which sets the covariances between \((\delta_t,\gamma_t)'\) and \((\nu_{1,t},\nu_{2,t},\ldots,\nu_{s^*,t})'\) to be zero. The covariance between \(\delta_t\) and \(\gamma_t\), as well as the covariances between \(\nu_{1,t},\nu_{2,t},\ldots,\nu_{s^*,t}\), are permitted to be non-zero and come directly from \(P_{r+1}\).
For models that include external regressors, forecasts are
conditional on future values of those regressors. If the future
regressor values originate in a CSV file with a Date
column, the same xts_to_idx() conversion used elsewhere in
this vignette applies: read the CSV, convert the date column, coerce to
xts, and then convert to idx_series using the
model’s existing calendar so that the resulting positions line up
correctly. The following example illustrates this using an inline text
block for reproducibility; in practice the data would normally be read
directly from a CSV file.
The examples below assume a Gompertz model has already been fit with
a weather regressor supplied via xpred, producing a
FilterResults object called res_weather. This
is fit explicitly here (using its own pinned estimation window, rather
than the ambient estimation.pos.end reassigned above), so
the CSV examples that follow are self-contained.
data(gauteng_weather_2021, package = "tsgc")
gauteng_weather_idx <- xts_to_idx(
gauteng_weather_2021[, c(1, 3)],
start.pos = idx_to_pos(cal, zoo::index(gauteng_weather_2021)[1])
)$series
weather.pos.start <- estimation.pos.start
weather.pos.end <- idx_to_pos(cal, "2021-05-03")
gauteng_weather_est <- get_timeframe(
gauteng_weather_idx, weather.pos.start, weather.pos.end
)
model_weather <- SSModelDynamicGompertz(
Y = Y, xpred = gauteng_weather_est, q = q,
start = weather.pos.start, end = weather.pos.end,
calendar = cal
)
res_weather <- estimate(model_weather)# This inline CSV block demonstrates the expected format of a file
# containing future values of the external regressors.
txt <- "
Date,windspd_mtrs_p_sec,temperature_C
2021-05-04,2.03,14.61
2021-05-05,1.54,15.51
2021-05-06,1.94,16.42
2021-05-07,2.38,15.54
2021-05-08,2.57,14.18
2021-05-09,2.65,13.55
2021-05-10,2.19,14.84
2021-05-11,2.08,15.55
2021-05-12,2.07,15.97
2021-05-13,1.92,15.86
2021-05-14,1.85,15.79
2021-05-15,2.86,15.57
2021-05-16,3.46,16.42
2021-05-17,2.39,13.53
"
# Read the demonstration CSV text into a data frame.
gauteng_weather_future_csv <- read.csv(
text = txt,
stringsAsFactors = FALSE
)
gauteng_weather_future_csv$Date <-
as.Date(gauteng_weather_future_csv$Date)
# Convert the data frame to xts first...
gauteng_weather_future_xts <- xts::xts(
gauteng_weather_future_csv[, -1],
order.by = gauteng_weather_future_csv$Date
)
# ...then to an idx_series, anchored on the fitted model's own calendar
# so that the resulting positions line up with res_weather$calendar.
gauteng_weather_future_idx <- xts_to_idx(
gauteng_weather_future_xts,
start.pos = idx_to_pos(res_weather$calendar,
zoo::index(gauteng_weather_future_xts)[1])
)$series
# Supply the future regressors directly on the fitted results object.
res_weather$xpred.new <- gauteng_weather_future_idxIn practice, users would normally read the future regressor values
directly from a CSV file rather than embedding the data in the code. The
CSV file should have the same structure as shown above, with a
Date column followed by one column for each external
regressor.
# Read future regressors from a CSV file.
gauteng_weather_future_csv <- read.csv(
"gauteng_weather_future.csv",
stringsAsFactors = FALSE
)
gauteng_weather_future_csv$Date <-
as.Date(gauteng_weather_future_csv$Date)
# Convert to xts, then to idx_series on the model's calendar.
gauteng_weather_future_xts <- xts::xts(
gauteng_weather_future_csv[, -1],
order.by = gauteng_weather_future_csv$Date
)
gauteng_weather_future_idx <- xts_to_idx(
gauteng_weather_future_xts,
start.pos = idx_to_pos(res_weather$calendar,
zoo::index(gauteng_weather_future_xts)[1])
)$series
# Supply the future regressors directly on the fitted results object.
res_weather$xpred.new <- gauteng_weather_future_idxOnce the future regressors have been supplied, subsequent forecasts from the fitted model are conditional on these external regressor values.
While this example is specifically for exogenous predictors supplied
to a SSModelDynamicGompertz fit, the same
xts_to_idx()/field-assignment pattern extends to
xpred_lead.new and xpred_targ.new on a
SSModelLeadingIndicator fit, as demonstrated for the
England cases-hospitalisations example above.
idx_axis_opts()Every plotting function in this section so far has left its
axis argument unset, which defaults to
mode = "auto": real calendar dates if the calendar has
posixct = TRUE (as in almost every example above),
otherwise plain integer positions. idx_axis_opts() lets you
override this per plot, and applies uniformly across every
plot.*/plot_* function in the package. To keep
the comparison clear, this section reuses a single plot and simply
varies axis.
# res/model/estimation.pos.end are re-used, generically-named objects
# earlier in this vignette.
res.axis.demo <- estimate(
SSModelDynamicGompertz$new(Y = Y, q = q, sea.period = 7,
start = estimation.pos.start,
end = idx_to_pos(cal, "2021-05-03"),
calendar = cal)
)
showcase_axis_plot <- function(axis = NULL) {
tsgc::plot_forecast(
res = res.axis.demo,
n.ahead = n.forecasts,
confidence.level = confidence.level,
plt.start = tail(res.axis.demo$index, 1) - plt.length,
series.name = "cases",
axis = axis
)
}mode = "date" (the default here, since
gauteng_cal$posixct = TRUE)Real calendar dates on the x-axis - this is what
axis = NULL already produced in Figure 3.
mode = "position"The raw integer idx_series position, ignoring the
calendar entirely.
mode = "steps"Steps from the calendar’s anchor position
(position - anchor_pos). Since gauteng_cal has
an anchor_name set ("first recorded case",
added earlier when the calendar was built), the axis label uses it
automatically.
mode = "time_since"The pattern-weighted calendar offset from the anchor, expressed in
calendar$units rather than a raw step count - here “days
since first recorded case”. For a daily series with no gaps this is
numerically identical to "steps", but it diverges for
series with an irregular pattern (e.g. business days) or a
non-day unit (e.g. quarters, months), and it also works for
posixct = TRUE calendars where it can be more readable than
a real date.
Setting info_box = TRUE (on any of the modes above)
appends a caption summarising the calendar: the anchor (and its name, if
set), the step size/unit, and the pattern. pattern_n
truncates a long pattern to its first n values; by default
the full pattern is shown.
If the info box’s pattern were long (e.g. a business-day calendar
with weekends skipped), pattern_n keeps the caption
readable:
idx_step() and
idx_calendar_step()Every calendar used elsewhere in this vignette is built with the
primary idx_calendar() constructor, whose step size is a
single amount/unit pair
(e.g. amount = 1, unit = "days", or
amount = 1, unit = "quarters"). This covers the common case
where the step between consecutive idx_series positions is
a whole multiple of one calendar unit. Some series, however, are
genuinely spaced by a combination of units that a single
amount/unit pair cannot express - for example,
a reporting system that timestamps quarterly figures a few seconds after
the quarter boundary, so that the step is “one quarter plus three
seconds”. idx_step() builds this kind of compound step, and
idx_calendar_step() is the idx_calendar
constructor that takes one.
## <idx_step> 1 quarters, 3 seconds
cal_step <- idx_calendar_step(
anchor = as.POSIXct("2024-01-01 00:00:03", tz = "UTC"),
step = qtr_step,
posixct = TRUE
)
idx_to_date(cal_step, 1:4)## [1] "2024-01-01 00:00:03 UTC" "2024-04-01 00:00:06 UTC"
## [3] "2024-07-01 00:00:09 UTC" "2024-10-01 00:00:12 UTC"
idx_to_pos() inverts this exactly as it does for the
primary constructor. A compound step has no single amount
to divide by, so the position is located by a binary search on the
number of steps from the anchor, as it is for
idx_calendar() calendars with numeric patterns:
## [1] 4
Any component of an idx_step (years,
quarters, months, weeks,
days, hours, minutes,
seconds) left at its default of 0 is simply
omitted from the step, so idx_step(days = 1) is equivalent
to the amount = 1, unit = "days" pairs used throughout the
main text. idx_step_add() is the lower-level function that
applies a given number of whole steps to an anchor date directly, and is
what an idx_calendar_step()-built calendar uses internally
to convert positions to dates; it is not usually called directly by
users of the package; it is available for cases where a step needs to be
applied outside of an idx_calendar altogether.
multi_step_pattern() and
idx_calendar_multi_step()The pattern argument of
idx_calendar()/idx_calendar_step() handles a
repeating cycle of different multiples of one step - e.g. a
business-day calendar, where four single-day steps (Mon-Thu) are
followed by a three-day step over the weekend. That still assumes every
step in the cycle is the same kind of step, just scaled
differently. Some data instead cycle through genuinely different kinds
of steps - for example, “three days, three days, then one month,
repeating”. multi_step_pattern() describes this kind of
cycle as a sequence of idx_step objects, and
idx_calendar_multi_step() is the idx_calendar
constructor that uses it.
cal_multi <- idx_calendar_multi_step(
anchor = as.Date("2024-01-01"),
multi_step = multi_step_pattern(
idx_step(days = 3), idx_step(days = 3), idx_step(months = 1)
),
posixct = TRUE
)
idx_to_date(cal_multi, 1:6)## [1] "2024-01-01 UTC" "2024-01-04 UTC" "2024-01-07 UTC" "2024-02-07 UTC"
## [5] "2024-02-10 UTC" "2024-02-13 UTC"
Because the slots in a multi_step_pattern can mix
calendar-relative (e.g. months) and fixed-duration (e.g. days)
components, there is no closed-form way to collapse the whole cycle into
a single numeric offset. idx_to_date() instead walks one
slot at a time from the anchor position, and idx_to_pos()’s
inverse walks the same cycle in reverse:
## [1] 4
This makes idx_calendar_multi_step() calendars more
expensive to convert than the other two constructors - each position
takes time proportional to its distance from the anchor, rather than
constant time - so it is best reserved for genuinely irregular step
sequences that the pattern argument of the other two
constructors cannot express. The "time_since" axis mode
described in the previous appendix is also unavailable for this
calendar: a multi_step_pattern has no single
amount to express an offset in, so
mode = "steps" or mode = "date" should be used
instead.
idx_calendar anchors and
idx_offset_to_pos()Not every idx_series needs a genuine calendar
interpretation. An idx_calendar’s anchor can
be any single reference point, not just a
Date/POSIXct - for example, a plain numeric
count of picoseconds since the start of an experiment.
idx_offset_to_pos() is the inverse operation for this case:
given a value already expressed in the anchor’s own units, it returns
the integer idx_series position that corresponds to it -
the counterpart of idx_to_pos(), which instead expects a
calendar date.
cal_ps <- idx_calendar(anchor = 0, anchor_pos = 1L, amount = 2.5,
unit = "picoseconds")
idx_offset_to_pos(cal_ps, 12.5)## [1] 6
None of the examples elsewhere in this vignette need
idx_offset_to_pos(), since every series used above has a
genuine Date/POSIXct calendar and so uses
idx_to_pos() instead; it is included here for completeness,
for applications - e.g. raw instrumentation data with no calendar
meaning - where idx_series positions are still worth
translating to and from the anchor’s own numeric scale.
Of course, while \(y_t\) denotes daily new cases here, it could equally denote weekly sales of a new product, etc. None of the analysis here is dependent on the data frequency.↩︎
These are not proper prediction or confidence intervals for \(y_{T+\ell}\). The one-step-ahead predictive distribution of \(\hat{y}_{T+\ell|T}\) (for \(\ell=1\)) is lognormal. This is not the case more than one step ahead due to the presence of the cumulative total in equation (2).↩︎
Note the change in sign of \(\gamma\) as compared with HK.↩︎