Python · FunctionalMH on GitHub

The problem

Most stationarity tests are built for one-dimensional series and one specific parametric model. A unit-root test on an AR(1), say. That breaks down once the true process is nonlinear or multidimensional. Fitting the wrong model and then testing that model for a unit root tells you very little about the real process.

This project takes a different route. It builds a nonparametric test that works for a broad class of multidimensional Itô diffusions, without assuming any parametric drift or diffusion function up front. It became the open-source FunctionalMH library, written for my MSc thesis (Testing Multidimensional Diffusion Processes for Stationarity, VU Amsterdam, supervised by Eric Beutner and Yicong Lin) in Econometrics and Operations Research.

The setting: a \(d\)-dimensional Itô diffusion process \((S_t)_{t\ge0}\), with state space \(\mathbb{R}^d\), given as the strong solution of \[S_0 = S, \qquad \mathrm{d}S_t = b(S_t)\,\mathrm{d}t + \sigma(S_t)\,\mathrm{d}W_t, \qquad t \ge 0,\] where \(b\) is the drift, \(\sigma\) is the diffusion coefficient, and \(W\) is a \(d\)-dimensional Brownian motion. You observe one trajectory of \(S\), sampled at discrete times. You don't know \(b\) or \(\sigma\). The question: is \(S\) stationary?

Three conditions make the test work. First, \(b\) and \(\sigma\) satisfy Lipschitz and linear-growth conditions, so a unique, non-exploding solution exists. Second, the diffusion coefficient is uniformly elliptic: the process moves with comparable variance in every direction, no direction is degenerate. This rules out cyclic paths and keeps the process aperiodic. Third, the process is Harris recurrent: every region of positive measure gets visited infinitely often. This guarantees an invariant measure exists, and it forces the process's occupation measure, a running count of how long the process has spent near a given point, to grow without bound. That growth rate is what the test statistic actually measures. Together, these three conditions cover a wide class of financial and physical diffusion models, while ruling out edge cases like Brownian motion with drift, which only visits most of its state space finitely often.

How the test works

The key result, due to Darling & Kac (1957) and sharpened by Lazić & Sandrić (2021) and Lee & Trutnau (2022): for a Harris recurrent, uniformly elliptic diffusion, stationarity is equivalent to the occupation measure growing linearly in time. A nonstationary process grows sub-linearly instead. So instead of testing a parametric hypothesis about drift or mean reversion, the test asks a simpler question. Does the process revisit its own state space at a linear rate, or slower?

To turn that into a number, the test compares two estimators of the diffusion matrix. Both are consistent whether or not the process is stationary, but they get there at different speeds.

  • A time-domain estimator: a local, EWMA-style average of squared increments in a shrinking window around a fixed point in time. Its convergence speed doesn't depend on stationarity, since it only looks at data close in time to the point being estimated.
  • A state-domain estimator: a Nadaraya–Watson kernel regression that averages squared increments over every observation near a given state, regardless of when it happened. Its speed depends on the occupation measure at that state. It converges faster once the process has spent more time nearby, and that only happens at a linear rate if the process is stationary.

This is the same idea as a Durbin–Wu–Hausman test: two estimators of the same thing that agree under the null and diverge under the alternative. Here, the null is that \(S\) is stationary. Under the null, the standardised difference between the two estimators converges to a mean-zero Gaussian sequence. Under the alternative, it diverges, because the state-domain estimator slows down while the time-domain estimator keeps its usual speed.

Because the diffusion matrix is estimated at every point along the trajectory, this comparison doesn't give one test statistic. It gives a whole sequence of them, roughly one per observed time point. The test needs one number to make a decision from that sequence, so it borrows an idea from extreme value theory. Under the null, the sequence behaves like a stationary Gaussian process, and the running maximum of such a process has a known, closed-form Gumbel-type limit. The test rejects stationarity if the observed running maximum crosses that limit. No bootstrap, no simulated critical values, just an analytic bound.

How the code is organised

Note. Everything below is written for bivariate processes (\(d=2\)). The theory above holds for any dimension \(d\), and models/processes.py includes simulators in other dimensions for reference, but the estimator and test statistic in testing/kernel_test.py are built specifically around \(2\times2\) diffusion matrices. The vech half-vectorisation, the matrix square-root inverse, and the density estimate inside the state-domain smoother all assume \(d=2\).

The package lives under src/mht/. testing/kernel_test.py has the core test: Kernel, KernelTest, Simulator, TestPlotter. testing/hypothesis.py adds MultipleHypTest, a Benjamini–Hochberg/Yekutieli FDR-control alternative to the running-maximum approach below. models/processes.py has four SDE simulators, including a bivariate correlated diffusion with a Milstein scheme and an explicit correction term for its polynomial diffusion coefficient. io/reader.py loads precomputed simulation CSVs.

Everything runs through one object, KernelTest. You give it a trajectory and two configuration dictionaries, and it takes you from raw data to a rejection decision in four steps: prepare the data, pick a kernel and bandwidth, compute the two estimators, then reduce them to one test statistic. The walkthrough below follows those four steps in order, with the actual code at each step.

