Skip to content

Commit 5b39822

Browse files
authored
🚀 [AR] Fill book chapter (#5)
1 parent b0e3de2 commit 5b39822

File tree

2 files changed

+300
-1
lines changed

2 files changed

+300
-1
lines changed

book/src/SUMMARY.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
- [Introduction](introduction.md)
22
- [Kalman filter](kf_linear.md)
33
- [Simple optimizers](simple_optimizers.md)
4-
- [Autoregressive models AR(p)]()
4+
- [Autoregressive models AR(p)](ar_models.md)

book/src/ar_models.md

Lines changed: 299 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,299 @@
1+
2+
# Autoregressive models
3+
4+
This chapter documents a header-only implementation of an Autoregressive model AR(*p*) in modern C++.
5+
It explains the class template, the OLS and Yule–Walker estimators, and small-but-important C++ details you asked about.
6+
7+
## AR(p) refresher
8+
9+
An AR(*p*) process is
10+
$$
11+
X_t = c + \phi_1 X_{t-1} + \cdots + \phi_p X_{t-p} + \varepsilon_t,
12+
$$
13+
with intercept $c$, coefficients $\phi_i$, and i.i.d. noise $\varepsilon_t \sim (0,\sigma^2)$.
14+
15+
## Header overview
16+
17+
```cpp
18+
#pragma once
19+
#include <Eigen/Dense>
20+
#include <iostream>
21+
#include <numeric>
22+
23+
namespace cppx::ar_models {
24+
25+
template <int order> class ARModel {
26+
public:
27+
using Vector = Eigen::Matrix<double, order, 1>;
28+
29+
ARModel() = default;
30+
ARModel(double intercept, double noise_variance) : c_(intercept), sigma2_(noise_variance){};
31+
32+
[[nodiscard]] double intercept() const noexcept { return c_; }
33+
[[nodiscard]] double noise() const noexcept { return sigma2_; }
34+
[[nodiscard]] const Vector &coefficients() const noexcept { return phi_; }
35+
36+
void set_coefficients(const Vector &phi) { phi_ = phi; }
37+
void set_intercept(double c) { c_ = c; }
38+
void set_noise(double noise) { sigma2_ = noise; }
39+
40+
double forecast_one_step(const std::vector<double> &hist) const {
41+
if (static_cast<int>(hist.size()) < order) {
42+
throw std::invalid_argument("History shorter than model order");
43+
}
44+
double y = c_;
45+
for (int i = 0; i < order; ++i) {
46+
y += phi_(i) * hist[i];
47+
}
48+
return y;
49+
}
50+
51+
private:
52+
Vector phi_;
53+
double c_ = 0.0;
54+
double sigma2_ = 1.0;
55+
};
56+
```
57+
Notes
58+
- `using Vector = Eigen::Matrix<double, order, 1>;` is the correct Eigen alias (there is no `Eigen::Vector<double, N>` type).
59+
- Defaulted constructor + in-class member initializers (C++11) keep initialization simple.
60+
- `[[nodiscard]]` marks return values that shouldn’t be ignored.
61+
- `static_cast<int>` is used because `std::vector::size()` returns `size_t` (unsigned), while Eigen commonly uses `int` sizes.
62+
63+
## Forecasting (one-step)
64+
65+
```cpp
66+
double forecast_one_step(const std::vector<double> &hist) const {
67+
if (static_cast<int>(hist.size()) < order) {
68+
throw std::invalid_argument("History shorter than model order");
69+
}
70+
double y = c_;
71+
for (int i = 0; i < order; ++i) {
72+
y += phi_(i) * hist[i]; // hist[0]=X_T, hist[1]=X_{T-1}, ...
73+
}
74+
return y;
75+
}
76+
```
77+
The one-step-ahead plug‑in forecast $\hat X_{T+1|T}$ equals $c + \sum_{i=1}^p \phi_i X_{T+1-i}$.
78+
79+
## OLS estimator (header-only)
80+
81+
Mathematically, we fit
82+
83+
$$
84+
X_t = c + \phi_1 X_{t-1} + \cdots + \phi_p X_{t-p} + \varepsilon_t.
85+
$$
86+
87+
Define:
88+
89+
- $Y = (X_{p}, X_{p+1}, \dots, X_T)^\top \in \mathbb{R}^{n}$, where $n = T-p$.
90+
- $X \in \mathbb{R}^{n \times (p+1)}$ the **design matrix**:
91+
92+
$$
93+
X =
94+
\begin{bmatrix}
95+
1 & X_{p-1} & X_{p-2} & \cdots & X_{0} \\\\
96+
1 & X_{p} & X_{p-1} & \cdots & X_{1} \\\\
97+
\vdots & \vdots & \vdots & & \vdots \\\\
98+
1 & X_{T-1} & X_{T-2} & \cdots & X_{T-p}
99+
\end{bmatrix}.
100+
$$
101+
102+
The regression model is
103+
104+
$$
105+
Y = X \beta + \varepsilon, \quad
106+
\beta =
107+
\begin{bmatrix}
108+
c \\ \phi_1 \\ \vdots \\ \phi_p
109+
\end{bmatrix}.
110+
$$
111+
112+
The **OLS estimator** is
113+
114+
$$
115+
\hat\beta = (X^\top X)^{-1} X^\top Y.
116+
$$
117+
118+
Residual variance estimate:
119+
120+
$$
121+
\hat\sigma^2 = \frac{1}{n-(p+1)} \|Y - X\hat\beta\|_2^2.
122+
$$
123+
124+
In code, we solve this with Eigen’s QR decomposition:
125+
126+
```cpp
127+
Eigen::VectorXd beta = X.colPivHouseholderQr().solve(Y);
128+
```
129+
130+
```cpp
131+
template <int order>
132+
ARModel<order> fit_ar_ols(const std::vector<double> &x) {
133+
if (static_cast<int>(x.size()) <= order) {
134+
throw std::invalid_argument("Time series too short for AR(order)");
135+
}
136+
const int T = static_cast<int>(x.size());
137+
const int n = T - order;
138+
139+
Eigen::MatrixXd X(n, order + 1);
140+
Eigen::VectorXd Y(n);
141+
142+
for (int t = 0; t < n; ++t) {
143+
Y(t) = x[order + t];
144+
X(t, 0) = 1.0; // intercept column
145+
for (int j = 0; j < order; ++j) {
146+
X(t, j + 1) = x[order + t - 1 - j]; // lagged regressors (most-recent-first)
147+
}
148+
}
149+
150+
Eigen::VectorXd beta = X.colPivHouseholderQr().solve(Y);
151+
Eigen::VectorXd resid = Y - X * beta;
152+
const double sigma2 = resid.squaredNorm() / static_cast<double>(n - (order + 1));
153+
154+
typename ARModel<order>::Vector phi;
155+
for (int j = 0; j < order; ++j) phi(j) = beta(j + 1);
156+
157+
ARModel<order> model;
158+
model.set_coefficients(phi);
159+
model.set_intercept(beta(0)); // beta(0) is the intercept
160+
model.set_noise(sigma2);
161+
return model;
162+
}
163+
```
164+
165+
## Yule–Walker (Levinson–Durbin)
166+
167+
The AR($p$) autocovariance equations are:
168+
169+
$$
170+
\gamma_k = \sum_{i=1}^p \phi_i \gamma_{k-i}, \quad k = 1, \dots, p,
171+
$$
172+
173+
where $\gamma_k = \text{Cov}(X_t, X_{t-k})$.
174+
175+
This leads to the **Yule–Walker system**:
176+
177+
$$
178+
\begin{bmatrix}
179+
\gamma_0 & \gamma_1 & \cdots & \gamma_{p-1} \\\\
180+
\gamma_1 & \gamma_0 & \cdots & \gamma_{p-2} \\\\
181+
\vdots & \vdots & \ddots & \vdots \\\\
182+
\gamma_{p-1} & \gamma_{p-2} & \cdots & \gamma_0
183+
\end{bmatrix}
184+
\begin{bmatrix}
185+
\phi_1 \\\\ \phi_2 \\\\ \vdots \\\\ \phi_p
186+
\end{bmatrix}
187+
=
188+
\begin{bmatrix}
189+
\gamma_1 \\\\ \gamma_2 \\\\ \vdots \\\\ \gamma_p
190+
\end{bmatrix}.
191+
$$
192+
193+
We estimate autocovariances by
194+
195+
$$
196+
\hat\gamma_k = \frac{1}{T} \sum_{t=k}^{T-1} (X_t-\bar X)(X_{t-k}-\bar X).
197+
$$
198+
199+
### Levinson–Durbin recursion
200+
201+
Efficiently solves the Toeplitz system in $O(p^2)$ time.
202+
At each step:
203+
204+
- Update reflection coefficient $\kappa_m$,
205+
- Update AR coefficients $a_j$,
206+
- Update innovation variance
207+
208+
$$
209+
E_m = E_{m-1}(1 - \kappa_m^2).
210+
$$
211+
212+
The final variance $E_p$ is the residual variance estimate.
213+
214+
215+
```cpp
216+
inline double _sample_mean(const std::vector<double> &x) {
217+
return std::accumulate(x.begin(), x.end(), 0.0) / x.size();
218+
}
219+
inline double _sample_autocov(const std::vector<double> &x, int k) {
220+
const int T = static_cast<int>(x.size());
221+
if (k >= T) throw std::invalid_argument("lag too large");
222+
const double mu = _sample_mean(x);
223+
double acc = 0.0;
224+
for (int t = k; t < T; ++t) acc += (x[t]-mu) * (x[t-k]-mu);
225+
return acc / static_cast<double>(T);
226+
}
227+
```
228+
- `std::vector` has no `.mean()`; we compute it with `std::accumulate` (from `<numeric>`).
229+
- For compile-time sizes (since `order` is a template parameter) we can use `std::array<double, order+1>` to hold autocovariances.
230+
231+
Levinson–Durbin recursion:
232+
```cpp
233+
template <int order>
234+
ARModel<order> fit_ar_yule_walkter(const std::vector<double> &x) {
235+
static_assert(order >= 1, "Yule–Walker needs order >= 1");
236+
if (static_cast<int>(x.size()) <= order) {
237+
throw std::invalid_argument("Time series too short for AR(order)");
238+
}
239+
240+
std::array<double, order + 1> r{};
241+
for (int k = 0; k <= order; ++k) r[k] = _sample_autocov(x, k);
242+
243+
typename ARModel<order>::Vector a; a.setZero();
244+
double E = r[0];
245+
if (std::abs(E) < 1e-15) throw std::runtime_error("Zero variance");
246+
247+
for (int m = 1; m <= order; ++m) {
248+
double acc = r[m];
249+
for (int j = 1; j < m; ++j) acc -= a(j - 1) * r[m - j];
250+
const double kappa = acc / E;
251+
252+
typename ARModel<order>::Vector a_new = a;
253+
a_new(m - 1) = kappa;
254+
for (int j = 1; j < m; ++j) a_new(j - 1) = a(j - 1) - kappa * a(m - j - 1);
255+
a = a_new;
256+
257+
E *= (1.0 - kappa * kappa);
258+
if (E <= 0) throw std::runtime_error("Non-positive innovation variance in recursion");
259+
}
260+
261+
const double xbar = _sample_mean(x);
262+
const double c = (1.0 - a.sum()) * xbar; // intercept so that mean(model) == sample mean
263+
264+
ARModel<order> model;
265+
model.set_coefficients(a);
266+
model.set_intercept(c);
267+
model.set_noise(E);
268+
return model;
269+
}
270+
```
271+
272+
## Small questions I asked myself while implementing this
273+
274+
- The class holds parameters + forecasting but the algorithms live outside. This way, I can add/replace estimators without modifying the class.
275+
276+
- `typename ARModel<order>::Vector` — why the `typename`? Inside templates, dependent names might be types or values. `typename` tells the compiler it’s a type.
277+
278+
- `std::array` vs `std::vector`? `std::array<T,N>` is fixed-size (size known at compile time) and stack-allocated while `std::vector<T>` is dynamic-size (runtime) and heap-allocated.
279+
280+
- Why `static_cast<int>(hist.size())`? `.size()` returns `size_t` (unsigned). Converting explicitly avoids signed/unsigned warnings and matches Eigen’s int-based indices.
281+
282+
## Example of usage
283+
284+
```cpp
285+
#include "ar.hpp"
286+
#include <iostream>
287+
#include <vector>
288+
289+
int main() {
290+
std::vector<double> x = {0.1, 0.3, 0.7, 0.8, 1.2, 1.0, 0.9};
291+
292+
auto m = fit_ar_ols<2>(x);
293+
std::cout << "c=" << m.intercept() << ", sigma^2=" << m.noise()
294+
<< ", phi=" << m.coefficients().transpose() << "\n";
295+
296+
std::vector<double> hist = {x.back(), x[x.size()-2]}; // [X_T, X_{T-1}]
297+
std::cout << "one-step forecast: " << m.forecast_one_step(hist) << "\n";
298+
}
299+
```

0 commit comments

Comments
 (0)