Back to Documentations

Signature Description Parameters
#include <DataFrame/DataFrameStatsVisitors.h>

template<arithmetic T, typename I = unsigned long>
struct  LjungBoxTestVisitor;

// -------------------------------------

template<typename T, typename I = unsigned long>
using lb_test_v = LjungBoxTestVisitor<T, I>;
Ljung-Box Test for Residual Autocorrelation
Tests whether any of the first m autocorrelations of a time series are non-zero. The null hypothesis H0 is that the data are independently distributed (i.e. the residuals are white noise).

The test statistic is:
  Q(m) = n(n+2) * Σ_{k=1}^{m}  ρ̂2(k) / (n − k)
         
where
n = sample size
m = number of lags tested
ρ̂̂(k) = sample autocorrelation at lag k

Under H0 the statistic Q(m) is asymptotically Χ2(m) distributed when the series has no fitted parameters (raw residuals). When the series is the residual of an ARIMA(p, d, q) model, the degrees of freedom should be reduced to m − p − q.

The p-value is the Χ2(dof) survival function, computed via the regularized upper incomplete gamma function:
  p = 1 − P(dof / 2, Q / 2) = Γ(dof / 2, Q / 2) / Γ(dof / 2)
         
A small p-value (conventionally < 0.05) gives evidence against H0, i.e. the series has significant autocorrelation up to lag m. Individual per-lag Q statistics and autocorrelations are also available so that callers can pinpoint which lags are driving any rejection.
This visitor is the natural companion to ARIMAVisitor and HWESForecastVisitor; run the forecast residuals through LjungBoxTestVisitor to confirm that no predictable structure was left in the errors.
  References:
    Ljung, G.M. and Box, G.E.P. (1978). "On a measure of lack of fit in
    time series models", Biometrika 65(2): 297–303.

    Box, G.E.P. and Pierce, D.A. (1970). "Distribution of residual
    autocorrelations in autoregressive-integrated moving average time
    series models", JASA 65(332): 1509–1526.
         
get_result() Overall Q(m) statistic
get_p_value() χ2(dof) p-value of the overall Q(m)
get_acf() Per-lag autocorrelations ρ̂(1) … ρ̂(m), size == max_lag
get_q_stats() Cumulative Q(k) statistics k=1…m, size == max_lag
get_p_values() Per-lag p-values for each Q(k), size == max_lag
explicit
LjungBoxTestVisitor(size_type max_lag, size_type dof_adjust = 0);
        
