SPB Git

spb/wp40_uqo

Public MIT
13.7 KB · 156 lines cpp
1// GARCH-family QML estimation on the CPU: BFGS on the unconstrained parameters, central-difference gradients,2// Armijo backtracking, and three covariance estimators (Hessian, OPG, QMLE sandwich = H^{-1} OPG H^{-1}).3#include "raid/models/garch.hpp"4#include "raid/core/error.hpp"5#include "raid/core/rng.hpp"6#include "raid/estimators/ols.hpp"7#include "raid/statistics/reductions.hpp"8#include <Eigen/Dense>9#include <chrono>10#include <cmath>11#include <numbers>12#include <cstdlib>13#include <cstdio>1415namespace raid::models {1617namespace {18using Vec = Eigen::VectorXd; using Mat = Eigen::MatrixXd;1920// Per-observation log-likelihood contributions ℓ_t (returns +inf nll on invalid params).21bool per_obs_ll(std::span<const f64> r, std::span<const f64> th, GarchModel m, GarchDist d, std::vector<f64>& ll) {22  if (!garch_params_valid(th, m, d)) return false;23  std::vector<f64> s2; if (!garch_variance_path(r, th, m, d, s2)) return false;24  const f64 mu = th[0]; const usize n = r.size(); ll.resize(n);25  if (d == GarchDist::gaussian) { const f64 k = 0.5 * std::log(2.0 * std::numbers::pi); for (usize t = 0; t < n; ++t) { const f64 e = r[t] - mu; ll[t] = -(k + 0.5 * std::log(s2[t]) + 0.5 * e * e / s2[t]); } }26  else { const f64 nu = th.back(); const f64 k = std::lgamma((nu + 1) / 2) - std::lgamma(nu / 2) - 0.5 * std::log(std::numbers::pi * (nu - 2)); for (usize t = 0; t < n; ++t) { const f64 e = r[t] - mu; ll[t] = k - 0.5 * std::log(s2[t]) - 0.5 * (nu + 1) * std::log1p(e * e / ((nu - 2) * s2[t])); } }27  return true;28}2930template <class F> Vec num_grad(const F& f, const Vec& x, f64 fx, f64 h0 = 1e-5) {31  Vec g(x.size());32  for (Eigen::Index i = 0; i < x.size(); ++i) { const f64 h = h0 * std::max(1.0, std::abs(x[i])); Vec xp = x, xm = x; xp[i] += h; xm[i] -= h; const f64 fp = f(xp), fm = f(xm);33    if (std::isfinite(fp) && std::isfinite(fm)) g[i] = (fp - fm) / (2 * h); else if (std::isfinite(fp)) g[i] = (fp - fx) / h; else if (std::isfinite(fm)) g[i] = (fx - fm) / h; else g[i] = 0; }34  return g;35}36template <class F> Mat num_hessian(const F& f, const Vec& x, f64 h0 = 1e-4) {37  const Eigen::Index p = x.size(); Mat H(p, p); const f64 fx = f(x);38  for (Eigen::Index i = 0; i < p; ++i) for (Eigen::Index j = i; j < p; ++j) {39    const f64 hi = h0 * std::max(1.0, std::abs(x[i])), hj = h0 * std::max(1.0, std::abs(x[j]));40    Vec a = x, b = x, c = x, dd = x; a[i] += hi; a[j] += hj; b[i] += hi; b[j] -= hj; c[i] -= hi; c[j] += hj; dd[i] -= hi; dd[j] -= hj;41    f64 v = (f(a) - f(b) - f(c) + f(dd)) / (4 * hi * hj);42    if (i == j) { Vec xp = x, xm = x; xp[i] += hi; xm[i] -= hi; v = (f(xp) - 2 * fx + f(xm)) / (hi * hi); }43    H(i, j) = v; H(j, i) = v; }44  return H;45}46}  // namespace4748static Estimate garch_fit_once(std::span<const f64> r0, const GarchFitOptions& opt, int start_variant);49Estimate garch_fit(std::span<const f64> r0, const GarchFitOptions& opt) {50  // Up to 3 starting-value variants (documented in the registry): 0 = default, 1 = low persistence (0.80), 2 = high persistence (0.985) / opposite EGARCH γ sign.51  Estimate best; bool have = false; int tried = 0;52  for (int v = 0; v < 3; ++v) { Estimate e = garch_fit_once(r0, opt, v); ++tried; if (!have || (e.ok() && (!best.ok() || e.loss < best.loss))) { best = e; have = true; } if (best.ok() && opt.retry_starts == false) break; if (best.ok()) break; }53  best.extra["start_variants_tried"] = tried; return best;54}55static Estimate garch_fit_once(std::span<const f64> r0, const GarchFitOptions& opt, int start_variant) {56  const auto t0 = std::chrono::steady_clock::now();57  Estimate e; e.model = "garch." + to_string(opt.model) + "." + to_string(opt.dist); e.backend = Backend::cpu; e.precision = Precision::fp64;58  e.names = garch_param_names(opt.model, opt.dist); e.n_obs = r0.size();59  RAID_REQUIRE(r0.size() >= 50, insufficient_samples, "garch_fit: need at least 50 observations");60  for (f64 v : r0) RAID_REQUIRE(std::isfinite(v), data_quality, "garch_fit: non-finite return");61  // scaling (arch rescales to unit variance ×100-ish; we scale so that sample std ≈ 1 and unscale afterwards)62  const f64 sd = std::sqrt(stats::variance(r0, 0)); RAID_REQUIRE(sd > 0, data_quality, "garch_fit: constant returns");63  const f64 scale = opt.scale_returns ? 1.0 / sd : 1.0;64  std::vector<f64> r(r0.size()); for (usize i = 0; i < r.size(); ++i) r[i] = r0[i] * scale;65  const usize p = garch_n_params(opt.model, opt.dist);66  // starting values (on the scaled series): μ = mean, persistence 0.95, α = 0.05 (GJR γ = 0.05), ν = 867  std::vector<f64> th0(p); th0[0] = stats::mean(std::span<const f64>(r));68  const f64 v = stats::variance(std::span<const f64>(r), 0);69  const f64 pers = start_variant == 1 ? 0.80 : (start_variant == 2 ? 0.985 : 0.95);70  if (opt.model == GarchModel::egarch) { th0[2] = 0.1; th0[3] = start_variant == 2 ? 0.05 : -0.05; th0[4] = pers; th0[1] = (1.0 - th0[4]) * std::log(v); }71  else if (opt.model == GarchModel::garch) { th0[2] = 0.05; th0[3] = pers - 0.05; th0[1] = v * (1 - pers); }72  else { th0[2] = 0.03; th0[3] = 0.05; th0[4] = pers - 0.055; th0[1] = v * (1 - pers); }73  if (opt.dist == GarchDist::student_t) th0[p - 1] = 8.0;74  auto f_u = [&](const Vec& u) { auto th = garch_from_unconstrained(std::span<const f64>(u.data(), static_cast<usize>(u.size())), opt.model, opt.dist); return garch_nll(r, th, opt.model, opt.dist); };75  auto u0v = garch_to_unconstrained(th0, opt.model, opt.dist);76  Vec x = Eigen::Map<Vec>(u0v.data(), static_cast<Eigen::Index>(p));77  f64 fx = f_u(x); int fevals = 1;78  if (!std::isfinite(fx)) { e.convergence = Convergence{false, 0, fevals, std::numeric_limits<f64>::quiet_NaN(), -1, "invalid starting values"}; return e; }79  auto fcount = [&](const Vec& y) { ++fevals; return f_u(y); };80  Vec g = num_grad(fcount, x, fx); Mat Hinv = Mat::Identity(static_cast<Eigen::Index>(p), static_cast<Eigen::Index>(p));81  int it = 0; bool converged = false; std::string msg;82  for (; it < opt.max_iter; ++it) {83    Vec dir = -Hinv * g; if (dir.dot(g) >= 0) { Hinv.setIdentity(); dir = -g; }84    f64 step = 1.0; f64 fnew = std::numeric_limits<f64>::infinity(); Vec xnew;85    for (int ls = 0; ls < 60; ++ls) { xnew = x + step * dir; fnew = fcount(xnew); if (std::isfinite(fnew) && fnew <= fx + 1e-4 * step * g.dot(dir)) break; step *= 0.5; }86    if (!std::isfinite(fnew) || fnew > fx) { msg = "line search failed"; break; }87    Vec gnew = num_grad(fcount, xnew, fnew);88    if (std::getenv("RAID_GARCH_TRACE")) { std::fprintf(stderr, "it=%d f=%.6f step=%.3g |g|=%.3e x=", it, fnew, step, gnew.norm()); for (Eigen::Index j = 0; j < x.size(); ++j) std::fprintf(stderr, "%.4f ", xnew[j]); std::fprintf(stderr, " g="); for (Eigen::Index j = 0; j < x.size(); ++j) std::fprintf(stderr, "%.3e ", gnew[j]); std::fprintf(stderr, "\n"); }89    const Vec s = xnew - x, yv = gnew - g; const f64 sy = s.dot(yv);90    if (sy > 1e-12) { const Mat I = Mat::Identity(static_cast<Eigen::Index>(p), static_cast<Eigen::Index>(p)); const f64 rho = 1.0 / sy; Hinv = (I - rho * s * yv.transpose()) * Hinv * (I - rho * yv * s.transpose()) + rho * s * s.transpose(); }91    const f64 rel = std::abs(fx - fnew) / std::max(1.0, std::abs(fx));92    x = xnew; fx = fnew; g = gnew;93    if (g.norm() < 1e-5 || (rel < opt.tol && g.norm() < 1e-2) || (s.norm() < 1e-9 && g.norm() < 1e-2)) { converged = true; msg = g.norm() < 1e-5 ? "gradient norm below 1e-5" : (rel < opt.tol ? "relative NLL change below tol (gradient < 1e-2 in transformed space)" : "step below 1e-9 (gradient < 1e-2)"); ++it; break; }94  }95  if (!converged && msg.empty()) msg = "maximum iterations reached";96  auto th = garch_from_unconstrained(std::span<const f64>(x.data(), static_cast<usize>(x.size())), opt.model, opt.dist);97  // Covariance on the natural parameters (scaled series) by numerical derivatives of the NLL98  auto f_th = [&](const Vec& t) { return garch_nll(r, std::span<const f64>(t.data(), static_cast<usize>(t.size())), opt.model, opt.dist); };99  Vec thv = Eigen::Map<Vec>(th.data(), static_cast<Eigen::Index>(p));100  Mat H = num_hessian(f_th, thv);101  // OPG from per-observation scores (central differences of ℓ_t)102  const usize n = r.size(); Mat scores(static_cast<Eigen::Index>(n), static_cast<Eigen::Index>(p));103  { std::vector<f64> lp, lm; for (Eigen::Index j = 0; j < static_cast<Eigen::Index>(p); ++j) { const f64 h = 1e-5 * std::max(1.0, std::abs(thv[j])); Vec a = thv, b = thv; a[j] += h; b[j] -= h;104      bool oka = per_obs_ll(r, std::span<const f64>(a.data(), p), opt.model, opt.dist, lp), okb = per_obs_ll(r, std::span<const f64>(b.data(), p), opt.model, opt.dist, lm);105      for (usize t = 0; t < n; ++t) scores(static_cast<Eigen::Index>(t), j) = (oka && okb) ? (lp[t] - lm[t]) / (2 * h) : std::numeric_limits<f64>::quiet_NaN(); } }106  Mat opg = scores.transpose() * scores;107  InferenceResult inf; Mat cov;108  Eigen::FullPivLU<Mat> lu(H); bool h_ok = lu.isInvertible() && H.allFinite();109  if (opt.cov == "hessian") { cov = h_ok ? Mat(lu.inverse()) : Mat(Mat::Constant(p, p, std::numeric_limits<f64>::quiet_NaN())); inf.covariance_type = "hessian"; }110  else if (opt.cov == "opg") { Eigen::FullPivLU<Mat> lo(opg); cov = lo.isInvertible() ? Mat(lo.inverse()) : Mat(Mat::Constant(p, p, std::numeric_limits<f64>::quiet_NaN())); inf.covariance_type = "opg"; }111  else { cov = h_ok ? Mat(lu.inverse() * opg * lu.inverse()) : Mat(Mat::Constant(p, p, std::numeric_limits<f64>::quiet_NaN())); inf.covariance_type = "qmle_sandwich"; }112  if (!h_ok) e.warnings.push_back("Hessian singular or non-finite at the optimum; standard errors unavailable");113  if (opt.dist == GarchDist::student_t && th.back() > 450.0) e.warnings.push_back("nu at its upper bound (500): the Student-t density is indistinguishable from Gaussian on this sample");114  // Unscale: r0 = r / scale. μ, ω, σ² scale by 1/scale, 1/scale², 1/scale²; α, β, γ, ν unchanged; EGARCH ω → ω + (1−β) log(1/scale²)115  std::vector<f64> th_out = th; Vec jac = Vec::Ones(static_cast<Eigen::Index>(p));116  th_out[0] = th[0] / scale; jac[0] = 1.0 / scale;117  if (opt.model == GarchModel::egarch) { th_out[1] = th[1] + (1.0 - th[4]) * std::log(1.0 / (scale * scale)); }118  else { th_out[1] = th[1] / (scale * scale); jac[1] = 1.0 / (scale * scale); }119  if (opt.model != GarchModel::egarch) cov = jac.asDiagonal() * cov * jac.asDiagonal();120  else { Mat J = Mat::Identity(p, p); J(0, 0) = 1.0 / scale; J(1, 4) = -std::log(1.0 / (scale * scale)); cov = J * cov * J.transpose(); }121  inf.covariance = cov; const f64 nll_unscaled = fx - static_cast<f64>(n) * std::log(scale);  // f_{r0}(x) = scale · f_r(scale·x) ⇒ −Σ log f(r0) = −Σ log f(r) − n log(scale)122  for (Eigen::Index j = 0; j < static_cast<Eigen::Index>(p); ++j) { const f64 se = std::sqrt(std::max(0.0, cov(j, j))); inf.std_errors.push_back(se); inf.t_stats.push_back(se > 0 ? th_out[static_cast<usize>(j)] / se : std::numeric_limits<f64>::quiet_NaN()); inf.p_values.push_back(est::t_pvalue_two_sided(inf.t_stats.back(), 0)); }123  e.inference = inf; e.coefficients = th_out; e.loss = nll_unscaled;124  e.convergence = Convergence{converged, it, fevals, g.norm(), converged ? 0 : 1, msg};125  std::vector<f64> s2; garch_variance_path(r0, th_out, opt.model, opt.dist, s2);126  e.extra = {{"loglik", -nll_unscaled}, {"scale", scale}, {"sigma2_last", s2.empty() ? 0.0 : s2.back()}, {"persistence", opt.model == GarchModel::garch ? th_out[2] + th_out[3] : (opt.model == GarchModel::gjr ? th_out[2] + th_out[4] + 0.5 * th_out[3] : th_out[4])}, {"aic", 2.0 * nll_unscaled + 2.0 * static_cast<f64>(p)}, {"bic", 2.0 * nll_unscaled + std::log(static_cast<f64>(n)) * static_cast<f64>(p)}};127  if (opt.forecast_horizon > 0 && converged) e.extra["forecast_variance"] = garch_forecast(e, r0, opt.forecast_horizon, opt.plan.seed);128  e.runtime_seconds = std::chrono::duration<f64>(std::chrono::steady_clock::now() - t0).count();129  return e;130}131132std::vector<f64> garch_forecast(const Estimate& fit, std::span<const f64> r, int H, u64 seed, int n_sim) {133  RAID_REQUIRE(H >= 1, invalid_argument, "horizon >= 1");134  const std::string& m = fit.model; GarchModel model = m.find(".gjr.") != std::string::npos ? GarchModel::gjr : (m.find(".egarch.") != std::string::npos ? GarchModel::egarch : GarchModel::garch);135  GarchDist dist = m.find("student_t") != std::string::npos ? GarchDist::student_t : GarchDist::gaussian;136  const auto& th = fit.coefficients; std::vector<f64> s2; RAID_REQUIRE(garch_variance_path(r, th, model, dist, s2), numerical, "forecast: invalid variance path");137  const f64 mu = th[0], omega = th[1], alpha = th[2]; const f64 gamma = model != GarchModel::garch ? th[3] : 0.0; const f64 beta = th[model != GarchModel::garch ? 4 : 3];138  const f64 eT = r.back() - mu, s2T = s2.back(); std::vector<f64> f(static_cast<usize>(H));139  if (model != GarchModel::egarch) {140    f[0] = omega + (alpha + (eT < 0 ? gamma : 0.0)) * eT * eT + beta * s2T;141    for (int h = 1; h < H; ++h) f[static_cast<usize>(h)] = omega + (alpha + 0.5 * gamma + beta) * f[static_cast<usize>(h - 1)];142    return f;143  }144  // EGARCH: h=1 analytical, h>1 by simulation of z (Gaussian or standardized t)145  const f64 nu = dist == GarchDist::student_t ? th.back() : 0.0;146  const f64 Eabs = dist == GarchDist::student_t ? 2.0 * std::sqrt(nu - 2.0) * std::exp(std::lgamma((nu + 1) / 2) - std::lgamma(nu / 2)) / ((nu - 1) * std::sqrt(std::numbers::pi)) : std::sqrt(2.0 / std::numbers::pi);147  const f64 zT = eT / std::sqrt(s2T); const f64 l1 = omega + alpha * (std::abs(zT) - Eabs) + gamma * zT + beta * std::log(s2T); f[0] = std::exp(l1);148  if (H == 1) return f;149  std::vector<f64> acc(static_cast<usize>(H), 0.0); Rng rng(seed, 7);150  for (int s = 0; s < n_sim; ++s) { f64 ls = l1; acc[0] += std::exp(ls); for (int h = 1; h < H; ++h) { const f64 z = dist == GarchDist::student_t ? rng.student_t(nu) : rng.normal(); ls = omega + alpha * (std::abs(z) - Eabs) + gamma * z + beta * ls; acc[static_cast<usize>(h)] += std::exp(ls); } }151  for (int h = 1; h < H; ++h) f[static_cast<usize>(h)] = acc[static_cast<usize>(h)] / n_sim;152  return f;153}154155}  // namespace raid::models156
Files · v0.1.0-wp40
Menu

Theme