Step 1. Load the trajectory

KernelTest is a thin, stateful container. Pass it a bivariate trajectory, either simulated with BivariateOUProcess/BivariateCorrelatedBM or read from a CSV, and it stores the configuration for the two estimators. Nothing gets computed yet, the estimates only fill in once you call the methods in steps 3 and 4.

class KernelTest:
    """Kernel-based test for time-homogeneity of the diffusion matrix.

    Compares a state-domain smoother against a time-domain smoother of the
    integrated diffusion matrix. Under H0 (time-homogeneous diffusion) the
    standardised difference converges to a Gaussian process whose running
    maximum has a known Gumbel-type limit distribution.
    """

    def __init__(
        self,
        data: pd.DataFrame,        # columns ['process 1', 'process 2']
        kernel_params: dict,       # {'bandwidth', 'n', 'T', 'kernel'}
        time_params: dict,         # {'bandwidth', 'n', 'T'}
        disable: bool = False,     # suppress tqdm progress bars
    ) -> None:
        self.data = data
        self.kernel_params = kernel_params
        self.time_params = time_params
        self.disable = disable
        self.kernel_estimates: dict = {}
        self.time_estimates: dict = {}

If the data is simulated on a grid of \(n\) observations over horizon \(T\), the effective sampling interval is \(\Delta_n = T/n\). That is the number every bandwidth below is tuned against.

Step 2. Pick a kernel and bandwidth

The state-domain smoother needs a kernel and a bandwidth. The default kernel is Kernel.BaseKernel, a plain boxcar: weight 1 inside the bandwidth, 0 outside. The time-domain smoother needs its own, separately tuned bandwidth. Both follow the near-optimal rates from Bandi & Moloche (2018), so you don't have to hand-tune a new constant for every dataset, Simulator looks them up from a table keyed on horizon length.

class Kernel:
    """Kernel functions for the state-domain (Nadaraya-Watson) smoother."""

    def __init__(self, *, kernel_params: dict) -> None:
        self.kernel_params = kernel_params

    def BaseKernel(self) -> Callable:
        """Boxcar/indicator kernel, K(x) = 1{|x| <= h}."""
        def k(x: np.ndarray) -> np.ndarray:
            return np.where(np.abs(x) <= 1, 1.0, 0.0)
        return k

Putting steps 1 and 2 together, this is what setting up a test looks like in practice. The bandwidth here follows the near-optimal rate \(h_{n,T} = C/(n^{1/6}\log n)\) from Bandi & Moloche (2018), with the constant \(C\) tuned once per horizon length.

from mht.testing.kernel_test import KernelTest, Kernel

n, T = 3000, 150.0
bandwidth = np.sqrt(3) * 6 / (n ** (1 / 6) * np.log(n))

test = KernelTest(
    data=trajectory,                                  # DataFrame, shape (n, 2)
    kernel_params={
        'bandwidth': bandwidth,
        'n': n, 'T': T,
        'kernel': Kernel.BaseKernel,
    },
    time_params={'bandwidth': 100 * T / n, 'n': n, 'T': T},
    disable=True,
)

Step 3. Compute both estimators

Three calls do the real work of this step. time_domain_smoother builds the EWMA-weighted time-domain estimator. state_domain_smoother builds the Nadaraya–Watson state-domain estimator, optionally using a kernel density estimate of the state instead of a raw occupation count. gauss then takes the standardised difference between the two, through a matrix square-root inverse of their combined covariance. If that matrix inversion fails numerically, it falls back to NaN rather than crashing, worth knowing, since a bad inversion could otherwise look like evidence against the null.

test.time_domain_smoother(lamb=0.94)     # EWMA time-domain estimator ĉ_TD
test.state_domain_smoother(dist=False)   # Nadaraya-Watson state-domain estimator ĉ_SD
test.gauss()                             # standardised difference, stored in test.gaussian

Before computing any test statistic, plot the two estimates against each other. They track the same diffusion coefficient, just from different data, so any real structural gap between them is exactly what the test is built to pick up later. The two figures below run this on two simulated bivariate trajectories: a stationary Ornstein–Uhlenbeck process, and a time-inhomogeneous diffusion where the true volatility drifts over time.

State-domain versus time-domain volatility estimate, one panel per process component, for a simulated stationary bivariate Ornstein-Uhlenbeck process

Stationary bivariate OU process, \(T=150\), \(n=3000\), one panel per component. The two estimators track each other closely throughout. Exactly what the null hypothesis predicts.

State-domain versus time-domain volatility estimate, one panel per process component, for a simulated time-inhomogeneous bivariate diffusion

Time-inhomogeneous bivariate diffusion, same \(T\) and \(n\). The state-domain smoother pools observations across the whole trajectory, so it overshoots the time-domain smoother whenever the true diffusion coefficient is moving.

Step 4. Reduce to one test statistic

