Skip to content

Commit b0e3de2

Browse files
authored
🚀 Add AR(p) model example (#4)
1 parent 337d855 commit b0e3de2

File tree

14 files changed

+313
-8
lines changed

14 files changed

+313
-8
lines changed

CMakeLists.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ cmake_minimum_required(VERSION 3.15)
44
project(cppxplorers LANGUAGES CXX)
55

66
# Set C++ standard
7-
set(CMAKE_CXX_STANDARD 17)
7+
set(CMAKE_CXX_STANDARD 20)
88
set(CMAKE_CXX_STANDARD_REQUIRED ON)
99
set(CMAKE_CXX_EXTENSIONS OFF)
1010

Justfile

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ install: build
2929
cmake --install {{BUILD_DIR}}
3030

3131
test: build
32-
ctest --test-dir {{BUILD_DIR}} --output-on-failure
32+
ctest --test-dir {{BUILD_DIR}} --output-on-failure -VV
3333

3434
clean:
3535
rm -rf {{BUILD_DIR}}
@@ -45,7 +45,7 @@ run-kalman:
4545
# Linting & formatting
4646
# -------------------------
4747
lint: build
48-
clang-tidy crates/*/src/*.cpp -p {{BUILD_DIR}} -- -std=c++17
48+
clang-tidy crates/*/src/*.cpp -p {{BUILD_DIR}} -- -std=c++20
4949

5050
fmt:
5151
find crates -type f \( -name '*.cpp' -o -name '*.hpp' \) -exec clang-format -i {} +

book/src/SUMMARY.md

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

crates/CMakeLists.txt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
add_subdirectory("kf_linear")
2-
add_subdirectory("simple_optimizers")
2+
add_subdirectory("simple_optimizers")
3+
add_subdirectory("ar_models")

crates/ar_models/CMakeLists.txt

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# Header-only target
2+
add_library(ar_models INTERFACE)
3+
4+
# Public includes (exports the include/ dir to consumers)
5+
target_include_directories(ar_models INTERFACE
6+
${CMAKE_CURRENT_SOURCE_DIR}/include
7+
)
8+
9+
# Link Eigen and set language level
10+
target_link_libraries(ar_models INTERFACE Eigen3::Eigen)
11+
target_compile_features(ar_models INTERFACE cxx_std_20)
12+
13+
# Demo executable
14+
add_executable(ar_demo src/main.cpp)
15+
target_link_libraries(ar_demo PRIVATE ar_models)
16+
17+
# Tests
18+
enable_testing()
19+
add_subdirectory(tests)

crates/ar_models/include/ar.hpp

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
#pragma once
2+
#include <Eigen/Dense>
3+
#include <iostream>
4+
#include <numeric>
5+
6+
namespace cppx::ar_models {
7+
8+
template <int order> class ARModel {
9+
public:
10+
using Vector = Eigen::Vector<double, order>;
11+
12+
ARModel() = default;
13+
ARModel(double intercept, double noise_variance) : c_(intercept), sigma2_(noise_variance){};
14+
15+
[[nodiscard]] double intercept() const noexcept { return c_; }
16+
[[nodiscard]] double noise() const noexcept { return sigma2_; }
17+
[[nodiscard]] const Vector &coefficients() const noexcept { return phi_; }
18+
19+
void set_coefficients(const Vector &phi) { phi_ = phi; }
20+
void set_intercept(double c) { c_ = c; }
21+
void set_noise(double noise) { sigma2_ = noise; }
22+
23+
double forecast_one_step(const std::vector<double> &hist) const {
24+
if (static_cast<int>(hist.size()) < order) {
25+
throw std::invalid_argument("History shorter than model order");
26+
}
27+
double y = c_;
28+
for (int i = 0; i < order; ++i) {
29+
y += phi_(i) * hist[i];
30+
}
31+
return y;
32+
}
33+
34+
private:
35+
Vector phi_;
36+
double c_ = 0.0;
37+
double sigma2_ = 1.0;
38+
};
39+
40+
template <int order> ARModel<order> fit_ar_ols(const std::vector<double> &x) {
41+
if (static_cast<int>(x.size()) <= order) {
42+
throw std::invalid_argument("Time series too short for AR(order)");
43+
}
44+
45+
const int T = static_cast<int>(x.size());
46+
const int n = T - order;
47+
48+
// Build the design system
49+
Eigen::MatrixXd X(n, order + 1);
50+
Eigen::VectorXd Y(n);
51+
52+
for (int t = 0; t < n; ++t) {
53+
Y(t) = x[order + t];
54+
X(t, 0) = 1.0;
55+
for (int j = 0; j < order; ++j) {
56+
X(t, j + 1) = x[order + t - 1 - j];
57+
}
58+
}
59+
60+
// Solve least squares
61+
Eigen::VectorXd beta = X.colPivHouseholderQr().solve(Y);
62+
// beta(0) = intercept, beta(1..order) = AR coefficients
63+
64+
// Compute residual variance
65+
Eigen::VectorXd resid = Y - X * beta;
66+
double sigma2 = resid.squaredNorm() / static_cast<double>(n - (order + 1));
67+
68+
// Create AR(p)
69+
typename ARModel<order>::Vector phi;
70+
for (int j = 0; j < order; ++j) {
71+
phi(j) = beta(j + 1);
72+
}
73+
74+
ARModel<order> model;
75+
model.set_coefficients(phi);
76+
model.set_intercept(beta(0));
77+
model.set_noise(sigma2);
78+
79+
return model;
80+
}
81+
82+
inline double _sample_mean(const std::vector<double> &x) {
83+
double mu = std::accumulate(x.begin(), x.end(), 0.0) / x.size();
84+
return mu;
85+
}
86+
inline double _sample_autocov(const std::vector<double> &x, int k) {
87+
const int T = static_cast<int>(x.size());
88+
if (k >= T) {
89+
throw std::invalid_argument("lag too large");
90+
}
91+
const double mu = _sample_mean(x);
92+
double acc = 0.0;
93+
for (int t = k; t < T; ++t) {
94+
acc += (x[t] - mu) * (x[t - k] - mu);
95+
}
96+
return acc / static_cast<double>(T);
97+
}
98+
99+
template <int order> ARModel<order> fir_ar_yule_walkter(const std::vector<double> &x) {
100+
static_assert(order >= 1, "Yule–Walker needs order >= 1");
101+
if (static_cast<int>(x.size()) <= order) {
102+
throw std::invalid_argument("Time series too short for AR(order)");
103+
}
104+
105+
// r[0..order] sample autocovariances
106+
std::array<double, order + 1> r{};
107+
for (int k = 0; k <= order; ++k) {
108+
r[k] = _sample_autocov(x, k);
109+
}
110+
111+
// Levinson–Durbin recursion
112+
typename ARModel<order>::Vector a;
113+
a.setZero();
114+
double E = r[0];
115+
if (std::abs(E) < 1e-15) {
116+
throw std::runtime_error("Zero variance");
117+
}
118+
119+
for (int m = 1; m <= order; ++m) {
120+
double acc = r[m];
121+
for (int j = 1; j < m; ++j)
122+
acc -= a(j - 1) * r[m - j];
123+
const double kappa = acc / E;
124+
125+
// update a (reflection update)
126+
typename ARModel<order>::Vector a_new = a;
127+
a_new(m - 1) = kappa;
128+
for (int j = 1; j < m; ++j) {
129+
a_new(j - 1) = a(j - 1) - kappa * a(m - j - 1);
130+
}
131+
a = a_new;
132+
133+
E *= (1.0 - kappa * kappa);
134+
if (E <= 0) {
135+
throw std::runtime_error("Non-positive innovation variance in recursion");
136+
}
137+
}
138+
139+
// Compute intercept so that unconditional mean matches sample mean (stationarity assumption)
140+
const double xbar = _sample_mean(x);
141+
const double one_minus_sum = 1.0 - a.sum();
142+
const double c = one_minus_sum * xbar;
143+
144+
ARModel<order> model;
145+
model.set_coefficients(a);
146+
model.set_intercept(c);
147+
model.set_noise(E);
148+
149+
return model;
150+
}
151+
152+
} // namespace cppx::ar_models

crates/ar_models/src/main.cpp

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
#include <iostream>
2+
3+
int main() {
4+
std::cout << "Hello, world!" << "\n";
5+
return 0;
6+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# crates/simple_optimizers/tests/CMakeLists.txt
2+
3+
add_executable(ar_models_test test_ar_models.cpp)
4+
5+
target_link_libraries(ar_models_test PRIVATE ar_models)
6+
7+
add_test(NAME ar_models_test COMMAND ar_models_test)
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
#include "ar.hpp"
2+
#include <cassert>
3+
#include <cmath>
4+
#include <iostream>
5+
#include <random>
6+
#include <vector>
7+
8+
using namespace cppx::ar_models;
9+
10+
// Tiny helper for floating-point checks
11+
static bool almost_equal(double a, double b, double tol = 1e-6) {
12+
return std::fabs(a - b) <= tol;
13+
}
14+
15+
int main() {
16+
// Default construction
17+
{
18+
ARModel<1> model;
19+
assert(almost_equal(model.intercept(), 0.0));
20+
assert(almost_equal(model.noise(), 1.0));
21+
assert(model.coefficients().size() == 1);
22+
}
23+
24+
// OLS fit recovers AR(1)
25+
{
26+
// Simulate AR(1): X_t = c + phi X_{t-1} + eps_t
27+
constexpr int P = 1;
28+
const double c = 0.4;
29+
const double phi = 0.65;
30+
const double sigma = 0.1; // small noise -> tighter estimates
31+
const int T = 1000;
32+
33+
std::mt19937 rng(12345);
34+
std::normal_distribution<double> N(0.0, sigma);
35+
36+
std::vector<double> x(T);
37+
x[0] = 0.0;
38+
for (int t = 1; t < T; ++t) {
39+
x[t] = c + phi * x[t - 1] + N(rng);
40+
}
41+
42+
auto m = fit_ar_ols<P>(x);
43+
// Coefficients
44+
assert(m.coefficients().size() == P);
45+
double phi_hat = m.coefficients()(0);
46+
// Intercept (your API calls it "intercept()", but you’re storing the intercept there)
47+
double c_hat = m.intercept();
48+
49+
// Loose but meaningful tolerances
50+
assert(std::fabs(phi_hat - phi) < 0.05);
51+
assert(std::fabs(c_hat - c) < 0.05);
52+
assert(m.noise() > 0.0);
53+
}
54+
55+
// Yule–Walker (Levinson–Durbin) recovers AR(1)
56+
{
57+
constexpr int P = 1;
58+
const double c = 0.2;
59+
const double phi = 0.5;
60+
const double sigma = 0.15;
61+
const int T = 1200;
62+
63+
std::mt19937 rng(54321);
64+
std::normal_distribution<double> N(0.0, sigma);
65+
66+
std::vector<double> x(T);
67+
x[0] = 0.0;
68+
for (int t = 1; t < T; ++t) {
69+
x[t] = c + phi * x[t - 1] + N(rng);
70+
}
71+
72+
// NOTE: your function name currently has a typo "fir_ar_yule_walkter"
73+
auto m = fir_ar_yule_walkter<P>(x);
74+
75+
double phi_hat = m.coefficients()(0);
76+
double c_hat = m.intercept();
77+
78+
assert(std::fabs(phi_hat - phi) < 0.07);
79+
assert(std::fabs(c_hat - c) < 0.07);
80+
assert(m.noise() > 0.0);
81+
}
82+
83+
// Forecast one-step sanity (AR(1))
84+
{
85+
ARModel<1> m;
86+
// Set a known model: X_t = c + phi X_{t-1} + eps
87+
ARModel<1>::Vector phi_vec;
88+
phi_vec << 0.6;
89+
m.set_coefficients(phi_vec);
90+
m.set_intercept(0.3); // intercept (your getter name is intercept())
91+
m.set_noise(0.2);
92+
93+
// hist = [X_T, X_{T-1}, ...] ; for AR(1) we need 1 value
94+
std::vector<double> hist = {2.0};
95+
double yhat = m.forecast_one_step(hist);
96+
// Expected: c + phi * X_T
97+
double expected = 0.3 + 0.6 * 2.0;
98+
assert(almost_equal(yhat, expected, 1e-12));
99+
}
100+
101+
// Error handling: series too short
102+
{
103+
std::vector<double> tiny = {1.0}; // length 1 cannot fit AR(2), nor AR(1) when n<=p
104+
bool threw = false;
105+
try {
106+
(void) fit_ar_ols<2>(tiny);
107+
} catch (const std::invalid_argument &) {
108+
threw = true;
109+
}
110+
assert(threw);
111+
}
112+
113+
return 0;
114+
}

crates/kf_linear/CMakeLists.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ target_include_directories(kf_linear INTERFACE
88

99
# Link Eigen and set language level
1010
target_link_libraries(kf_linear INTERFACE Eigen3::Eigen)
11-
target_compile_features(kf_linear INTERFACE cxx_std_17)
11+
target_compile_features(kf_linear INTERFACE cxx_std_20)
1212

1313
# Demo executable
1414
add_executable(kf_demo src/main.cpp)

0 commit comments

Comments
 (0)