max_lag() Number of lags m to include in the Q statistic. The rule of thumb is min(10, n/5) for non-seasonal data (Hyndman & Athanasopoulos).
dof_adjust() Subtract this from max_lag to get the Χ2 degrees of freedom. Set to p+q when testing ARIMA(p,d,q) residuals. Default is 0 (raw series / white-noise test).
T: Column data type.
I: Index type.
static void test_LjungBoxTestVisitor()  {

    std::cout << "\nTesting LjungBoxTestVisitor{ } ..." << std::endl;

    using MyDataFrame = StdDataFrame<unsigned long>;

    constexpr std::size_t   col_s { 500 };

    std::vector<unsigned long>  idx(col_s);

    std::iota(idx.begin(), idx.end(), 0UL);

    MyDataFrame df;

    df.load_index(std::move(idx));

    RandGenParams<double>   p;

    p.seed = 123;

    // White noise
    //
    {
        df.load_column<double>("x", gen_normal_dist<double>(col_s, p));

        lb_test_v<double>   lb { 10 };

        df.single_act_visit<double>("x", lb);

        assert(std::abs(lb.get_result() - 9.70203) < 0.00001);

        // Overall Q(10) should be small, p-value large
        //
        assert(std::abs(lb.get_p_value() - 0.467012) < 0.000001);

        // Result vectors are the right length
        //
        assert(lb.get_acf().size() == 10);
        assert(lb.get_q_stats().size() == 10);
        assert(lb.get_p_values().size() == 10);

        // All per-lag ACF should be close to 0
        //
        for (const auto &rho : lb.get_acf())
            assert(std::abs(rho) < 0.2);
        assert(std::abs(lb.get_acf()[0] - -0.025529) < 0.000001);
        assert(std::abs(lb.get_acf()[5] - -0.009931) < 0.000001);
        assert(std::abs(lb.get_acf()[9] - 0.072755) < 0.000001);

        // Q stats must be non-decreasing
        //
        for (std::size_t i = 1; i < lb.get_q_stats().size(); ++i)
            assert(lb.get_q_stats()[i] >= lb.get_q_stats()[i - 1]);
        assert(std::abs(lb.get_q_stats()[0] - 0.327812) < 0.000001);
        assert(std::abs(lb.get_q_stats()[5] - 1.28697) < 0.00001);
        assert(std::abs(lb.get_q_stats()[9] - 9.70203) < 0.00001);
    }

    // AR -- strong autocorrelation at lag 1
    //
    {
        auto                y = gen_normal_dist<double>(col_s, p);
        constexpr double    phi { 0.9 };

        for (std::size_t i = 1; i < col_s; ++i)
            y[i] = phi * y[i - 1] + y[i];

        df.load_column<double>("y", std::move(y));

        lb_test_v<double>   lb { 10 };

        df.single_act_visit<double>("y", lb);

        // Strong autocorrelation — Q should be very large, p near 0
        //
        assert(std::abs(lb.get_result() - 1305.37) < 0.01);
        assert(lb.get_p_value() < 1e-20);

        // ACF at lag 1 should be strongly positive
        //
        assert(std::abs(lb.get_acf()[0] - 0.874076) < 0.000001);
    }

    // Sine wave
    //
    {
        std::vector<double> z(col_s);
        constexpr double    freq { 0.1 };

        for (std::size_t i = 0; i < col_s; ++i)
            z[i] = std::sin(2.0 * M_PI * freq * static_cast<double>(i));

        df.load_column<double>("z", std::move(z));

        lb_test_v<double>   lb { 20 };

        df.single_act_visit<double>("z", lb);

        // Strong autocorrelation — Q should be very large, p near 0
        //
        assert(std::abs(lb.get_result() - 4909.6) < 0.1);
        assert(lb.get_p_value() < 1e-20);
    }

    // Formula wave
    //
    {
        std::vector<double> A(col_s);

        for (std::size_t i = 0; i < col_s; ++i)
            A[i] = (i % 2 == 0) ? 1.0 : -1.0;

        df.load_column<double>("A", std::move(A));

        LjungBoxTestVisitor<double> lb { 2 };

        df.single_act_visit<double>("A", lb);

        assert(std::abs(lb.get_result() - 1000.99) < 0.01);
        assert(lb.get_p_value() < 1e-20);

        // rho(1) should be very close to −1
        // rho(2) should be very close to +1
        //
        assert(lb.get_acf().size() == 2);
        assert(std::abs(lb.get_acf()[0] - -0.998) < 0.001);
        assert(std::abs(lb.get_acf()[1] - 0.996) < 0.001);

        // Verify Q(2) against the formula
        //
        const double nd { static_cast<double>(col_s) };
        const double r1 { lb.get_acf()[0] };
        const double r2 { lb.get_acf()[1] };
        const double q_exp { nd * (nd + 2.0) * (r1 * r1 / (nd - 1.0) + r2 * r2 / (nd - 2.0)) };

        assert(std::abs(lb.get_q_stats()[1] - q_exp) < 1e-9);

        // p-value = chi2_survival(dof/2=1, Q/2) for each cumulative Q
        // For lag 1: dof=1, Q(1) large → p near 0
        //
        assert(lb.get_p_values()[0] < 1e-10);
        assert(lb.get_p_values()[1] < 1e-10);
    }

    // Degree of freedom (DOF) adjustment for ARIMA resdiduals
    //
    {
        df.load_column<double>("B", gen_normal_dist<double>(col_s, p));

        lb_test_v<double>   lb_raw { 10, 0 };  // no adjustment
        lb_test_v<double>   lb_adj { 10, 1 };  // AR(1) residuals

        df.single_act_visit<double>("B", lb_raw);
        df.single_act_visit<double>("B", lb_adj);

        // Q stats must be identical (adjustment only affects dof, not Q)
        //
        const auto  &qs_raw { lb_raw.get_q_stats() };
        const auto  &qs_adj { lb_adj.get_q_stats() };

        for (std::size_t i = 0; i < 10; ++i)
            assert(std::abs(qs_raw[i] - qs_adj[i]) < 1e-12);

        // ACF must be identical too
        //
        const auto  &acf_raw { lb_raw.get_acf() };
        const auto  &acf_adj { lb_adj.get_acf() };

        for (std::size_t i = 0; i < 10; ++i)
            assert(std::abs(acf_raw[i] - acf_adj[i]) < 1e-12);

        // p-values differ because dof differs.
        // With dof_adjust=1 each lag k uses dof=k-1 instead of dof=k.
        // For moderate Q values the χ^2(k-1) right tail is lighter than the
        // χ^2(k) right tail, so a smaller dof gives a smaller p-value.
        // At lag 1 dof clamps to 1 in both cases, so lag 0 is excluded.
        //
        const auto  &pv_raw { lb_raw.get_p_values() };
        const auto  &pv_adj { lb_adj.get_p_values() };

        for (std::size_t i = 1; i < 10; ++i)
            assert(pv_adj[i] < pv_raw[i]);

        assert(lb_raw.get_p_value() > lb_adj.get_p_value());
        assert(std::abs(lb_raw.get_result() - lb_adj.get_result()) < 1e-12);
    }
}

C++ DataFrame