gauss leaves you with a sequence of standardised differences, one per observed time point, not a single number. transform_1D_gauss is the last step: it collapses that sequence into a scalar running maximum and pairs it with the analytic Pickands–Berman Gumbel bound at a given confidence level. If the running maximum crosses the bound, the test rejects stationarity. No simulation or bootstrap is needed for the critical value, it comes out of the extreme-value theory directly.

def transform_1D_gauss(self, alpha: float = 0.95) -> tuple:
    """Reduce the Gaussian process to a scalar running maximum and its
    Gumbel-type critical bound at confidence level alpha."""
    x = np.log(1 / np.log(1 / alpha))
    n = len(self.gaussian)
    a_n = [np.sqrt(2 * np.log(z)) for z in range(1, n + 1)]
    b_n = [
        np.sqrt(2 * np.log(z))
        - np.log(np.pi * np.log(z)) / (2 * np.sqrt(2 * np.log(z)))
        for z in range(1, n + 1)
    ]
    bound = [np.nan] + [(x / a_n[i]) + b_n[i] for i in range(1, n)]
    scalar_gauss = [float(np.sum(g) / np.sqrt(3)) for g in self.gaussian]
    return bound, scalar_gauss

Calling it and checking the result is one line each.

bound, z = test.transform_1D_gauss(alpha=0.95)
rejects = running_maximum(z)[-1] > bound[-1]

Here is that check on the same two trajectories from step 3. On the stationary process, the running maximum settles below the critical bound early and stays there. On the time-inhomogeneous process, it crosses the bound within the first few hundred observations and keeps climbing, since the standardised difference between the two estimators never stabilises once the true diffusion coefficient starts moving.

Running maximum test statistic for the stationary Ornstein-Uhlenbeck process, staying below the Gumbel critical bounds at every confidence level

The standardised Gaussian process \(Z_t\) (grey), its empirical running maximum \(\phi_j\) (red), and the Pickands–Berman critical bound at four confidence levels. The running maximum never crosses even the loosest (50%) bound, so the test fails to reject stationarity.

Running maximum test statistic for the nonstationary time-inhomogeneous diffusion, sharply exceeding all Gumbel critical bounds

Same construction on the time-inhomogeneous diffusion. The running maximum clears every bound within the first few hundred observations and never comes back. A clear rejection of stationarity.

Why the code is split this way

Each piece was pulled apart for a reason. time_domain_smoother and state_domain_smoother stay separate methods, not one combined estimator, because they use different amounts of data at different rates. Keeping them apart also makes it easy to swap in a different smoother later, without touching the rest of the pipeline. gauss is its own method because it is the one step doing a numerically fragile matrix inversion, so isolating it makes that failure mode easy to spot. transform_1D_gauss is kept separate from gauss because picking a confidence level and reducing to a running maximum is a modelling choice, distinct from estimating the standardised difference itself. Simulator drives Monte Carlo replication and picks bandwidths automatically, so a simulation study doesn't need manual tuning for every run. TestPlotter subclasses KernelTest to add the plotting methods used for the figures above, so a plot always reflects whatever configuration produced the underlying test object. Numerical safety nets run throughout: fragile matrix inversions return NaN instead of raising, and a caching decorator falls back gracefully when it's handed something unhashable, like a DataFrame.

Testing and validation

tests/test_processes.py and tests/test_kernel_test.py are smoke tests. They check that simulated paths are finite and correctly shaped, and that the pipeline runs end to end and returns a finite bound. They don't check actual rejection rates. simulations/ holds precomputed CSVs of Gaussian-process paths under the null, at two sample sizes, used for a batch rejection-rate study of the test itself.

In the thesis's own simulation study, the test lands close to its nominal 5% rejection rate on a stationary bivariate Ornstein–Uhlenbeck process as the sample grows. It rejects a nonstationary planar Brownian motion quickly. On a time-inhomogeneous bivariate diffusion, it correctly flags nonstationarity, but with a real limitation: power falls as \(T\) grows while the sampling interval \(\Delta_n\) is held fixed. That happens because the process, viewed at any single fixed timescale, starts to look like a stationary Ornstein–Uhlenbeck process locally. It's the same caveat from the theory section above: the running-maximum approximation needs a big enough sample for the extreme-value asymptotics to actually hold.

Repository structure

src/mht/
    testing/
        kernel_test.py        # KernelTest, Simulator, TestPlotter
        hypothesis.py         # MultipleHypTest, LaTeXTable
    models/
        processes.py          # BivariateOUProcess, BivariateCorrelatedBM, ...
    io/
        reader.py             # Reader class for simulation CSV files
    viz/                      # TestPlotter re-exported here
    utils/
        decorators.py
simulations/                  # Pre-computed CSV simulation results
notebooks/
    example.ipynb
tests/
    test_processes.py
    test_kernel_test.py

Requires Python ≥ 3.10. Also includes BH/BY FDR procedures as an alternative multiple-hypothesis baseline for simulation studies.