mavats

Scientific methods for matrix- and tensor-valued time series.

Arrays are time-first. Tucker factor results use orthonormal loadings; additive dynamic factors and legacy submodules have their own documented normalizations. See the repository's docs/methods.md for assumptions, references and coverage gaps. This development release is not a claim of complete literature coverage.

Start with the real-world data protocol and method-by-method visual gallery. These source-distribution examples distinguish forecasts from same-day reconstruction and retain failed fits and scientific applicability warnings.

  1"""Scientific methods for matrix- and tensor-valued time series.
  2
  3Arrays are time-first. Tucker factor results use orthonormal loadings; additive
  4dynamic factors and legacy submodules have their own documented normalizations.
  5See the repository's
  6docs/methods.md for assumptions, references and coverage gaps. This development
  7release is not a claim of complete literature coverage.
  8
  9Start with the [real-world data protocol](https://github.com/dx-li/MaVaTS/blob/v0.2.0a2/docs/real-world-examples.md)
 10and [method-by-method visual gallery](https://github.com/dx-li/MaVaTS/blob/v0.2.0a2/docs/gallery/index.md).
 11These source-distribution examples distinguish forecasts from same-day
 12reconstruction and retain failed fits and scientific applicability warnings.
 13"""
 14
 15from .autoregression import (
 16    MARRankSelection,
 17    MARResult,
 18    fit_mar,
 19    nearest_kronecker_product,
 20    select_mar_rank,
 21)
 22from .baselines import VARResult, fit_naive, fit_var
 23from .calibration import MonitorCalibration, calibrate_monitor
 24from .cointegration import CMARI1Diagnostics, CMARResult, cmar_i1_diagnostics, fit_cmar
 25from .constrained import (
 26    MultiTermConstrainedFactorResult,
 27    PartialConstrainedFactorResult,
 28    fit_constrained_factor,
 29    fit_multiterm_constrained_factor,
 30    fit_partial_constrained_factor,
 31)
 32from .cp import CPIdentificationError, CPResult, fit_cp_factor
 33from .decorrelation import MatrixDecorrelationResult, fit_matrix_decorrelation
 34from .dynamic import TwoWayDynamicResult, fit_two_way_dynamic
 35from .envelope import EnvelopeMARResult, fit_envelope_mar
 36from .factors import (
 37    FactorResult,
 38    eigenvalue_ratio,
 39    fit_alpha_pca,
 40    fit_lagged_factor,
 41    fit_projected_pca,
 42)
 43from .huber import HuberFactorResult, fit_huber_factor
 44from .ihr import IHRFactorResult, IHRRankResult, fit_ihr_factor, select_ihr_ranks
 45from .inference import (
 46    MARInferenceResult,
 47    MARSpecificationResult,
 48    mar_inference,
 49    mar_specification_test,
 50)
 51from .marma import (
 52    MARMADiagnostics,
 53    MARMAFilterResult,
 54    MARMAOptimizationRun,
 55    MARMAParameters,
 56    MARMAResult,
 57    MARMASimulation,
 58    filter_marma,
 59    fit_marma,
 60    marma_diagnostics,
 61    simulate_marma,
 62)
 63from .monitoring import MatrixFactorMonitor, MonitorStep
 64from .rank_selection import (
 65    RankSelectionStep,
 66    TensorRankSelectionResult,
 67    select_tensor_rank,
 68)
 69from .rank_stability import (
 70    RankStabilityCell,
 71    RankStabilityChoice,
 72    RankStabilityInterval,
 73    TensorRankStabilityResult,
 74    tensor_rank_stability,
 75)
 76from .robust import fit_matrix_kendall, matrix_kendall
 77from .sparse import SparseMARResult, fit_sparse_mar
 78from .tensor import fit_tensor_factor
 79from .tensor_autoregression import TensorARResult, fit_tensor_ar
 80from .threshold import ThresholdFactorResult, fit_threshold_factors
 81from .volatility import (
 82    MatrixGARCHFilterResult,
 83    MatrixGARCHForecast,
 84    MatrixGARCHParameters,
 85    MatrixGARCHResult,
 86    MatrixGARCHSimulation,
 87    MatrixGARCHState,
 88    filter_matrix_garch,
 89    fit_matrix_garch,
 90    simulate_matrix_garch,
 91)
 92
 93__version__ = "0.2.0a2"
 94
 95__all__ = [
 96    "EnvelopeMARResult",
 97    "fit_envelope_mar",
 98    "RankSelectionStep",
 99    "TensorRankSelectionResult",
100    "select_tensor_rank",
101    "RankStabilityCell",
102    "RankStabilityChoice",
103    "RankStabilityInterval",
104    "TensorRankStabilityResult",
105    "tensor_rank_stability",
106    "MultiTermConstrainedFactorResult",
107    "PartialConstrainedFactorResult",
108    "fit_multiterm_constrained_factor",
109    "fit_partial_constrained_factor",
110    "MARMADiagnostics",
111    "MARMAFilterResult",
112    "MARMAOptimizationRun",
113    "MARMAParameters",
114    "MARMAResult",
115    "MARMASimulation",
116    "filter_marma",
117    "fit_marma",
118    "marma_diagnostics",
119    "simulate_marma",
120    "CMARI1Diagnostics",
121    "CMARResult",
122    "cmar_i1_diagnostics",
123    "fit_cmar",
124    "IHRFactorResult",
125    "IHRRankResult",
126    "fit_ihr_factor",
127    "select_ihr_ranks",
128    "TensorARResult",
129    "fit_tensor_ar",
130    "MatrixFactorMonitor",
131    "MonitorStep",
132    "MonitorCalibration",
133    "calibrate_monitor",
134    "TwoWayDynamicResult",
135    "fit_two_way_dynamic",
136    "MatrixGARCHFilterResult",
137    "MatrixGARCHForecast",
138    "MatrixGARCHParameters",
139    "MatrixGARCHResult",
140    "MatrixGARCHSimulation",
141    "MatrixGARCHState",
142    "filter_matrix_garch",
143    "fit_matrix_garch",
144    "simulate_matrix_garch",
145    "MARInferenceResult",
146    "MARSpecificationResult",
147    "MatrixDecorrelationResult",
148    "SparseMARResult",
149    "ThresholdFactorResult",
150    "fit_matrix_decorrelation",
151    "fit_sparse_mar",
152    "fit_threshold_factors",
153    "mar_inference",
154    "mar_specification_test",
155    "HuberFactorResult",
156    "fit_huber_factor",
157    "CPIdentificationError",
158    "CPResult",
159    "fit_cp_factor",
160    "FactorResult",
161    "MARResult",
162    "MARRankSelection",
163    "VARResult",
164    "eigenvalue_ratio",
165    "fit_alpha_pca",
166    "fit_constrained_factor",
167    "fit_lagged_factor",
168    "fit_mar",
169    "fit_matrix_kendall",
170    "fit_naive",
171    "fit_projected_pca",
172    "fit_tensor_factor",
173    "fit_var",
174    "matrix_kendall",
175    "nearest_kronecker_product",
176    "select_mar_rank",
177]
@dataclass
class EnvelopeMARResult(mavats.MARResult):
113@dataclass
114class EnvelopeMARResult(MARResult):
115    """Fixed-dimension EMAR estimate and local-optimization diagnostics.
116
117    The model paper and references of :func:`fit_envelope_mar` also apply to
118    all inherited forecast/covariance methods. ``row_envelope`` and
119    ``column_envelope`` are orthonormal bases; compare their projectors, not
120    their coordinates. Every lag's output lies in these spaces and each
121    innovation covariance is reduced by the corresponding space.
122
123    ``optimization_history`` records every attempted sweep, with row/column
124    selected-start and per-start objectives, gradient norms and stop reasons.
125    ``converged`` requires both outer likelihood and selected inner gradient
126    criteria, not global optimality or stationarity of the time series.
127    ``warmup_converged`` concerns only the unrestricted initialization.
128    ``objective_history`` contains accepted complete EMAR sweeps on scaled
129    data, excluding the unrestricted warmup. Covariance scaling and physical
130    likelihood conversion follow MARResult. Dense diagnostic allocations are
131    guarded by ``max_dense_elements``; matrix-form forecasts avoid them.
132    """
133
134    envelope_dims: tuple = (0, 0)
135    row_envelope: np.ndarray = field(default_factory=lambda: np.empty((0, 0)))
136    column_envelope: np.ndarray = field(default_factory=lambda: np.empty((0, 0)))
137    optimization_history: tuple = ()
138    warmup_converged: bool | None = None
139    stop_reason: str = "max_iter"
140    likelihood_converged: bool = False
141    inner_converged: bool = False
142    max_dense_elements: int = 10_000_000
143
144    def _guard_dense(self, count):
145        if count > self.max_dense_elements:
146            raise ValueError(
147                "dense diagnostic exceeds max_dense_elements; matrix-form forecasting is still available"
148            )
149
150    @property
151    def coefficients(self):
152        self._guard_dense(self.order * self.intercept.size**2)
153        return super().coefficients
154
155    @property
156    def spectral_radius(self):
157        if self.order > 1:
158            self._guard_dense((self.order * self.intercept.size) ** 2)
159        return super().spectral_radius
160
161    @property
162    def log_likelihood(self):
163        """Physical-unit conditional Gaussian likelihood, without squaring scale."""
164        return float(
165            -self.objective_history[-1] - self.residuals.size * np.log(self.data_scale)
166        )
167
168    def residual_covariance(self, *, ddof=0):
169        self._guard_dense(self.intercept.size**2)
170        return super().residual_covariance(ddof=ddof)

Fixed-dimension EMAR estimate and local-optimization diagnostics.

The model paper and references of fit_envelope_mar() also apply to all inherited forecast/covariance methods. row_envelope and column_envelope are orthonormal bases; compare their projectors, not their coordinates. Every lag's output lies in these spaces and each innovation covariance is reduced by the corresponding space.

optimization_history records every attempted sweep, with row/column selected-start and per-start objectives, gradient norms and stop reasons. converged requires both outer likelihood and selected inner gradient criteria, not global optimality or stationarity of the time series. warmup_converged concerns only the unrestricted initialization. objective_history contains accepted complete EMAR sweeps on scaled data, excluding the unrestricted warmup. Covariance scaling and physical likelihood conversion follow MARResult. Dense diagnostic allocations are guarded by max_dense_elements; matrix-form forecasts avoid them.

EnvelopeMARResult( left: numpy.ndarray, right: numpy.ndarray, intercept: numpy.ndarray, fitted_values: numpy.ndarray, residuals: numpy.ndarray, method: str, converged: bool, n_iter: int, objective_history: numpy.ndarray, row_covariance: numpy.ndarray | None = None, column_covariance: numpy.ndarray | None = None, covariance_regularized: bool = False, ridge: float = 0.0, ranks: tuple | None = None, data_scale: float = 1.0, _history: numpy.ndarray = <factory>, envelope_dims: tuple = (0, 0), row_envelope: numpy.ndarray = <factory>, column_envelope: numpy.ndarray = <factory>, optimization_history: tuple = (), warmup_converged: bool | None = None, stop_reason: str = 'max_iter', likelihood_converged: bool = False, inner_converged: bool = False, max_dense_elements: int = 10000000)
envelope_dims: tuple = (0, 0)
row_envelope: numpy.ndarray
column_envelope: numpy.ndarray
optimization_history: tuple = ()
warmup_converged: bool | None = None
stop_reason: str = 'max_iter'
likelihood_converged: bool = False
inner_converged: bool = False
max_dense_elements: int = 10000000
coefficients
150    @property
151    def coefficients(self):
152        self._guard_dense(self.order * self.intercept.size**2)
153        return super().coefficients

Dense column-major VAR coefficients; allocates p*(m*n)**2 entries.

spectral_radius
155    @property
156    def spectral_radius(self):
157        if self.order > 1:
158            self._guard_dense((self.order * self.intercept.size) ** 2)
159        return super().spectral_radius

Companion spectral radius (dense companion for order > 1).

log_likelihood
161    @property
162    def log_likelihood(self):
163        """Physical-unit conditional Gaussian likelihood, without squaring scale."""
164        return float(
165            -self.objective_history[-1] - self.residuals.size * np.log(self.data_scale)
166        )

Physical-unit conditional Gaussian likelihood, without squaring scale.

def residual_covariance(self, *, ddof=0):
168    def residual_covariance(self, *, ddof=0):
169        self._guard_dense(self.intercept.size**2)
170        return super().residual_covariance(ddof=ddof)

Centered covariance of column-major residuals (dense mn by mn).

def fit_envelope_mar( X, envelope_dims, *, order=1, fit_intercept=True, max_iter=100, tol=1e-08, inner_max_iter=200, inner_tol=1e-06, inner_starts=3, random_state=0, initial=None, initial_covariance=None, initial_envelopes=None, warmup_max_iter=100, max_dense_elements=10000000):
173def fit_envelope_mar(
174    X,
175    envelope_dims,
176    *,
177    order=1,
178    fit_intercept=True,
179    max_iter=100,
180    tol=1e-8,
181    inner_max_iter=200,
182    inner_tol=1e-6,
183    inner_starts=3,
184    random_state=0,
185    initial=None,
186    initial_covariance=None,
187    initial_envelopes=None,
188    warmup_max_iter=100,
189    max_dense_elements=10_000_000,
190):
191    """Fit Gaussian envelope MAR(p) with fixed positive envelope dimensions.
192
193    Parameters
194    ----------
195    X : array_like, shape (T, m, n)
196        Finite real time-first observations. All lags share the same N=T-p
197        regression rows. Missing observations are not supported.
198    envelope_dims : (u, v)
199        Response envelope dimensions, 1 <= u <= m and 1 <= v <= n.
200        A_l = R eta_l and B_l = C xi_l; past inputs are NOT projected onto
201        R and C. Covariances have material and orthogonal-complement blocks.
202        Full dimensions give the unrestricted separable MAR likelihood.
203    order : int, default 1
204        Number of distinct bilinear lag terms with shared output envelopes.
205    fit_intercept : bool, default True
206        Profile a free intercept by centering response and each lag over the
207        same regression sample; restore the intercept in original units.
208    max_iter, tol : int, float
209        Complete row/column sweeps and relative negative-loglikelihood
210        tolerance on data divided by its maximum absolute entry.
211    inner_max_iter, inner_tol, inner_starts : int, float, int
212        Grassmann iterations, absolute gradient tolerance, and fresh starts
213        per block. Starts are the smallest residual eigenvectors, smallest
214        response eigenvectors, then random orthonormal bases. The previous
215        envelope is always an additional start. No global optimum guarantee.
216    random_state : int or numpy.random.Generator, default 0
217        Local random starts; does not change NumPy's global RNG.
218    initial : (left, right), optional
219        Finite factors shaped (p,m,m), (p,n,n); 2-D accepted for p=1.
220        Bypasses unrestricted MAR-MLE warmup. Without this, the paper's
221        unrestricted warmup uses up to warmup_max_iter sweeps.
222    initial_covariance : (row, column), optional
223        SPD covariance factors in physical units, divided by data_scale
224        individually for computation. With no initial coefficients these
225        initialize the unrestricted warmup. Otherwise default to identities
226        in scaled-data units.
227    initial_envelopes : (R, C), optional
228        Orthonormal bases shaped (m,u), (n,v), used as extra first starts.
229        Without supplied bases, an unrestricted warmup also supplies leading
230        left singular vectors of its concatenated row/column coefficients.
231        These are additional numerical starts, not fixed envelope estimates.
232    warmup_max_iter : int, default 100
233        Unrestricted MAR-MLE initialization budget, separately diagnosed.
234    max_dense_elements : int, default 10000000
235        Guard for dense result diagnostics and automatic projection warmup.
236        Larger warmups use matrix-sized identity initialization instead.
237
238    Returns
239    -------
240    EnvelopeMARResult
241        Bilinear factors, reducing spaces, separable covariance, forecasts
242        and inner/outer optimization diagnostics. Inspect ``converged``.
243
244    Notes
245    -----
246    Implements equations (15)--(16) using joint-lag, whitened SVD regressions
247    and the actual logdet Grassmann profile, not PCA or reduced-rank ALS.
248    Singular weighted designs and numerically singular residual moments
249    raise: no ridge, covariance flooring or pseudodeterminant is substituted.
250    Each nonzero lag pair is scale/sign normalized separately. Stability is
251    diagnosed, never imposed. Only fixed-dimension point estimation is
252    implemented: no dimension/order selection, sparse SEMAR, standard errors
253    or the separate one-sided identity-coefficient model.
254
255    References
256    ----------
257    Samadi, S. Y. and De Alwis, T. P. (2026), "Envelope Matrix Autoregressive
258    Models", Journal of Business & Economic Statistics 44(2), 397--412,
259    equations (13), (15)--(16), Algorithm 1,
260    https://doi.org/10.1080/07350015.2025.2537404.
261    Numerical solver and source-equation corrections are documented in
262    docs/envelope-notes.md; this is not an exact replication of paper code.
263    """
264    order = positive_int(order, "order")
265    original = as_series(X, min_samples=order + 2)
266    m, n = original.shape[1:]
267    dims = ranks_tuple(envelope_dims, (m, n))
268    if dims is None:
269        raise ValueError("envelope_dims must specify two positive dimensions")
270    max_iter = positive_int(max_iter, "max_iter")
271    inner_max_iter = positive_int(inner_max_iter, "inner_max_iter")
272    inner_starts = positive_int(inner_starts, "inner_starts")
273    warmup_max_iter = positive_int(warmup_max_iter, "warmup_max_iter")
274    max_dense_elements = positive_int(max_dense_elements, "max_dense_elements")
275    tol = finite_scalar(tol, "tol", minimum=0)
276    inner_tol = finite_scalar(inner_tol, "inner_tol", minimum=0)
277    if not isinstance(fit_intercept, (bool, np.bool_)):
278        raise ValueError("fit_intercept must be boolean")
279    rng = random_generator(random_state)
280    scale = float(np.max(np.abs(original))) or 1.0
281    data = original / scale
282    raw_lags = [data[order - lag : len(data) - lag] for lag in range(1, order + 1)]
283    mean = data[order:].mean(axis=0) if fit_intercept else np.zeros((m, n))
284    lag_means = [
285        z.mean(axis=0) if fit_intercept else np.zeros((m, n)) for z in raw_lags
286    ]
287    response = data[order:] - mean
288    lags = [z - mu for z, mu in zip(raw_lags, lag_means)]
289    bases = [None, None]
290    if initial_envelopes is not None:
291        if len(initial_envelopes) != 2:
292            raise ValueError("initial_envelopes must contain row and column bases")
293        bases = [
294            _basis(b, d, r, "initial envelope")
295            for b, d, r in zip(initial_envelopes, (m, n), dims)
296        ]
297    covariances = [np.eye(m), np.eye(n)]
298    if initial_covariance is not None:
299        if len(initial_covariance) != 2:
300            raise ValueError("initial_covariance must contain two matrices")
301        for i, (value, d) in enumerate(zip(initial_covariance, (m, n))):
302            raw = np.asarray(value)
303            if np.iscomplexobj(raw) or raw.dtype.kind not in "biuf":
304                raise ValueError("initial covariance must be real numeric")
305            cov = np.array(raw, dtype=float, copy=True) / scale
306            if cov.shape != (d, d) or not np.allclose(cov, cov.T, rtol=1e-10, atol=0):
307                raise ValueError(
308                    "initial covariance must be symmetric and dimensionally compatible"
309                )
310            covariances[i] = _spd(cov, "initial covariance")[0]
311    warmup_converged = None
312    if initial is None:
313        warmup = fit_mar(
314            data,
315            method="mle",
316            order=order,
317            fit_intercept=fit_intercept,
318            max_iter=warmup_max_iter,
319            tol=tol,
320            covariance_floor=0,
321            initial_covariance=tuple(covariances),
322            init="auto" if order * (m * n) ** 2 <= max_dense_elements else "identity",
323        )
324        left, right = warmup.left.copy(), warmup.right.copy()
325        covariances = [warmup.row_covariance.copy(), warmup.column_covariance.copy()]
326        warmup_converged = warmup.converged
327        if initial_envelopes is None:
328            # Covariance-eigenvector starts alone can be trapped in immaterial
329            # directions when material variance is high. Use the training-only
330            # warmup's coefficient spaces as an additional first start.
331            bases = [
332                np.linalg.svd(np.concatenate(coef, axis=1), full_matrices=False)[0][
333                    :, :rank
334                ]
335                for coef, rank in zip((left, right), dims)
336            ]
337    else:
338        if len(initial) != 2:
339            raise ValueError("initial must contain left and right coefficients")
340        coefficients = []
341        for value, d in zip(initial, (m, n)):
342            raw = np.asarray(value)
343            if np.iscomplexobj(raw) or raw.dtype.kind not in "biuf":
344                raise ValueError("initial coefficients must be real numeric")
345            array = np.array(raw, dtype=float, copy=True)
346            if order == 1 and array.ndim == 2:
347                array = array[None]
348            if array.shape != (order, d, d) or not np.isfinite(array).all():
349                raise ValueError(
350                    "initial coefficient dimensions must match order and X"
351                )
352            coefficients.append(array)
353        left, right = coefficients
354    row, column = covariances
355    history, optimization = [], []
356    likelihood_converged = inner_converged = False
357    reason = "max_iter"
358    options = dict(starts=inner_starts, rng=rng, max_iter=inner_max_iter, tol=inner_tol)
359    transposed_lags = [z.transpose(0, 2, 1) for z in lags]
360    for iteration in range(1, max_iter + 1):
361        a, new_row, r, rd = _block_update(
362            response, lags, right, column, dims[0], initial=bases[0], **options
363        )
364        new_left = np.stack(np.split(a, order, axis=1))
365        b, new_column, c, cd = _block_update(
366            response.transpose(0, 2, 1),
367            transposed_lags,
368            new_left,
369            new_row,
370            dims[1],
371            initial=bases[1],
372            **options,
373        )
374        new_right = np.stack(np.split(b, order, axis=1))
375        for lag in range(order):
376            # Never replace a zero A with an out-of-envelope coordinate vector.
377            if np.any(new_left[lag]):
378                new_left[lag], new_right[lag] = _normalize(
379                    new_left[lag], new_right[lag]
380                )
381        norm = np.linalg.norm(new_row)
382        new_row, new_column = new_row / norm, new_column * norm
383        residual = response - _prediction(lags, new_left, new_right)
384        value = _nll(residual, new_row, new_column)
385        if not np.isfinite(value):
386            raise FloatingPointError("envelope likelihood exceeds floating-point range")
387        accepted = not history or value <= history[-1] + 1e-10 * max(
388            1, abs(history[-1])
389        )
390        optimization.append(
391            dict(
392                iteration=iteration,
393                row=rd,
394                column=cd,
395                accepted=accepted,
396                objective=value,
397            )
398        )
399        if not accepted:
400            reason = "likelihood_increase"
401            likelihood_converged = False
402            break
403        likelihood_converged = bool(
404            history and abs(history[-1] - value) <= tol * max(1, abs(history[-1]))
405        )
406        inner_converged = rd["converged"] and cd["converged"]
407        history.append(value)
408        left, right, row, column, bases = (
409            new_left,
410            new_right,
411            new_row,
412            new_column,
413            [r, c],
414        )
415        if likelihood_converged and inner_converged:
416            reason = "likelihood_and_gradient_tolerance"
417            break
418    intercept = mean.copy()
419    for a, mu, b in zip(left, lag_means, right):
420        intercept -= a @ mu @ b.T
421    fitted = mean + _prediction(lags, left, right)
422    with np.errstate(over="ignore", invalid="ignore"):
423        physical = [
424            intercept * scale,
425            fitted * scale,
426            (data[order:] - fitted) * scale,
427            row * scale,
428            column * scale,
429        ]
430    if not all(np.isfinite(value).all() for value in physical):
431        raise FloatingPointError(
432            "envelope fit exceeds floating-point range in physical units"
433        )
434    return EnvelopeMARResult(
435        left=left,
436        right=right,
437        intercept=physical[0],
438        fitted_values=physical[1],
439        residuals=physical[2],
440        method="envelope-mle",
441        converged=likelihood_converged and inner_converged,
442        n_iter=len(history),
443        objective_history=np.asarray(history),
444        row_covariance=physical[3],
445        column_covariance=physical[4],
446        data_scale=scale,
447        _history=original[-order:].copy(),
448        envelope_dims=dims,
449        row_envelope=bases[0],
450        column_envelope=bases[1],
451        optimization_history=tuple(optimization),
452        warmup_converged=warmup_converged,
453        stop_reason=reason,
454        likelihood_converged=likelihood_converged,
455        inner_converged=inner_converged,
456        max_dense_elements=max_dense_elements,
457    )

Fit Gaussian envelope MAR(p) with fixed positive envelope dimensions.

Parameters
  • X (array_like, shape (T, m, n)): Finite real time-first observations. All lags share the same N=T-p regression rows. Missing observations are not supported.
  • envelope_dims ((u, v)): Response envelope dimensions, 1 <= u <= m and 1 <= v <= n. A_l = R eta_l and B_l = C xi_l; past inputs are NOT projected onto R and C. Covariances have material and orthogonal-complement blocks. Full dimensions give the unrestricted separable MAR likelihood.
  • order (int, default 1): Number of distinct bilinear lag terms with shared output envelopes.
  • fit_intercept (bool, default True): Profile a free intercept by centering response and each lag over the same regression sample; restore the intercept in original units.
  • max_iter, tol (int, float): Complete row/column sweeps and relative negative-loglikelihood tolerance on data divided by its maximum absolute entry.
  • inner_max_iter, inner_tol, inner_starts (int, float, int): Grassmann iterations, absolute gradient tolerance, and fresh starts per block. Starts are the smallest residual eigenvectors, smallest response eigenvectors, then random orthonormal bases. The previous envelope is always an additional start. No global optimum guarantee.
  • random_state (int or numpy.random.Generator, default 0): Local random starts; does not change NumPy's global RNG.
  • initial ((left, right), optional): Finite factors shaped (p,m,m), (p,n,n); 2-D accepted for p=1. Bypasses unrestricted MAR-MLE warmup. Without this, the paper's unrestricted warmup uses up to warmup_max_iter sweeps.
  • initial_covariance ((row, column), optional): SPD covariance factors in physical units, divided by data_scale individually for computation. With no initial coefficients these initialize the unrestricted warmup. Otherwise default to identities in scaled-data units.
  • initial_envelopes ((R, C), optional): Orthonormal bases shaped (m,u), (n,v), used as extra first starts. Without supplied bases, an unrestricted warmup also supplies leading left singular vectors of its concatenated row/column coefficients. These are additional numerical starts, not fixed envelope estimates.
  • warmup_max_iter (int, default 100): Unrestricted MAR-MLE initialization budget, separately diagnosed.
  • max_dense_elements (int, default 10000000): Guard for dense result diagnostics and automatic projection warmup. Larger warmups use matrix-sized identity initialization instead.
Returns
  • EnvelopeMARResult: Bilinear factors, reducing spaces, separable covariance, forecasts and inner/outer optimization diagnostics. Inspect converged.
Notes

Implements equations (15)--(16) using joint-lag, whitened SVD regressions and the actual logdet Grassmann profile, not PCA or reduced-rank ALS. Singular weighted designs and numerically singular residual moments raise: no ridge, covariance flooring or pseudodeterminant is substituted. Each nonzero lag pair is scale/sign normalized separately. Stability is diagnosed, never imposed. Only fixed-dimension point estimation is implemented: no dimension/order selection, sparse SEMAR, standard errors or the separate one-sided identity-coefficient model.

References

Samadi, S. Y. and De Alwis, T. P. (2026), "Envelope Matrix Autoregressive Models", Journal of Business & Economic Statistics 44(2), 397--412, equations (13), (15)--(16), Algorithm 1, https://doi.org/10.1080/07350015.2025.2537404. Numerical solver and source-equation corrections are documented in docs/envelope-notes.md; this is not an exact replication of paper code.

@dataclass
class RankSelectionStep:
 94@dataclass
 95class RankSelectionStep:
 96    """One mode's criterion evaluated at its sequential update.
 97
 98    ``eigenvalues * exp(log_eigenvalue_scale)`` are the physical fourth-power
 99    spectrum; neither exponentiation nor physical spectra need be representable.
100    ``log_penalty`` is in those same physical units. IC ``scores`` share the
101    positive factor ``exp(log_score_scale)``; ER scores are ordinary ratios.
102    ``log_scores`` retain physical IC values or dimensionless ER log ratios.
103    Rounding may tie displayed scores even when stable criterion comparisons
104    distinguish them. The exact-tie convention is the smallest candidate rank.
105    ``projected_shape`` records the data dimensions used for this moment, NOT
106    the original dimensions retained in the theoretical penalty.
107
108    References
109    ----------
110    Han, Chen and Zhang (2022), Rank Determination in Tensor Factor Model,
111    equations (1)--(2), (7)--(8). https://doi.org/10.1214/22-EJS1991
112    """
113
114    rank: int
115    eigenvalues: np.ndarray
116    log_eigenvalue_scale: float
117    log_penalty: float
118    candidate_ranks: np.ndarray
119    scores: np.ndarray
120    log_scores: np.ndarray
121    log_score_scale: float
122    projected_shape: tuple

One mode's criterion evaluated at its sequential update.

eigenvalues * exp(log_eigenvalue_scale) are the physical fourth-power spectrum; neither exponentiation nor physical spectra need be representable. log_penalty is in those same physical units. IC scores share the positive factor exp(log_score_scale); ER scores are ordinary ratios. log_scores retain physical IC values or dimensionless ER log ratios. Rounding may tie displayed scores even when stable criterion comparisons distinguish them. The exact-tie convention is the smallest candidate rank. projected_shape records the data dimensions used for this moment, NOT the original dimensions retained in the theoretical penalty.

References

Han, Chen and Zhang (2022), Rank Determination in Tensor Factor Model, equations (1)--(2), (7)--(8). https://doi.org/10.1214/22-EJS1991

RankSelectionStep( rank: int, eigenvalues: numpy.ndarray, log_eigenvalue_scale: float, log_penalty: float, candidate_ranks: numpy.ndarray, scores: numpy.ndarray, log_scores: numpy.ndarray, log_score_scale: float, projected_shape: tuple)
rank: int
eigenvalues: numpy.ndarray
log_eigenvalue_scale: float
log_penalty: float
candidate_ranks: numpy.ndarray
scores: numpy.ndarray
log_scores: numpy.ndarray
log_score_scale: float
projected_shape: tuple
@dataclass
class TensorRankSelectionResult:
133@dataclass
134class TensorRankSelectionResult:
135    """Selected dynamic ranks, fitted Tucker spaces, and complete sweep history.
136
137    ``history[0]`` is the unprojected selection; subsequent entries contain one
138    sequential sweep. ``initial_ranks`` are those unprojected choices, while
139    ``starting_ranks`` are the conservative projection initialization. The final
140    ``ranks`` are per-mode criterion choices, not necessarily minimal multilinear
141    ranks: any zero mode implies an identically zero centered signal, regardless
142    of other modes' reported ranks. ``signal_ranks`` makes this distinction.
143    Convergence means ranks AND loading projectors stopped changing, not correct
144    rank recovery. Iterative spectra refer to the corresponding sequential
145    update, not a new simultaneous recomputation using all final loadings.
146
147    References
148    ----------
149    Han, Chen and Zhang (2022), Rank Determination in Tensor Factor Model,
150    Section 3.2 and Remarks 5--7. https://doi.org/10.1214/22-EJS1991
151    """
152
153    ranks: tuple
154    loadings: tuple
155    factors: np.ndarray
156    signal: np.ndarray
157    residuals: np.ndarray
158    mean: np.ndarray
159    history: tuple
160    initial_ranks: tuple
161    starting_ranks: tuple
162    rank_history: tuple
163    projector_changes: tuple
164    method: str
165    criterion: str
166    penalty: int
167    nu: float
168    c0: float
169    log_penalty_multipliers: tuple
170    max_ranks: tuple
171    lags: tuple
172    data_scale: float
173    n_iter: int
174    converged: bool
175    stop_reason: str
176
177    @property
178    def diagnostics(self):
179        return self.history[-1]
180
181    @property
182    def eigenvalues(self):
183        return tuple(step.eigenvalues for step in self.diagnostics)
184
185    @property
186    def signal_ranks(self):
187        return (0,) * len(self.ranks) if 0 in self.ranks else self.ranks
188
189    def transform(self, X):
190        """Project new data using only fitted spaces and the training mean.
191
192        References
193        ----------
194        Han, Chen and Zhang (2022), Rank Determination in Tensor Factor Model,
195        equations (4)--(5). https://doi.org/10.1214/22-EJS1991
196        Fixed-loading Tucker projection is reconstruction, not forecasting.
197        """
198        X = as_series(X, min_samples=1, ndim=len(self.ranks) + 1)
199        if X.shape[1:] != self.mean.shape:
200            raise ValueError("observation dimensions must match the fitted model")
201        axes = tuple(range(1, X.ndim))
202        scale = np.maximum(
203            np.max(np.abs(X), axis=axes, keepdims=True),
204            float(np.max(np.abs(self.mean))),
205        )
206        scale = np.where(scale > 0, scale, 1.0)
207        return _restore(
208            _project(X / scale - self.mean / scale, self.loadings),
209            scale,
210            "transformed factors",
211        )
212
213    def inverse_transform(self, factors):
214        """Reconstruct with the training mean, including empty zero-rank cores.
215
216        References
217        ----------
218        Han, Chen and Zhang (2022), Rank Determination in Tensor Factor Model,
219        equation (4). https://doi.org/10.1214/22-EJS1991
220        Restoring a fitted deterministic mean is the centering convention.
221        """
222        raw = np.asarray(factors)
223        if (
224            np.iscomplexobj(raw)
225            or raw.dtype.kind not in "biuf"
226            or raw.ndim != len(self.ranks) + 1
227            or raw.shape[0] < 1
228            or raw.shape[1:] != self.ranks
229        ):
230            raise ValueError("factors must be finite real cores with the fitted ranks")
231        factors = np.asarray(raw, dtype=float)
232        if not np.isfinite(factors).all():
233            raise ValueError("factors must be finite real cores with the fitted ranks")
234        axes = tuple(range(1, factors.ndim))
235        scale = np.maximum(
236            np.max(np.abs(factors), axis=axes, keepdims=True, initial=0),
237            float(np.max(np.abs(self.mean))),
238        )
239        scale = np.where(scale > 0, scale, 1.0)
240        return _restore(
241            _project(factors / scale, self.loadings, inverse=True) + self.mean / scale,
242            scale,
243            "reconstructed signal",
244        )

Selected dynamic ranks, fitted Tucker spaces, and complete sweep history.

history[0] is the unprojected selection; subsequent entries contain one sequential sweep. initial_ranks are those unprojected choices, while starting_ranks are the conservative projection initialization. The final ranks are per-mode criterion choices, not necessarily minimal multilinear ranks: any zero mode implies an identically zero centered signal, regardless of other modes' reported ranks. signal_ranks makes this distinction. Convergence means ranks AND loading projectors stopped changing, not correct rank recovery. Iterative spectra refer to the corresponding sequential update, not a new simultaneous recomputation using all final loadings.

References

Han, Chen and Zhang (2022), Rank Determination in Tensor Factor Model, Section 3.2 and Remarks 5--7. https://doi.org/10.1214/22-EJS1991

TensorRankSelectionResult( ranks: tuple, loadings: tuple, factors: numpy.ndarray, signal: numpy.ndarray, residuals: numpy.ndarray, mean: numpy.ndarray, history: tuple, initial_ranks: tuple, starting_ranks: tuple, rank_history: tuple, projector_changes: tuple, method: str, criterion: str, penalty: int, nu: float, c0: float, log_penalty_multipliers: tuple, max_ranks: tuple, lags: tuple, data_scale: float, n_iter: int, converged: bool, stop_reason: str)
ranks: tuple
loadings: tuple
factors: numpy.ndarray
signal: numpy.ndarray
residuals: numpy.ndarray
mean: numpy.ndarray
history: tuple
initial_ranks: tuple
starting_ranks: tuple
rank_history: tuple
projector_changes: tuple
method: str
criterion: str
penalty: int
nu: float
c0: float
log_penalty_multipliers: tuple
max_ranks: tuple
lags: tuple
data_scale: float
n_iter: int
converged: bool
stop_reason: str
diagnostics
177    @property
178    def diagnostics(self):
179        return self.history[-1]
eigenvalues
181    @property
182    def eigenvalues(self):
183        return tuple(step.eigenvalues for step in self.diagnostics)
signal_ranks
185    @property
186    def signal_ranks(self):
187        return (0,) * len(self.ranks) if 0 in self.ranks else self.ranks
def transform(self, X):
189    def transform(self, X):
190        """Project new data using only fitted spaces and the training mean.
191
192        References
193        ----------
194        Han, Chen and Zhang (2022), Rank Determination in Tensor Factor Model,
195        equations (4)--(5). https://doi.org/10.1214/22-EJS1991
196        Fixed-loading Tucker projection is reconstruction, not forecasting.
197        """
198        X = as_series(X, min_samples=1, ndim=len(self.ranks) + 1)
199        if X.shape[1:] != self.mean.shape:
200            raise ValueError("observation dimensions must match the fitted model")
201        axes = tuple(range(1, X.ndim))
202        scale = np.maximum(
203            np.max(np.abs(X), axis=axes, keepdims=True),
204            float(np.max(np.abs(self.mean))),
205        )
206        scale = np.where(scale > 0, scale, 1.0)
207        return _restore(
208            _project(X / scale - self.mean / scale, self.loadings),
209            scale,
210            "transformed factors",
211        )

Project new data using only fitted spaces and the training mean.

References

Han, Chen and Zhang (2022), Rank Determination in Tensor Factor Model, equations (4)--(5). https://doi.org/10.1214/22-EJS1991 Fixed-loading Tucker projection is reconstruction, not forecasting.

def inverse_transform(self, factors):
213    def inverse_transform(self, factors):
214        """Reconstruct with the training mean, including empty zero-rank cores.
215
216        References
217        ----------
218        Han, Chen and Zhang (2022), Rank Determination in Tensor Factor Model,
219        equation (4). https://doi.org/10.1214/22-EJS1991
220        Restoring a fitted deterministic mean is the centering convention.
221        """
222        raw = np.asarray(factors)
223        if (
224            np.iscomplexobj(raw)
225            or raw.dtype.kind not in "biuf"
226            or raw.ndim != len(self.ranks) + 1
227            or raw.shape[0] < 1
228            or raw.shape[1:] != self.ranks
229        ):
230            raise ValueError("factors must be finite real cores with the fitted ranks")
231        factors = np.asarray(raw, dtype=float)
232        if not np.isfinite(factors).all():
233            raise ValueError("factors must be finite real cores with the fitted ranks")
234        axes = tuple(range(1, factors.ndim))
235        scale = np.maximum(
236            np.max(np.abs(factors), axis=axes, keepdims=True, initial=0),
237            float(np.max(np.abs(self.mean))),
238        )
239        scale = np.where(scale > 0, scale, 1.0)
240        return _restore(
241            _project(factors / scale, self.loadings, inverse=True) + self.mean / scale,
242            scale,
243            "reconstructed signal",
244        )

Reconstruct with the training mean, including empty zero-rank cores.

References

Han, Chen and Zhang (2022), Rank Determination in Tensor Factor Model, equation (4). https://doi.org/10.1214/22-EJS1991 Restoring a fitted deterministic mean is the centering convention.

def select_tensor_rank( X, *, method='tipup', criterion='er', penalty=1, max_ranks=None, lags=1, iterative=False, nu=0.0, penalty_multiplier=1.0, log_penalty_multiplier=None, c0=0.1, initial_ranks=None, max_iter=100, tol=1e-08, center=True):
266def select_tensor_rank(
267    X,
268    *,
269    method="tipup",
270    criterion="er",
271    penalty=1,
272    max_ranks=None,
273    lags=1,
274    iterative=False,
275    nu=0.0,
276    penalty_multiplier=1.0,
277    log_penalty_multiplier=None,
278    c0=0.1,
279    initial_ranks=None,
280    max_iter=100,
281    tol=1e-8,
282    center=True,
283):
284    """Select Tucker ranks by the published TOPUP/TIPUP IC or ER criteria.
285
286    Parameters
287    ----------
288    X : array_like, shape (T, d1, ..., dK)
289        Finite real training observations, K >= 1 and every d_k >= 2.
290    method : {'topup', 'tipup'}
291        Past-first lag outer/inner product moments, Section 3.2.
292    criterion : {'ic', 'er'}
293        Equation (1) permits 0,...,m*; equation (2) permits 1,...,m*.
294    penalty : int, 1,...,5
295        Exact g1,...,g5 (IC) or h1,...,h5 (ER), equations (7)--(8).
296    max_ranks : sequence of int, optional
297        Search bounds m*_k < d_k; defaults to floor(d_k/2). Zero bounds are
298        allowed only for IC. Full-dimension search extensions are not provided.
299    lags : int, default 1
300        h0: accumulate lags 1,...,h0, without averaging the moment over lags.
301    iterative : bool, default False
302        Sequential equation (6) updates, reselecting every rank in each sweep.
303    nu : float in [0, 1], default 0
304        IC strength tuning, ideally weakest-factor exponent delta1. Zero is the
305        paper's default strong-factor setting, not an estimate of strength.
306    penalty_multiplier, log_penalty_multiplier : scalar or sequence, optional
307        Positive multiplicative constants, one per mode if desired. The log
308        alternative supports constants outside float range and requires the
309        ordinary multiplier to remain its scalar default 1. Constants apply
310        to FIXED physical fourth-power penalties, not relative eigenvalue
311        heuristics. Under X -> a X, add 4 log(abs(a)) to log multipliers to
312        preserve the criterion. Keeping constants fixed need NOT preserve ranks.
313    c0 : positive float, default 0.1
314        Constant in h1; ignored by other displayed penalty families.
315    initial_ranks : sequence of int, optional
316        Iterative projection initialization only, up to d_k. Default is
317        min(2*r_initial, r_initial+3, d_k), following Remark 5 with dimension cap.
318    max_iter : positive int, default 100
319        Maximum sequential sweeps, excluding the unprojected initialization.
320    tol : positive float, default 1e-8
321        Stop when ranks agree and the maximum Frobenius projector change < tol.
322    center : bool, default True
323        Remove and retain the training mean, as recommended in Remark 6 when
324        demeaning does not remove a factor dimension (constant factors violate
325        that premise). Held-out transformations use this training mean only.
326
327    Notes
328    -----
329    Original d and d_k enter penalties even after projection. Only moments use
330    projected dimensions. No penalty calibration, confidence intervals, ER
331    mock-zero eigenvalue, or full-dimension rank extension is implied. Serially
332    white noise, informative lag moments, factor strengths and the rate
333    conditions of Section 4 are substantive assumptions; numerical convergence
334    does not check them. TIPUP can lose factors by lag-inner-product cancellation.
335    IC zero-mode selections have exact empty-core reconstruction. With an empty
336    projected mode subsequent moments are zero, an absorbing loss of information
337    which the positive-rank asymptotic theory does not remedy.
338
339    References
340    ----------
341    Han, Chen and Zhang (2022), Rank Determination in Tensor Factor Model,
342    equations (1), (2), (6)--(8), Remarks 5--7.
343    https://doi.org/10.1214/22-EJS1991
344    https://arxiv.org/html/2011.07131v3
345    """
346    method, criterion = str(method).lower(), str(criterion).lower()
347    if method not in {"topup", "tipup"}:
348        raise ValueError("method must be 'topup' or 'tipup'")
349    if criterion not in {"ic", "er"}:
350        raise ValueError("criterion must be 'ic' or 'er'")
351    penalty = positive_int(penalty, "penalty")
352    if penalty > 5:
353        raise ValueError("penalty must be in 1,...,5")
354    if not isinstance(iterative, (bool, np.bool_)):
355        raise ValueError("iterative must be boolean")
356    h0 = positive_int(lags, "lags")
357    max_iter = positive_int(max_iter, "max_iter")
358    tol = finite_scalar(tol, "tol", minimum=0)
359    nu = finite_scalar(nu, "nu", minimum=0)
360    c0 = finite_scalar(c0, "c0", minimum=0)
361    if tol == 0 or c0 == 0 or nu > 1:
362        raise ValueError("tol and c0 must be positive, and nu must be <= 1")
363    ndim = np.ndim(X)
364    if ndim < 2:
365        raise ValueError("X needs time and at least one spatial mode")
366    X, work, mean, scale = _prepare(X, center, ndim=ndim)
367    shape, n = X.shape[1:], len(X)
368    if min(shape) < 2 or h0 >= n:
369        raise ValueError("all mode dimensions must be >= 2 and lags < sample size")
370    if max_ranks is None:
371        max_ranks = tuple(d // 2 for d in shape)
372    else:
373        try:
374            max_ranks = tuple(max_ranks)
375        except TypeError as exc:
376            raise ValueError("max_ranks must contain one bound per mode") from exc
377        if len(max_ranks) != len(shape):
378            raise ValueError("max_ranks must contain one bound per mode")
379        max_ranks = tuple(
380            positive_int(r, "max_rank", minimum=0 if criterion == "ic" else 1)
381            for r in max_ranks
382        )
383        if any(r >= d for r, d in zip(max_ranks, shape)):
384            raise ValueError("max_ranks must be strictly below mode dimensions")
385    if log_penalty_multiplier is None:
386        log_multipliers = _per_mode(
387            penalty_multiplier, len(shape), "penalty_multiplier"
388        )
389    else:
390        if (
391            np.ndim(penalty_multiplier) != 0
392            or finite_scalar(penalty_multiplier, "penalty_multiplier") != 1
393        ):
394            raise ValueError(
395                "supply either penalty_multiplier or log_penalty_multiplier"
396            )
397        log_multipliers = _per_mode(
398            log_penalty_multiplier,
399            len(shape),
400            "log_penalty_multiplier",
401            logarithmic=True,
402        )
403    log_penalties = tuple(
404        _log_penalty(shape, n, h0, k, criterion, penalty, nu, c0) + c
405        for k, c in enumerate(log_multipliers)
406    )
407    lag_values = tuple(range(1, h0 + 1))
408    log_scale = 4 * math.log(scale)
409
410    def update(projected, mode):
411        moment = (
412            np.zeros((shape[mode], shape[mode]))
413            if projected.size == 0
414            else _lagged_moment(_unfold_series(projected, mode), lag_values, method)
415        )
416        if not np.isfinite(moment).all():
417            raise ValueError("lag moment cannot be represented after normalization")
418        values, vectors = np.linalg.eigh((moment + moment.T) * 0.5)
419        values = np.maximum(values[::-1], 0)
420        vectors = _canonical_signs(vectors[:, ::-1])
421        chosen = _criterion(
422            values, log_scale, log_penalties[mode], criterion, max_ranks[mode]
423        )
424        step = RankSelectionStep(
425            eigenvalues=values,
426            log_eigenvalue_scale=log_scale,
427            log_penalty=log_penalties[mode],
428            projected_shape=projected.shape[1:],
429            **chosen,
430        )
431        return vectors, step
432
433    first = [update(work, k) for k in range(len(shape))]
434    initial = tuple(pair[1].rank for pair in first)
435    if initial_ranks is not None:
436        if not iterative:
437            raise ValueError("initial_ranks apply only to iterative selection")
438        try:
439            starting = tuple(initial_ranks)
440        except TypeError as exc:
441            raise ValueError("initial_ranks must contain one rank per mode") from exc
442        if len(starting) != len(shape):
443            raise ValueError("initial_ranks must contain one rank per mode")
444        starting = tuple(
445            positive_int(r, "initial_rank", minimum=0 if criterion == "ic" else 1)
446            for r in starting
447        )
448        if any(r > d for r, d in zip(starting, shape)):
449            raise ValueError("initial_ranks cannot exceed mode dimensions")
450    else:
451        starting = (
452            tuple(min(2 * r, r + 3, d) for r, d in zip(initial, shape))
453            if iterative
454            else initial
455        )
456    loadings = [pair[0][:, :r] for pair, r in zip(first, starting)]
457    history = [tuple(pair[1] for pair in first)]
458    rank_history, changes = [starting], []
459    converged, iteration = not iterative, 0
460    if iterative:
461        for iteration in range(1, max_iter + 1):
462            old = list(loadings)
463            steps = []
464            for mode in range(len(shape)):
465                vectors, step = update(_project(work, loadings, skip=mode), mode)
466                loadings[mode] = vectors[:, : step.rank]
467                steps.append(step)
468            current = tuple(u.shape[1] for u in loadings)
469            change = max(
470                float(np.linalg.norm(u @ u.T - v @ v.T)) for u, v in zip(loadings, old)
471            )
472            history.append(tuple(steps))
473            changes.append(change)
474            rank_history.append(current)
475            if current == tuple(u.shape[1] for u in old) and change < tol:
476                converged = True
477                break
478    ranks = tuple(u.shape[1] for u in loadings)
479    core = _project(work, loadings)
480    reconstructed = _project(core, loadings, inverse=True) + mean / scale
481    signal = _restore(reconstructed, scale, "fitted signal")
482    factors = _restore(core, scale, "fitted factors")
483    residuals = _restore(X / scale - reconstructed, scale, "fitted residuals")
484    return TensorRankSelectionResult(
485        ranks,
486        tuple(loadings),
487        factors,
488        signal,
489        residuals,
490        mean,
491        tuple(history),
492        initial,
493        starting,
494        tuple(rank_history),
495        tuple(changes),
496        ("i" if iterative else "") + method,
497        criterion,
498        penalty,
499        nu,
500        c0,
501        log_multipliers,
502        max_ranks,
503        lag_values,
504        scale,
505        iteration,
506        converged,
507        (
508            "rank_and_projector_tolerance"
509            if iterative and converged
510            else ("max_iter" if iterative else "noniterative")
511        ),
512    )

Select Tucker ranks by the published TOPUP/TIPUP IC or ER criteria.

Parameters
  • X (array_like, shape (T, d1, ..., dK)): Finite real training observations, K >= 1 and every d_k >= 2.
  • method ({'topup', 'tipup'}): Past-first lag outer/inner product moments, Section 3.2.
  • criterion ({'ic', 'er'}): Equation (1) permits 0,...,m*; equation (2) permits 1,...,m*.
  • penalty (int, 1,...,5): Exact g1,...,g5 (IC) or h1,...,h5 (ER), equations (7)--(8).
  • max_ranks (sequence of int, optional): Search bounds m*_k < d_k; defaults to floor(d_k/2). Zero bounds are allowed only for IC. Full-dimension search extensions are not provided.
  • lags (int, default 1): h0: accumulate lags 1,...,h0, without averaging the moment over lags.
  • iterative (bool, default False): Sequential equation (6) updates, reselecting every rank in each sweep.
  • nu (float in [0, 1], default 0): IC strength tuning, ideally weakest-factor exponent delta1. Zero is the paper's default strong-factor setting, not an estimate of strength.
  • penalty_multiplier, log_penalty_multiplier (scalar or sequence, optional): Positive multiplicative constants, one per mode if desired. The log alternative supports constants outside float range and requires the ordinary multiplier to remain its scalar default 1. Constants apply to FIXED physical fourth-power penalties, not relative eigenvalue heuristics. Under X -> a X, add 4 log(abs(a)) to log multipliers to preserve the criterion. Keeping constants fixed need NOT preserve ranks.
  • c0 (positive float, default 0.1): Constant in h1; ignored by other displayed penalty families.
  • initial_ranks (sequence of int, optional): Iterative projection initialization only, up to d_k. Default is min(2*r_initial, r_initial+3, d_k), following Remark 5 with dimension cap.
  • max_iter (positive int, default 100): Maximum sequential sweeps, excluding the unprojected initialization.
  • tol (positive float, default 1e-8): Stop when ranks agree and the maximum Frobenius projector change < tol.
  • center (bool, default True): Remove and retain the training mean, as recommended in Remark 6 when demeaning does not remove a factor dimension (constant factors violate that premise). Held-out transformations use this training mean only.
Notes

Original d and d_k enter penalties even after projection. Only moments use projected dimensions. No penalty calibration, confidence intervals, ER mock-zero eigenvalue, or full-dimension rank extension is implied. Serially white noise, informative lag moments, factor strengths and the rate conditions of Section 4 are substantive assumptions; numerical convergence does not check them. TIPUP can lose factors by lag-inner-product cancellation. IC zero-mode selections have exact empty-core reconstruction. With an empty projected mode subsequent moments are zero, an absorbing loss of information which the positive-rank asymptotic theory does not remedy.

References

Han, Chen and Zhang (2022), Rank Determination in Tensor Factor Model, equations (1), (2), (6)--(8), Remarks 5--7. https://doi.org/10.1214/22-EJS1991 https://arxiv.org/html/2011.07131v3

@dataclass(frozen=True)
class RankStabilityCell:
20@dataclass(frozen=True)
21class RankStabilityCell:
22    """One IC fit, or its recorded numerical failure; inherits the module reference."""
23
24    result: object | None
25    error: str | None = None
26
27    @property
28    def converged(self):
29        return self.result is not None and bool(self.result.converged)
30
31    @property
32    def n_iter(self):
33        return None if self.result is None else self.result.n_iter

One IC fit, or its recorded numerical failure; inherits the module reference.

RankStabilityCell(result: object | None, error: str | None = None)
result: object | None
error: str | None = None
converged
27    @property
28    def converged(self):
29        return self.result is not None and bool(self.result.converged)
n_iter
31    @property
32    def n_iter(self):
33        return None if self.result is None else self.result.n_iter
@dataclass(frozen=True)
class RankStabilityChoice:
54@dataclass(frozen=True)
55class RankStabilityChoice:
56    """Modewise finite-grid choices, not a jointly refitted rank estimate.
57
58    Missing choices are None, never silently replaced by a boundary value.
59    With iterative IC, refitting the chosen modewise multipliers jointly may
60    change ranks because the mode updates are coupled. See the module reference.
61    """
62
63    selected_indices: tuple
64    penalty_multipliers: tuple
65    selected_ranks: tuple
66    intervals: tuple
67    reasons: tuple
68    variance_tolerance: float
69    minimum_grid_points: int
70    allow_unconverged: bool
71    point_reasons: tuple
72
73    @property
74    def complete(self):
75        return all(index is not None for index in self.selected_indices)

Modewise finite-grid choices, not a jointly refitted rank estimate.

Missing choices are None, never silently replaced by a boundary value. With iterative IC, refitting the chosen modewise multipliers jointly may change ranks because the mode updates are coupled. See the module reference.

RankStabilityChoice( selected_indices: tuple, penalty_multipliers: tuple, selected_ranks: tuple, intervals: tuple, reasons: tuple, variance_tolerance: float, minimum_grid_points: int, allow_unconverged: bool, point_reasons: tuple)
selected_indices: tuple
penalty_multipliers: tuple
selected_ranks: tuple
intervals: tuple
reasons: tuple
variance_tolerance: float
minimum_grid_points: int
allow_unconverged: bool
point_reasons: tuple
complete
73    @property
74    def complete(self):
75        return all(index is not None for index in self.selected_indices)
@dataclass(frozen=True)
class RankStabilityInterval:
36@dataclass(frozen=True)
37class RankStabilityInterval:
38    """A maximal constant-full-rank, low-variance run on the supplied c grid.
39
40    Index endpoints are inclusive. Reasons retain rejected runs, including the
41    maximum-rank plateau and zero-rank tail discussed in the module reference.
42    """
43
44    start_index: int
45    end_index: int
46    lower_multiplier: float
47    upper_multiplier: float
48    rank: int
49    admissible: bool
50    reasons: tuple
51    touches_grid_boundary: bool

A maximal constant-full-rank, low-variance run on the supplied c grid.

Index endpoints are inclusive. Reasons retain rejected runs, including the maximum-rank plateau and zero-rank tail discussed in the module reference.

RankStabilityInterval( start_index: int, end_index: int, lower_multiplier: float, upper_multiplier: float, rank: int, admissible: bool, reasons: tuple, touches_grid_boundary: bool)
start_index: int
end_index: int
lower_multiplier: float
upper_multiplier: float
rank: int
admissible: bool
reasons: tuple
touches_grid_boundary: bool
@dataclass(frozen=True)
class TensorRankStabilityResult:
 78@dataclass(frozen=True)
 79class TensorRankStabilityResult:
 80    """Full IC stability path; no observations or subsamples are selected secretly.
 81
 82    ``ranks[k,c,j]`` uses -1 only for failed cells. ``variances[k,c]`` uses
 83    divisor J and is NaN if any cell at that c failed (no case deletion).
 84    ``cells[c][j]`` retains the complete rank-selector result and convergence.
 85    The final subsample is full data, hence ``full_sample_ranks=ranks[:,:,-1]``.
 86    All indices are zero-based; time_prefixes are exclusive stopping positions.
 87    References: Han, Chen and Zhang (2022), Remark 8 and Section 5.4,
 88    https://arxiv.org/html/2011.07131v3.
 89    """
 90
 91    c_grid: np.ndarray
 92    spatial_subsets: tuple
 93    time_prefixes: tuple
 94    max_ranks: tuple
 95    options: dict
 96    ranks: np.ndarray
 97    variances: np.ndarray
 98    cells: tuple
 99
100    @property
101    def full_sample_ranks(self):
102        return self.ranks[:, :, -1].copy()
103
104    @property
105    def converged(self):
106        """Boolean (c, subsample) array, not a path-level success certificate."""
107        return np.array([[cell.converged for cell in row] for row in self.cells])
108
109    @property
110    def monotone(self):
111        """Whether all successful ranks are nonincreasing in c, by mode.
112
113        A mode with failed cells returns False. Iterative projection/rank
114        coupling need not preserve the fixed-moment IC monotonicity.
115        """
116        return np.array(
117            [
118                bool(np.all(mode >= 0) and np.all(np.diff(mode, axis=0) <= 0))
119                for mode in self.ranks
120            ]
121        )
122
123    def choose_plateaus(
124        self, *, variance_tolerance, minimum_grid_points, allow_unconverged=False
125    ):
126        """Choose the first admissible stability interval separately for each mode.
127
128        Both numerical tolerances are required: points must be consecutive in
129        the supplied grid, have variance <= variance_tolerance and constant
130        full-sample rank. Rank zero and the fixed maximum rank are excluded.
131        By default every subsample fit at every selected point must converge.
132        The representative is the lower-middle gridpoint, with no interpolation.
133        Rejected intervals and reasons are retained; missing choices are None.
134
135        References
136        ----------
137        Han, Chen and Zhang (2022), "Rank Determination in Tensor Factor Model",
138        https://arxiv.org/html/2011.07131v3, Remark 8 and Section 5.4.
139        Their second-interval discussion motivates rejecting the initial
140        maximum-rank plateau. The explicit finite-grid width, variance tolerance,
141        convergence filter and representative are package discretization choices.
142        """
143        variance_tolerance = finite_scalar(
144            variance_tolerance, "variance_tolerance", minimum=0
145        )
146        minimum_grid_points = positive_int(
147            minimum_grid_points, "minimum_grid_points", minimum=2
148        )
149        if not isinstance(allow_unconverged, (bool, np.bool_)):
150            raise ValueError("allow_unconverged must be boolean")
151        complete_c = np.all(self.ranks >= 0, axis=(0, 2))
152        converged_c = self.converged.all(axis=1)
153        selected, multipliers, chosen_ranks, all_intervals, reasons = [], [], [], [], []
154        all_point_reasons = []
155        for mode, maximum in enumerate(self.max_ranks):
156            full = self.ranks[mode, :, -1]
157            low = complete_c & np.isfinite(self.variances[mode])
158            low &= self.variances[mode] <= variance_tolerance
159            if not allow_unconverged:
160                low &= converged_c
161            point_reasons = []
162            for c_index in range(len(self.c_grid)):
163                rejected = []
164                if not complete_c[c_index]:
165                    rejected.append("failed_subsample_fit")
166                if not np.isfinite(self.variances[mode, c_index]):
167                    rejected.append("variance_unavailable")
168                elif self.variances[mode, c_index] > variance_tolerance:
169                    rejected.append("variance_above_tolerance")
170                if not allow_unconverged and not converged_c[c_index]:
171                    rejected.append("unconverged_subsample_fit")
172                point_reasons.append(tuple(rejected))
173            all_point_reasons.append(tuple(point_reasons))
174            intervals = []
175            index = 0
176            while index < len(self.c_grid):
177                if not low[index]:
178                    index += 1
179                    continue
180                end = index
181                while (
182                    end + 1 < len(self.c_grid)
183                    and low[end + 1]
184                    and full[end + 1] == full[index]
185                ):
186                    end += 1
187                rejected = []
188                if full[index] == maximum:
189                    rejected.append("maximum_rank_plateau")
190                if full[index] == 0:
191                    rejected.append("zero_rank_under_positive_rank_assumption")
192                if end - index + 1 < minimum_grid_points:
193                    rejected.append("too_few_grid_points")
194                intervals.append(
195                    RankStabilityInterval(
196                        index,
197                        end,
198                        float(self.c_grid[index]),
199                        float(self.c_grid[end]),
200                        int(full[index]),
201                        not rejected,
202                        tuple(rejected),
203                        index == 0 or end == len(self.c_grid) - 1,
204                    )
205                )
206                index = end + 1
207            first = next((region for region in intervals if region.admissible), None)
208            if first is None:
209                selected.append(None)
210                multipliers.append(None)
211                chosen_ranks.append(None)
212                reasons.append("no_admissible_region_on_supplied_grid")
213            else:
214                representative = (first.start_index + first.end_index) // 2
215                selected.append(representative)
216                multipliers.append(float(self.c_grid[representative]))
217                chosen_ranks.append(first.rank)
218                reasons.append("first_admissible_region_lower_middle_gridpoint")
219            all_intervals.append(tuple(intervals))
220        return RankStabilityChoice(
221            tuple(selected),
222            tuple(multipliers),
223            tuple(chosen_ranks),
224            tuple(all_intervals),
225            tuple(reasons),
226            variance_tolerance,
227            minimum_grid_points,
228            bool(allow_unconverged),
229            tuple(all_point_reasons),
230        )

Full IC stability path; no observations or subsamples are selected secretly.

ranks[k,c,j] uses -1 only for failed cells. variances[k,c] uses divisor J and is NaN if any cell at that c failed (no case deletion). cells[c][j] retains the complete rank-selector result and convergence. The final subsample is full data, hence full_sample_ranks=ranks[:,:,-1]. All indices are zero-based; time_prefixes are exclusive stopping positions. References: Han, Chen and Zhang (2022), Remark 8 and Section 5.4, https://arxiv.org/html/2011.07131v3.

TensorRankStabilityResult( c_grid: numpy.ndarray, spatial_subsets: tuple, time_prefixes: tuple, max_ranks: tuple, options: dict, ranks: numpy.ndarray, variances: numpy.ndarray, cells: tuple)
c_grid: numpy.ndarray
spatial_subsets: tuple
time_prefixes: tuple
max_ranks: tuple
options: dict
ranks: numpy.ndarray
variances: numpy.ndarray
cells: tuple
full_sample_ranks
100    @property
101    def full_sample_ranks(self):
102        return self.ranks[:, :, -1].copy()
converged
104    @property
105    def converged(self):
106        """Boolean (c, subsample) array, not a path-level success certificate."""
107        return np.array([[cell.converged for cell in row] for row in self.cells])

Boolean (c, subsample) array, not a path-level success certificate.

monotone
109    @property
110    def monotone(self):
111        """Whether all successful ranks are nonincreasing in c, by mode.
112
113        A mode with failed cells returns False. Iterative projection/rank
114        coupling need not preserve the fixed-moment IC monotonicity.
115        """
116        return np.array(
117            [
118                bool(np.all(mode >= 0) and np.all(np.diff(mode, axis=0) <= 0))
119                for mode in self.ranks
120            ]
121        )

Whether all successful ranks are nonincreasing in c, by mode.

A mode with failed cells returns False. Iterative projection/rank coupling need not preserve the fixed-moment IC monotonicity.

def choose_plateaus( self, *, variance_tolerance, minimum_grid_points, allow_unconverged=False):
123    def choose_plateaus(
124        self, *, variance_tolerance, minimum_grid_points, allow_unconverged=False
125    ):
126        """Choose the first admissible stability interval separately for each mode.
127
128        Both numerical tolerances are required: points must be consecutive in
129        the supplied grid, have variance <= variance_tolerance and constant
130        full-sample rank. Rank zero and the fixed maximum rank are excluded.
131        By default every subsample fit at every selected point must converge.
132        The representative is the lower-middle gridpoint, with no interpolation.
133        Rejected intervals and reasons are retained; missing choices are None.
134
135        References
136        ----------
137        Han, Chen and Zhang (2022), "Rank Determination in Tensor Factor Model",
138        https://arxiv.org/html/2011.07131v3, Remark 8 and Section 5.4.
139        Their second-interval discussion motivates rejecting the initial
140        maximum-rank plateau. The explicit finite-grid width, variance tolerance,
141        convergence filter and representative are package discretization choices.
142        """
143        variance_tolerance = finite_scalar(
144            variance_tolerance, "variance_tolerance", minimum=0
145        )
146        minimum_grid_points = positive_int(
147            minimum_grid_points, "minimum_grid_points", minimum=2
148        )
149        if not isinstance(allow_unconverged, (bool, np.bool_)):
150            raise ValueError("allow_unconverged must be boolean")
151        complete_c = np.all(self.ranks >= 0, axis=(0, 2))
152        converged_c = self.converged.all(axis=1)
153        selected, multipliers, chosen_ranks, all_intervals, reasons = [], [], [], [], []
154        all_point_reasons = []
155        for mode, maximum in enumerate(self.max_ranks):
156            full = self.ranks[mode, :, -1]
157            low = complete_c & np.isfinite(self.variances[mode])
158            low &= self.variances[mode] <= variance_tolerance
159            if not allow_unconverged:
160                low &= converged_c
161            point_reasons = []
162            for c_index in range(len(self.c_grid)):
163                rejected = []
164                if not complete_c[c_index]:
165                    rejected.append("failed_subsample_fit")
166                if not np.isfinite(self.variances[mode, c_index]):
167                    rejected.append("variance_unavailable")
168                elif self.variances[mode, c_index] > variance_tolerance:
169                    rejected.append("variance_above_tolerance")
170                if not allow_unconverged and not converged_c[c_index]:
171                    rejected.append("unconverged_subsample_fit")
172                point_reasons.append(tuple(rejected))
173            all_point_reasons.append(tuple(point_reasons))
174            intervals = []
175            index = 0
176            while index < len(self.c_grid):
177                if not low[index]:
178                    index += 1
179                    continue
180                end = index
181                while (
182                    end + 1 < len(self.c_grid)
183                    and low[end + 1]
184                    and full[end + 1] == full[index]
185                ):
186                    end += 1
187                rejected = []
188                if full[index] == maximum:
189                    rejected.append("maximum_rank_plateau")
190                if full[index] == 0:
191                    rejected.append("zero_rank_under_positive_rank_assumption")
192                if end - index + 1 < minimum_grid_points:
193                    rejected.append("too_few_grid_points")
194                intervals.append(
195                    RankStabilityInterval(
196                        index,
197                        end,
198                        float(self.c_grid[index]),
199                        float(self.c_grid[end]),
200                        int(full[index]),
201                        not rejected,
202                        tuple(rejected),
203                        index == 0 or end == len(self.c_grid) - 1,
204                    )
205                )
206                index = end + 1
207            first = next((region for region in intervals if region.admissible), None)
208            if first is None:
209                selected.append(None)
210                multipliers.append(None)
211                chosen_ranks.append(None)
212                reasons.append("no_admissible_region_on_supplied_grid")
213            else:
214                representative = (first.start_index + first.end_index) // 2
215                selected.append(representative)
216                multipliers.append(float(self.c_grid[representative]))
217                chosen_ranks.append(first.rank)
218                reasons.append("first_admissible_region_lower_middle_gridpoint")
219            all_intervals.append(tuple(intervals))
220        return RankStabilityChoice(
221            tuple(selected),
222            tuple(multipliers),
223            tuple(chosen_ranks),
224            tuple(all_intervals),
225            tuple(reasons),
226            variance_tolerance,
227            minimum_grid_points,
228            bool(allow_unconverged),
229            tuple(all_point_reasons),
230        )

Choose the first admissible stability interval separately for each mode.

Both numerical tolerances are required: points must be consecutive in the supplied grid, have variance <= variance_tolerance and constant full-sample rank. Rank zero and the fixed maximum rank are excluded. By default every subsample fit at every selected point must converge. The representative is the lower-middle gridpoint, with no interpolation. Rejected intervals and reasons are retained; missing choices are None.

References

Han, Chen and Zhang (2022), "Rank Determination in Tensor Factor Model", https://arxiv.org/html/2011.07131v3, Remark 8 and Section 5.4. Their second-interval discussion motivates rejecting the initial maximum-rank plateau. The explicit finite-grid width, variance tolerance, convergence filter and representative are package discretization choices.

def tensor_rank_stability( X, c_grid, *, spatial_subsets, time_prefixes, max_ranks, method='tipup', penalty=2, lags=1, iterative=False, nu=0.0, center=True, max_iter=100, tol=1e-08):
301def tensor_rank_stability(
302    X,
303    c_grid,
304    *,
305    spatial_subsets,
306    time_prefixes,
307    max_ranks,
308    method="tipup",
309    penalty=2,
310    lags=1,
311    iterative=False,
312    nu=0.0,
313    center=True,
314    max_iter=100,
315    tol=1e-8,
316):
317    """Compute the published IC penalty-constant subsample-stability path.
318
319    c_grid is a supplied positive increasing vector. At each point the same c
320    multiplies all mode penalties; modewise choices may subsequently differ.
321    spatial_subsets[j][k] gives actual indices in mode k, time_prefixes[j]
322    gives the number of initial observations. Sets must grow strictly in every
323    spatial mode and finish with full data; prefixes may repeat but must end at
324    len(X). At least two paired subsamples are required. No random subsampling,
325    shuffling, out-of-sample tuning, rank-cap clipping or interpolation occurs.
326
327    Every cell reruns IC with that subsample's dimensions and T, including its
328    own centering and iterative initialization. Numerical fit failures are
329    recorded, not omitted from the variance. Their rank is -1 and variance NaN.
330    Finite unconverged results remain available but are ineligible for default
331    plateau selection. options records the controls passed to the IC selector.
332
333    References
334    ----------
335    Han, Y., Chen, R., and Zhang, C.-H. (2022), "Rank Determination in Tensor
336    Factor Model", https://arxiv.org/html/2011.07131v3, Remark 8 and Section 5.4.
337    S[k,c] is the population variance over all supplied subsamples (ddof=0).
338    The explicit index grid and numerical plateau interpretation are user/package
339    choices, not an automatically consistent selector for arbitrary data.
340    """
341    # Imported here to keep the independent path records lightweight.
342    from .rank_selection import select_tensor_rank
343
344    lags = positive_int(lags, "lags")
345    X = as_series(X, min_samples=lags + 1, ndim=None)
346    grid, subsets, prefixes, caps = _grids(
347        X, c_grid, spatial_subsets, time_prefixes, max_ranks, lags
348    )
349    if method not in ("tipup", "topup"):
350        raise ValueError("method must be tipup or topup")
351    penalty = positive_int(penalty, "penalty")
352    if penalty > 5:
353        raise ValueError("penalty must be an integer from 1 through 5")
354    nu = finite_scalar(nu, "nu", minimum=0)
355    if nu > 1:
356        raise ValueError("nu must lie between zero and one")
357    if not isinstance(iterative, (bool, np.bool_)) or not isinstance(
358        center, (bool, np.bool_)
359    ):
360        raise ValueError("iterative and center must be boolean")
361    max_iter = positive_int(max_iter, "max_iter")
362    tol = finite_scalar(tol, "tol", minimum=0)
363    if tol == 0:
364        raise ValueError("tol must be positive")
365    options = dict(
366        method=method,
367        criterion="ic",
368        penalty=penalty,
369        max_ranks=caps,
370        lags=lags,
371        iterative=bool(iterative),
372        nu=nu,
373        center=bool(center),
374        max_iter=max_iter,
375        tol=tol,
376    )
377    ranks = np.full((X.ndim - 1, len(grid), len(subsets)), -1, dtype=int)
378    cells = [[None] * len(subsets) for _ in grid]
379    # Materialize only one subsample at a time. Preserve the exact supplied order.
380    for j, (indices, stop) in enumerate(zip(subsets, prefixes)):
381        sample = X[:stop]
382        for axis, index in enumerate(indices, 1):
383            sample = np.take(sample, index, axis=axis)
384        for c_index, multiplier in enumerate(grid):
385            try:
386                fitted = select_tensor_rank(
387                    sample, penalty_multiplier=float(multiplier), **options
388                )
389                ranks[:, c_index, j] = fitted.ranks
390                cells[c_index][j] = RankStabilityCell(fitted)
391            except (ValueError, FloatingPointError, np.linalg.LinAlgError) as exc:
392                cells[c_index][j] = RankStabilityCell(
393                    None, f"{type(exc).__name__}: {exc}"
394                )
395    variances = ranks.astype(float).var(axis=2, ddof=0)
396    variances[np.any(ranks < 0, axis=2)] = np.nan
397    return TensorRankStabilityResult(
398        grid,
399        subsets,
400        prefixes,
401        caps,
402        options,
403        ranks,
404        variances,
405        tuple(tuple(row) for row in cells),
406    )

Compute the published IC penalty-constant subsample-stability path.

c_grid is a supplied positive increasing vector. At each point the same c multiplies all mode penalties; modewise choices may subsequently differ. spatial_subsets[j][k] gives actual indices in mode k, time_prefixes[j] gives the number of initial observations. Sets must grow strictly in every spatial mode and finish with full data; prefixes may repeat but must end at len(X). At least two paired subsamples are required. No random subsampling, shuffling, out-of-sample tuning, rank-cap clipping or interpolation occurs.

Every cell reruns IC with that subsample's dimensions and T, including its own centering and iterative initialization. Numerical fit failures are recorded, not omitted from the variance. Their rank is -1 and variance NaN. Finite unconverged results remain available but are ineligible for default plateau selection. options records the controls passed to the IC selector.

References

Han, Y., Chen, R., and Zhang, C.-H. (2022), "Rank Determination in Tensor Factor Model", https://arxiv.org/html/2011.07131v3, Remark 8 and Section 5.4. S[k,c] is the population variance over all supplied subsamples (ddof=0). The explicit index grid and numerical plateau interpretation are user/package choices, not an automatically consistent selector for arbitrary data.

@dataclass
class MultiTermConstrainedFactorResult:
466@dataclass
467class MultiTermConstrainedFactorResult:
468    """Sum of separately constrained components with joint least-squares scores.
469
470    ``loadings[j]`` is a (row,column) pair and ``factors[j]`` has shape
471    (T,rj,cj). Components exclude ``mean``. Overlapping components are generally
472    not orthogonal: their energies do not obey a Pythagorean decomposition.
473    ``identification_diagnostics`` records the surviving projected ranks, not
474    a proof of population identification. ``converged=True`` means direct
475    linear algebra completed, not statistical consistency or rank correctness.
476
477    References
478    ----------
479    Chen, Tsay and Chen (2020), Constrained Factor Models for High-Dimensional
480    Matrix-Variate Time Series. https://doi.org/10.1080/01621459.2019.1584899
481    """
482
483    loadings: tuple
484    factors: tuple
485    signal: np.ndarray
486    residuals: np.ndarray
487    mean: np.ndarray
488    constraint_bases: tuple
489    eigenvalues: tuple
490    rank_diagnostics: tuple
491    identification_diagnostics: tuple
492    score_condition: float
493    score_design_rank: int
494    data_scale: float
495    lags: tuple
496    _score_inverse: np.ndarray = field(repr=False)
497    method: str = "multiterm-constrained-lagged"
498    converged: bool = True
499    n_iter: int = 0
500
501    @property
502    def ranks(self):
503        return tuple((row.shape[1], column.shape[1]) for row, column in self.loadings)
504
505    @property
506    def component_signals(self):
507        return tuple(
508            _reconstruct_parts([core], [loading], np.zeros_like(self.mean))
509            for core, loading in zip(self.factors, self.loadings)
510        )
511
512    def transform(self, X):
513        """Jointly solve new contemporaneous component scores in vec_F order.
514
515        References
516        ----------
517        Chen, Tsay and Chen (2020), Constrained Factor Models for High-Dimensional
518        Matrix-Variate Time Series. https://doi.org/10.1080/01621459.2019.1584899
519        Joint score regression completes the reconstruction of Section 3.3's
520        loading estimator; it is an implementation extension, not an explicit
521        paper factor-update equation or a forecast. Loadings remain fixed.
522        """
523        work, scales = _new_work(X, self.mean)
524        flat = work.transpose(0, 2, 1).reshape(len(work), -1)
525        with np.errstate(over="ignore", invalid="ignore"):
526            scores = _finite_output(
527                (flat @ self._score_inverse.T) * scales[:, None], "joint factor scores"
528            )
529        cores, start = [], 0
530        for r, c in self.ranks:
531            cores.append(
532                scores[:, start : start + r * c]
533                .reshape(len(work), c, r)
534                .transpose(0, 2, 1)
535            )
536            start += r * c
537        return tuple(cores)
538
539    def inverse_transform(self, factors):
540        """Sum component signals and restore the optional training mean.
541
542        References
543        ----------
544        Chen, Tsay and Chen (2020), Constrained Factor Models for High-Dimensional
545        Matrix-Variate Time Series, equation (3).
546        https://doi.org/10.1080/01621459.2019.1584899
547        Mean restoration is an implementation convention.
548        """
549        try:
550            factors = tuple(factors)
551        except TypeError as exc:
552            raise ValueError("factors must contain one core series per term") from exc
553        if len(factors) != len(self.ranks):
554            raise ValueError("factors must contain one core series per term")
555        cores = tuple(
556            _core_array(core, rank, f"factors[{j}]")
557            for j, (core, rank) in enumerate(zip(factors, self.ranks))
558        )
559        if len({len(core) for core in cores}) != 1:
560            raise ValueError("component factor series must have equal sample counts")
561        return _reconstruct_parts(cores, self.loadings, self.mean)

Sum of separately constrained components with joint least-squares scores.

loadings[j] is a (row,column) pair and factors[j] has shape (T,rj,cj). Components exclude mean. Overlapping components are generally not orthogonal: their energies do not obey a Pythagorean decomposition. identification_diagnostics records the surviving projected ranks, not a proof of population identification. converged=True means direct linear algebra completed, not statistical consistency or rank correctness.

References

Chen, Tsay and Chen (2020), Constrained Factor Models for High-Dimensional Matrix-Variate Time Series. https://doi.org/10.1080/01621459.2019.1584899

MultiTermConstrainedFactorResult( loadings: tuple, factors: tuple, signal: numpy.ndarray, residuals: numpy.ndarray, mean: numpy.ndarray, constraint_bases: tuple, eigenvalues: tuple, rank_diagnostics: tuple, identification_diagnostics: tuple, score_condition: float, score_design_rank: int, data_scale: float, lags: tuple, _score_inverse: numpy.ndarray, method: str = 'multiterm-constrained-lagged', converged: bool = True, n_iter: int = 0)
loadings: tuple
factors: tuple
signal: numpy.ndarray
residuals: numpy.ndarray
mean: numpy.ndarray
constraint_bases: tuple
eigenvalues: tuple
rank_diagnostics: tuple
identification_diagnostics: tuple
score_condition: float
score_design_rank: int
data_scale: float
lags: tuple
method: str = 'multiterm-constrained-lagged'
converged: bool = True
n_iter: int = 0
ranks
501    @property
502    def ranks(self):
503        return tuple((row.shape[1], column.shape[1]) for row, column in self.loadings)
component_signals
505    @property
506    def component_signals(self):
507        return tuple(
508            _reconstruct_parts([core], [loading], np.zeros_like(self.mean))
509            for core, loading in zip(self.factors, self.loadings)
510        )
def transform(self, X):
512    def transform(self, X):
513        """Jointly solve new contemporaneous component scores in vec_F order.
514
515        References
516        ----------
517        Chen, Tsay and Chen (2020), Constrained Factor Models for High-Dimensional
518        Matrix-Variate Time Series. https://doi.org/10.1080/01621459.2019.1584899
519        Joint score regression completes the reconstruction of Section 3.3's
520        loading estimator; it is an implementation extension, not an explicit
521        paper factor-update equation or a forecast. Loadings remain fixed.
522        """
523        work, scales = _new_work(X, self.mean)
524        flat = work.transpose(0, 2, 1).reshape(len(work), -1)
525        with np.errstate(over="ignore", invalid="ignore"):
526            scores = _finite_output(
527                (flat @ self._score_inverse.T) * scales[:, None], "joint factor scores"
528            )
529        cores, start = [], 0
530        for r, c in self.ranks:
531            cores.append(
532                scores[:, start : start + r * c]
533                .reshape(len(work), c, r)
534                .transpose(0, 2, 1)
535            )
536            start += r * c
537        return tuple(cores)

Jointly solve new contemporaneous component scores in vec_F order.

References

Chen, Tsay and Chen (2020), Constrained Factor Models for High-Dimensional Matrix-Variate Time Series. https://doi.org/10.1080/01621459.2019.1584899 Joint score regression completes the reconstruction of Section 3.3's loading estimator; it is an implementation extension, not an explicit paper factor-update equation or a forecast. Loadings remain fixed.

def inverse_transform(self, factors):
539    def inverse_transform(self, factors):
540        """Sum component signals and restore the optional training mean.
541
542        References
543        ----------
544        Chen, Tsay and Chen (2020), Constrained Factor Models for High-Dimensional
545        Matrix-Variate Time Series, equation (3).
546        https://doi.org/10.1080/01621459.2019.1584899
547        Mean restoration is an implementation convention.
548        """
549        try:
550            factors = tuple(factors)
551        except TypeError as exc:
552            raise ValueError("factors must contain one core series per term") from exc
553        if len(factors) != len(self.ranks):
554            raise ValueError("factors must contain one core series per term")
555        cores = tuple(
556            _core_array(core, rank, f"factors[{j}]")
557            for j, (core, rank) in enumerate(zip(factors, self.ranks))
558        )
559        if len({len(core) for core in cores}) != 1:
560            raise ValueError("component factor series must have equal sample counts")
561        return _reconstruct_parts(cores, self.loadings, self.mean)

Sum component signals and restore the optional training mean.

References

Chen, Tsay and Chen (2020), Constrained Factor Models for High-Dimensional Matrix-Variate Time Series, equation (3). https://doi.org/10.1080/01621459.2019.1584899 Mean restoration is an implementation convention.

@dataclass
class PartialConstrainedFactorResult:
216@dataclass
217class PartialConstrainedFactorResult:
218    """Four-block partial model with shared orthonormal row/column groups.
219
220    ``ranks`` are total ranks; ``row_ranks`` and ``column_ranks`` distinguish
221    supplied constraints from their complements. ``factor_blocks`` and
222    ``signal_blocks`` are 2-by-2 tuples, excluding the optional training mean.
223    Eigenvalues and block moments use the single original ``X/data_scale``
224    normalization; fourth-power physical moment units need not be representable.
225    ``converged=True`` describes direct eigensolves, not statistical validity.
226
227    References
228    ----------
229    Chen, Tsay and Chen (2020), Constrained Factor Models for High-Dimensional
230    Matrix-Variate Time Series. https://doi.org/10.1080/01621459.2019.1584899
231    """
232
233    loadings: tuple
234    factors: np.ndarray
235    signal: np.ndarray
236    residuals: np.ndarray
237    mean: np.ndarray
238    row_ranks: tuple
239    column_ranks: tuple
240    constraint_bases: tuple
241    eigenvalues: tuple
242    block_moments: tuple
243    rank_diagnostics: tuple
244    data_scale: float
245    interactions: bool
246    lags: tuple
247    method: str = "partial-constrained-lagged"
248    converged: bool = True
249    n_iter: int = 0
250
251    @property
252    def ranks(self):
253        return sum(self.row_ranks), sum(self.column_ranks)
254
255    @property
256    def factor_blocks(self):
257        r, c = self.row_ranks[0], self.column_ranks[0]
258        return tuple(
259            tuple(self.factors[:, rs, cs] for cs in (slice(0, c), slice(c, None)))
260            for rs in (slice(0, r), slice(r, None))
261        )
262
263    @property
264    def signal_blocks(self):
265        rows = np.split(self.loadings[0], [self.row_ranks[0]], axis=1)
266        columns = np.split(self.loadings[1], [self.column_ranks[0]], axis=1)
267        return tuple(
268            tuple(
269                _reconstruct_parts(
270                    [self.factor_blocks[l][k]],
271                    [(rows[l], columns[k])],
272                    np.zeros_like(self.mean),
273                )
274                for k in range(2)
275            )
276            for l in range(2)
277        )
278
279    def transform(self, X):
280        """Project new observations with fixed shared loading spaces.
281
282        References
283        ----------
284        Chen, Tsay and Chen (2020), Constrained Factor Models for High-Dimensional
285        Matrix-Variate Time Series, equations (5)--(6).
286        https://doi.org/10.1080/01621459.2019.1584899
287        This contemporaneous projection is not a forecast or loading refit.
288        """
289        work, scales = _new_work(X, self.mean)
290        cores = self.loadings[0].T @ work @ self.loadings[1]
291        if not self.interactions:
292            r, c = self.row_ranks[0], self.column_ranks[0]
293            cores[:, :r, c:] = 0
294            cores[:, r:, :c] = 0
295        with np.errstate(over="ignore", invalid="ignore"):
296            return _finite_output(cores * scales[:, None, None], "factor scores")
297
298    def inverse_transform(self, factors):
299        """Reconstruct the fitted block model and restore its training mean.
300
301        References
302        ----------
303        Chen, Tsay and Chen (2020), Constrained Factor Models for High-Dimensional
304        Matrix-Variate Time Series. https://doi.org/10.1080/01621459.2019.1584899
305        Mean restoration is an explicit implementation convention.
306        """
307        factors = _core_array(factors, self.ranks, "factors")
308        if not self.interactions:
309            r, c = self.row_ranks[0], self.column_ranks[0]
310            if np.any(factors[:, :r, c:] != 0) or np.any(factors[:, r:, :c] != 0):
311                raise ValueError(
312                    "diagonal interaction model requires zero cross blocks"
313                )
314        return _reconstruct_parts([factors], [self.loadings], self.mean)

Four-block partial model with shared orthonormal row/column groups.

ranks are total ranks; row_ranks and column_ranks distinguish supplied constraints from their complements. factor_blocks and signal_blocks are 2-by-2 tuples, excluding the optional training mean. Eigenvalues and block moments use the single original X/data_scale normalization; fourth-power physical moment units need not be representable. converged=True describes direct eigensolves, not statistical validity.

References

Chen, Tsay and Chen (2020), Constrained Factor Models for High-Dimensional Matrix-Variate Time Series. https://doi.org/10.1080/01621459.2019.1584899

PartialConstrainedFactorResult( loadings: tuple, factors: numpy.ndarray, signal: numpy.ndarray, residuals: numpy.ndarray, mean: numpy.ndarray, row_ranks: tuple, column_ranks: tuple, constraint_bases: tuple, eigenvalues: tuple, block_moments: tuple, rank_diagnostics: tuple, data_scale: float, interactions: bool, lags: tuple, method: str = 'partial-constrained-lagged', converged: bool = True, n_iter: int = 0)
loadings: tuple
factors: numpy.ndarray
signal: numpy.ndarray
residuals: numpy.ndarray
mean: numpy.ndarray
row_ranks: tuple
column_ranks: tuple
constraint_bases: tuple
eigenvalues: tuple
block_moments: tuple
rank_diagnostics: tuple
data_scale: float
interactions: bool
lags: tuple
method: str = 'partial-constrained-lagged'
converged: bool = True
n_iter: int = 0
ranks
251    @property
252    def ranks(self):
253        return sum(self.row_ranks), sum(self.column_ranks)
factor_blocks
255    @property
256    def factor_blocks(self):
257        r, c = self.row_ranks[0], self.column_ranks[0]
258        return tuple(
259            tuple(self.factors[:, rs, cs] for cs in (slice(0, c), slice(c, None)))
260            for rs in (slice(0, r), slice(r, None))
261        )
signal_blocks
263    @property
264    def signal_blocks(self):
265        rows = np.split(self.loadings[0], [self.row_ranks[0]], axis=1)
266        columns = np.split(self.loadings[1], [self.column_ranks[0]], axis=1)
267        return tuple(
268            tuple(
269                _reconstruct_parts(
270                    [self.factor_blocks[l][k]],
271                    [(rows[l], columns[k])],
272                    np.zeros_like(self.mean),
273                )
274                for k in range(2)
275            )
276            for l in range(2)
277        )
def transform(self, X):
279    def transform(self, X):
280        """Project new observations with fixed shared loading spaces.
281
282        References
283        ----------
284        Chen, Tsay and Chen (2020), Constrained Factor Models for High-Dimensional
285        Matrix-Variate Time Series, equations (5)--(6).
286        https://doi.org/10.1080/01621459.2019.1584899
287        This contemporaneous projection is not a forecast or loading refit.
288        """
289        work, scales = _new_work(X, self.mean)
290        cores = self.loadings[0].T @ work @ self.loadings[1]
291        if not self.interactions:
292            r, c = self.row_ranks[0], self.column_ranks[0]
293            cores[:, :r, c:] = 0
294            cores[:, r:, :c] = 0
295        with np.errstate(over="ignore", invalid="ignore"):
296            return _finite_output(cores * scales[:, None, None], "factor scores")

Project new observations with fixed shared loading spaces.

References

Chen, Tsay and Chen (2020), Constrained Factor Models for High-Dimensional Matrix-Variate Time Series, equations (5)--(6). https://doi.org/10.1080/01621459.2019.1584899 This contemporaneous projection is not a forecast or loading refit.

def inverse_transform(self, factors):
298    def inverse_transform(self, factors):
299        """Reconstruct the fitted block model and restore its training mean.
300
301        References
302        ----------
303        Chen, Tsay and Chen (2020), Constrained Factor Models for High-Dimensional
304        Matrix-Variate Time Series. https://doi.org/10.1080/01621459.2019.1584899
305        Mean restoration is an explicit implementation convention.
306        """
307        factors = _core_array(factors, self.ranks, "factors")
308        if not self.interactions:
309            r, c = self.row_ranks[0], self.column_ranks[0]
310            if np.any(factors[:, :r, c:] != 0) or np.any(factors[:, r:, :c] != 0):
311                raise ValueError(
312                    "diagonal interaction model requires zero cross blocks"
313                )
314        return _reconstruct_parts([factors], [self.loadings], self.mean)

Reconstruct the fitted block model and restore its training mean.

References

Chen, Tsay and Chen (2020), Constrained Factor Models for High-Dimensional Matrix-Variate Time Series. https://doi.org/10.1080/01621459.2019.1584899 Mean restoration is an explicit implementation convention.

def fit_multiterm_constrained_factor( X, constraints, ranks=None, *, lags=1, center=False, max_design_elements=10000000):
564def fit_multiterm_constrained_factor(
565    X,
566    constraints,
567    ranks=None,
568    *,
569    lags=1,
570    center=False,
571    max_design_elements=10_000_000,
572):
573    """Fit a sum of constrained matrix factor terms, including overlapping spans.
574
575    ``constraints`` is a nonempty sequence of (row_basis,column_basis) pairs;
576    each basis is full column rank, or None for the full corresponding space.
577    ``ranks`` supplies one (row_rank,column_rank) pair per term. None ranks use
578    the half-block-dimension numerical ``eigenvalue_ratio`` convention, not a
579    calibrated absence-of-factors test; explicit (0,0) omits a term and its competing
580    projections. A term must have both ranks positive or both zero.
581
582    Orthogonal product spaces are isolated by own-space projections (Section
583    3.3); either row OR column orthogonality removes a competing term. Otherwise
584    Remark 3 estimates row loadings after annihilating competing column spans,
585    and column loadings after annihilating competing row spans. Complement
586    coordinates are used instead of dense projectors. Already annihilated
587    orthogonal competitors are excluded, avoiding unnecessary information loss.
588    For more than two terms, removing the union of competing spans is the
589    explicit algebraic extension of the paper's two-term construction.
590
591    Both the projected constraints and estimated loadings must retain the
592    requested numerical ranks, and the joint vec_F Kronecker score design must
593    be full column rank. These are necessary numerical checks, not guarantees
594    that unknown population factors survive or have informative lag dynamics.
595    Near-overlap can strongly amplify noise; survival singular values and the
596    joint score condition number are returned, with no implicit ridge.
597
598    Scores jointly minimize the original-coordinate reconstruction error.
599    This is an explicit reconstruction extension for overlapping terms, not a
600    factor-update equation specified in Remark 3. The dense score design is
601    guarded by ``max_design_elements``. Fitting uses one original-data scale;
602    centering is optional and no entrywise standardization is performed.
603    White measurement noise independent of temporally informative factors is
604    required. No forecasting, inference or calibrated rank test is supplied.
605
606    References
607    ----------
608    Chen, Tsay and Chen (2020), Constrained Factor Models for High-Dimensional
609    Matrix-Variate Time Series, equation (3), Section 3.3 and Remark 3.
610    https://doi.org/10.1080/01621459.2019.1584899
611    Algorithm text: https://arxiv.org/html/1710.06075v3#S3.SS3
612    """
613    X, work, mean, scale = _prepare(X, center)
614    lags = _lags(lags, len(X))
615    max_design_elements = positive_int(max_design_elements, "max_design_elements")
616    try:
617        constraints = tuple(tuple(pair) for pair in constraints)
618    except TypeError as exc:
619        raise ValueError("constraints must contain (row,column) basis pairs") from exc
620    if not constraints or any(len(pair) != 2 for pair in constraints):
621        raise ValueError("constraints must contain nonempty (row,column) basis pairs")
622    bases = tuple(
623        tuple(
624            _constraint_basis(h, X.shape[k + 1], f"constraints[{j}][{k}]")
625            for k, h in enumerate(pair)
626        )
627        for j, pair in enumerate(constraints)
628    )
629    if ranks is None:
630        ranks = (None,) * len(bases)
631    try:
632        ranks = tuple(ranks)
633    except TypeError as exc:
634        raise ValueError("ranks must contain one pair per term") from exc
635    if len(ranks) != len(bases):
636        raise ValueError("ranks must contain one pair per term")
637    requested = tuple(
638        _group_ranks(pair, [h.shape[1] for h in hs], f"ranks[{j}]")
639        for j, (pair, hs) in enumerate(zip(ranks, bases))
640    )
641    for pair in requested:
642        if 0 in pair and pair != (0, 0):
643            raise ValueError("an omitted term must have ranks (0,0)")
644    active = [j for j, pair in enumerate(requested) if pair != (0, 0)]
645    loadings, spectra, rank_info, identification = [], [], [], []
646    for j, (row, column) in enumerate(bases):
647        others = [i for i in active if i != j]
648        direct = all(
649            _orthogonal(row, bases[i][0]) or _orthogonal(column, bases[i][1])
650            for i in others
651        )
652        if requested[j] == (0, 0):
653            loadings.append((row[:, :0], column[:, :0]))
654            spectra.append((np.zeros(row.shape[1]), np.zeros(column.shape[1])))
655            rank_info.append(
656                tuple(
657                    dict(
658                        rank=0,
659                        automatic=False,
660                        numerical_moment_rank=None,
661                        search_bound=None,
662                        zero_moment=None,
663                    )
664                    for _ in range(2)
665                )
666            )
667            identification.append(
668                dict(estimator="omitted", row_survival=None, column_survival=None)
669            )
670            continue
671        if direct:
672            reduced = row.T @ work @ column
673            series = (reduced, reduced.transpose(0, 2, 1))
674            row_complement, column_complement = np.eye(len(row)), np.eye(len(column))
675        else:
676            column_competitors = [
677                bases[i][1] for i in others if not _orthogonal(row, bases[i][0])
678            ]
679            row_competitors = [
680                bases[i][0] for i in others if not _orthogonal(column, bases[i][1])
681            ]
682            row_complement = _complement_union(row_competitors, len(row))
683            column_complement = _complement_union(column_competitors, len(column))
684            if not row_complement.shape[1] or not column_complement.shape[1]:
685                raise ValueError(
686                    f"term {j} is unidentified: competing constraints exhaust an ambient space"
687                )
688            series = (
689                row.T @ work @ column_complement,
690                column.T @ work.transpose(0, 2, 1) @ row_complement,
691            )
692        pairs = tuple(
693            _moment_space(_lagged_moment(z, lags, "topup"), rank, f"term {j} mode {k}")
694            for k, (z, rank) in enumerate(zip(series, requested[j]))
695        )
696        if any(p[0].shape[1] == 0 for p in pairs):
697            raise ValueError(
698                f"term {j} has no informative projected dynamics; explicitly omit it with (0,0)"
699            )
700        survived = (
701            _survival(row_complement, row, pairs[0][0], f"term {j} row"),
702            _survival(column_complement, column, pairs[1][0], f"term {j} column"),
703        )
704        loadings.append((row @ pairs[0][0], column @ pairs[1][0]))
705        spectra.append(tuple(p[1] for p in pairs))
706        rank_info.append(tuple(p[2] for p in pairs))
707        identification.append(
708            dict(
709                estimator="direct" if direct else "competing-complements",
710                row_survival=survived[0],
711                column_survival=survived[1],
712            )
713        )
714    score_dimension = sum(row.shape[1] * column.shape[1] for row, column in loadings)
715    if X.shape[1] * X.shape[2] * score_dimension > max_design_elements:
716        raise ValueError("joint score design exceeds max_design_elements")
717    design = np.column_stack([np.kron(column, row) for row, column in loadings])
718    if score_dimension:
719        u, s, vh = np.linalg.svd(design, full_matrices=False)
720        rank = int(np.count_nonzero(s > s[0] * max(design.shape) * np.finfo(float).eps))
721        if rank != score_dimension:
722            raise ValueError(
723                "joint score design is rank deficient: components are not identifiable"
724            )
725        inverse, condition = (vh.T / s) @ u.T, float(s[0] / s[-1])
726    else:
727        inverse, condition, rank = np.empty((0, design.shape[0])), 1.0, 0
728    result = MultiTermConstrainedFactorResult(
729        tuple(loadings),
730        (),
731        np.empty_like(X),
732        np.empty_like(X),
733        mean,
734        bases,
735        tuple(spectra),
736        tuple(rank_info),
737        tuple(identification),
738        condition,
739        rank,
740        scale,
741        lags,
742        inverse,
743    )
744    result.factors = result.transform(X)
745    result.signal = result.inverse_transform(result.factors)
746    with np.errstate(over="ignore", invalid="ignore"):
747        result.residuals = _finite_output(X - result.signal, "residuals")
748    return result

Fit a sum of constrained matrix factor terms, including overlapping spans.

constraints is a nonempty sequence of (row_basis,column_basis) pairs; each basis is full column rank, or None for the full corresponding space. ranks supplies one (row_rank,column_rank) pair per term. None ranks use the half-block-dimension numerical eigenvalue_ratio convention, not a calibrated absence-of-factors test; explicit (0,0) omits a term and its competing projections. A term must have both ranks positive or both zero.

Orthogonal product spaces are isolated by own-space projections (Section 3.3); either row OR column orthogonality removes a competing term. Otherwise Remark 3 estimates row loadings after annihilating competing column spans, and column loadings after annihilating competing row spans. Complement coordinates are used instead of dense projectors. Already annihilated orthogonal competitors are excluded, avoiding unnecessary information loss. For more than two terms, removing the union of competing spans is the explicit algebraic extension of the paper's two-term construction.

Both the projected constraints and estimated loadings must retain the requested numerical ranks, and the joint vec_F Kronecker score design must be full column rank. These are necessary numerical checks, not guarantees that unknown population factors survive or have informative lag dynamics. Near-overlap can strongly amplify noise; survival singular values and the joint score condition number are returned, with no implicit ridge.

Scores jointly minimize the original-coordinate reconstruction error. This is an explicit reconstruction extension for overlapping terms, not a factor-update equation specified in Remark 3. The dense score design is guarded by max_design_elements. Fitting uses one original-data scale; centering is optional and no entrywise standardization is performed. White measurement noise independent of temporally informative factors is required. No forecasting, inference or calibrated rank test is supplied.

References

Chen, Tsay and Chen (2020), Constrained Factor Models for High-Dimensional Matrix-Variate Time Series, equation (3), Section 3.3 and Remark 3. https://doi.org/10.1080/01621459.2019.1584899 Algorithm text: https://arxiv.org/html/1710.06075v3#S3.SS3

def fit_partial_constrained_factor( X, *, row_constraints=None, column_constraints=None, row_ranks=(None, None), column_ranks=(None, None), interactions=True, lags=1, center=False):
317def fit_partial_constrained_factor(
318    X,
319    *,
320    row_constraints=None,
321    column_constraints=None,
322    row_ranks=(None, None),
323    column_ranks=(None, None),
324    interactions=True,
325    lags=1,
326    center=False,
327):
328    """Fit the partial matrix factor model with shared four-block loadings.
329
330    Constraint matrices have shapes (p,m1), (q,m2). Each rank pair gives the
331    rank inside the supplied space followed by the rank in its orthogonal
332    complement, bounded by (m1,p-m1) or (m2,q-m2). None constraints mean the
333    full space; an explicit (dimension,0) matrix means an empty supplied space.
334    Empty groups have rank zero; explicit zero ranks suppress a loading group.
335    Missing ranks use a numerical eigenvalue ratio, not a no-factor test;
336    exactly zero moments return zero. Positive requested ranks must have
337    numerically informative lag moments. Full population identification and
338    constraint correctness cannot be certified by these finite-sample checks.
339
340    For each l,k, form X_lk=H_Rl' X H_Ck. Equation (14) adds the SEPARATE
341    lag-moment Gram matrices over k for row group l, and over l for column
342    group k. Cross-block lag products are not included. Data are normalized
343    once before all blocks; blockwise rescaling would change the estimator.
344    ``interactions=False`` imposes equation (6)'s zero cross-factor blocks;
345    only diagonal blocks then enter loading estimation and reconstruction.
346    This restriction must be scientifically justified, not selected implicitly.
347
348    ``center=True`` removes/restores an ordinary training mean. No entrywise
349    standardization, forecast dynamics, rank tests, or uncertainty estimates
350    are supplied. See ``eigenvalue_ratio``: the implementation uses half the
351    block dimension as its search bound, not the paper's dimensionally
352    ambiguous ambient-p bound. Full block ranks require explicit specification.
353
354    References
355    ----------
356    Chen, Tsay and Chen (2020), Constrained Factor Models for High-Dimensional
357    Matrix-Variate Time Series, equations (5), (6), (12), (14), Section 3.4.
358    https://doi.org/10.1080/01621459.2019.1584899
359    Algorithm text: https://arxiv.org/html/1710.06075v3#S3.SS4
360    """
361    X, work, mean, scale = _prepare(X, center)
362    if not isinstance(interactions, (bool, np.bool_)):
363        raise ValueError("interactions must be boolean")
364    lags = _lags(lags, len(X))
365    rows = _split_constraints(row_constraints, X.shape[1], "row_constraints")
366    columns = _split_constraints(column_constraints, X.shape[2], "column_constraints")
367    requested = (
368        _group_ranks(row_ranks, [h.shape[1] for h in rows], "row_ranks"),
369        _group_ranks(column_ranks, [h.shape[1] for h in columns], "column_ranks"),
370    )
371    moments = [[], []]
372    for l in range(2):
373        rm, cm = [], []
374        for k in range(2):
375            block = rows[l].T @ work @ columns[k]
376            active = interactions or l == k
377            rm.append(
378                _lagged_moment(block, lags, "topup")
379                if active and block.size
380                else np.zeros((rows[l].shape[1],) * 2)
381            )
382            cm.append(
383                _lagged_moment(block.transpose(0, 2, 1), lags, "topup")
384                if active and block.size
385                else np.zeros((columns[k].shape[1],) * 2)
386            )
387        moments[0].append(tuple(rm))
388        moments[1].append(tuple(cm))
389    pairs = [[], []]
390    for l in range(2):
391        pairs[0].append(
392            _moment_space(sum(moments[0][l]), requested[0][l], f"row group {l}")
393        )
394        pairs[1].append(
395            _moment_space(
396                sum(moments[1][k][l] for k in range(2)),
397                requested[1][l],
398                f"column group {l}",
399            )
400        )
401    loading = tuple(
402        np.column_stack([h @ entry[0] for h, entry in zip(bases, pair)])
403        for bases, pair in zip((rows, columns), pairs)
404    )
405    result = PartialConstrainedFactorResult(
406        loading,
407        np.empty((0, 0, 0)),
408        np.empty_like(X),
409        np.empty_like(X),
410        mean,
411        tuple(p[0].shape[1] for p in pairs[0]),
412        tuple(p[0].shape[1] for p in pairs[1]),
413        (rows, columns),
414        tuple(tuple(p[1] for p in pair) for pair in pairs),
415        tuple(tuple(part) for part in moments),
416        tuple(tuple(p[2] for p in pair) for pair in pairs),
417        scale,
418        bool(interactions),
419        lags,
420    )
421    result.factors = result.transform(X)
422    result.signal = result.inverse_transform(result.factors)
423    with np.errstate(over="ignore", invalid="ignore"):
424        result.residuals = _finite_output(X - result.signal, "residuals")
425    return result

Fit the partial matrix factor model with shared four-block loadings.

Constraint matrices have shapes (p,m1), (q,m2). Each rank pair gives the rank inside the supplied space followed by the rank in its orthogonal complement, bounded by (m1,p-m1) or (m2,q-m2). None constraints mean the full space; an explicit (dimension,0) matrix means an empty supplied space. Empty groups have rank zero; explicit zero ranks suppress a loading group. Missing ranks use a numerical eigenvalue ratio, not a no-factor test; exactly zero moments return zero. Positive requested ranks must have numerically informative lag moments. Full population identification and constraint correctness cannot be certified by these finite-sample checks.

For each l,k, form X_lk=H_Rl' X H_Ck. Equation (14) adds the SEPARATE lag-moment Gram matrices over k for row group l, and over l for column group k. Cross-block lag products are not included. Data are normalized once before all blocks; blockwise rescaling would change the estimator. interactions=False imposes equation (6)'s zero cross-factor blocks; only diagonal blocks then enter loading estimation and reconstruction. This restriction must be scientifically justified, not selected implicitly.

center=True removes/restores an ordinary training mean. No entrywise standardization, forecast dynamics, rank tests, or uncertainty estimates are supplied. See eigenvalue_ratio: the implementation uses half the block dimension as its search bound, not the paper's dimensionally ambiguous ambient-p bound. Full block ranks require explicit specification.

References

Chen, Tsay and Chen (2020), Constrained Factor Models for High-Dimensional Matrix-Variate Time Series, equations (5), (6), (12), (14), Section 3.4. https://doi.org/10.1080/01621459.2019.1584899 Algorithm text: https://arxiv.org/html/1710.06075v3#S3.SS4

@dataclass(frozen=True)
class MARMADiagnostics:
213@dataclass(frozen=True)
214class MARMADiagnostics:
215    """Numerical full-polynomial root checks, not structural identification.
216
217    ``is_stable`` and ``is_invertible`` require companion radii below
218    ``1-tolerance``. Roots near the boundary are not certified. Gauge choices
219    do not establish left coprimeness or minimal ARMA orders (Tsay §2.1).
220    """
221
222    ar_eigenvalues: np.ndarray
223    ma_eigenvalues: np.ndarray
224    ar_spectral_radius: float
225    ma_spectral_radius: float
226    is_stable: bool
227    is_invertible: bool
228    tolerance: float
229    structural_identification_certified: bool = False

Numerical full-polynomial root checks, not structural identification.

is_stable and is_invertible require companion radii below 1-tolerance. Roots near the boundary are not certified. Gauge choices do not establish left coprimeness or minimal ARMA orders (Tsay §2.1).

MARMADiagnostics( ar_eigenvalues: numpy.ndarray, ma_eigenvalues: numpy.ndarray, ar_spectral_radius: float, ma_spectral_radius: float, is_stable: bool, is_invertible: bool, tolerance: float, structural_identification_certified: bool = False)
ar_eigenvalues: numpy.ndarray
ma_eigenvalues: numpy.ndarray
ar_spectral_radius: float
ma_spectral_radius: float
is_stable: bool
is_invertible: bool
tolerance: float
structural_identification_certified: bool = False
@dataclass
class MARMAFilterResult:
355@dataclass
356class MARMAFilterResult:
357    """Conditional innovations and plug-in forecasts for the module's model.
358
359    Residuals have the full input length: the conditioning prefix is zero,
360    not estimated noise. Fitted values there are NaN. ``log_likelihood`` scores
361    only the suffix, with all Gaussian constants, if covariance is supplied.
362    Forecast covariances condition on the fixed initialization and parameters;
363    they omit parameter and presample-state uncertainty.
364    """
365
366    parameters: MARMAParameters
367    residuals: np.ndarray
368    fitted_values: np.ndarray
369    conditioning_mask: np.ndarray
370    log_likelihood: float | None
371    history: np.ndarray = field(repr=False)
372
373    def forecast(self, steps):
374        """Recurse using past innovations and zero future innovations (Tsay §2.4)."""
375        steps = positive_int(steps, "steps")
376        parameters = self.parameters
377        p, q = parameters.ar_order, parameters.ma_order
378        if not p and not q:
379            return np.repeat(parameters.intercept[None], steps, axis=0)
380        values = [x.copy() for x in self.history[-max(p, 1) :]]
381        noises = [x.copy() for x in self.residuals[-max(q, 1) :]]
382        predictions = []
383        for _ in range(steps):
384            scales = [np.max(abs(parameters.intercept))]
385            if p:
386                scales.extend(np.max(abs(x)) for x in values[-p:])
387            if q:
388                scales.extend(np.max(abs(x)) for x in noises[-q:])
389            scale = max(scales) or 1.0
390            value = parameters.intercept / scale
391            with np.errstate(over="ignore", invalid="ignore"):
392                for lag, (a, b) in enumerate(
393                    zip(parameters.ar_left, parameters.ar_right), 1
394                ):
395                    value = value + a @ (values[-lag] / scale) @ b.T
396                for lag, (a, b) in enumerate(
397                    zip(parameters.ma_left, parameters.ma_right), 1
398                ):
399                    value = value - a @ (noises[-lag] / scale) @ b.T
400                value = value * scale
401            value = _represented(value, "MARMA forecast")
402            predictions.append(value)
403            values.append(value)
404            noises.append(np.zeros_like(value))
405        return np.array(predictions)
406
407    def forecast_covariance(self, steps, *, max_dense_dimension=256):
408        """Dense horizon covariances from full impulse responses (Tsay §2.4)."""
409        return _forecast_covariance(self.parameters, steps, max_dense_dimension)

Conditional innovations and plug-in forecasts for the module's model.

Residuals have the full input length: the conditioning prefix is zero, not estimated noise. Fitted values there are NaN. log_likelihood scores only the suffix, with all Gaussian constants, if covariance is supplied. Forecast covariances condition on the fixed initialization and parameters; they omit parameter and presample-state uncertainty.

MARMAFilterResult( parameters: MARMAParameters, residuals: numpy.ndarray, fitted_values: numpy.ndarray, conditioning_mask: numpy.ndarray, log_likelihood: float | None, history: numpy.ndarray)
parameters: MARMAParameters
residuals: numpy.ndarray
fitted_values: numpy.ndarray
conditioning_mask: numpy.ndarray
log_likelihood: float | None
history: numpy.ndarray
def forecast(self, steps):
373    def forecast(self, steps):
374        """Recurse using past innovations and zero future innovations (Tsay §2.4)."""
375        steps = positive_int(steps, "steps")
376        parameters = self.parameters
377        p, q = parameters.ar_order, parameters.ma_order
378        if not p and not q:
379            return np.repeat(parameters.intercept[None], steps, axis=0)
380        values = [x.copy() for x in self.history[-max(p, 1) :]]
381        noises = [x.copy() for x in self.residuals[-max(q, 1) :]]
382        predictions = []
383        for _ in range(steps):
384            scales = [np.max(abs(parameters.intercept))]
385            if p:
386                scales.extend(np.max(abs(x)) for x in values[-p:])
387            if q:
388                scales.extend(np.max(abs(x)) for x in noises[-q:])
389            scale = max(scales) or 1.0
390            value = parameters.intercept / scale
391            with np.errstate(over="ignore", invalid="ignore"):
392                for lag, (a, b) in enumerate(
393                    zip(parameters.ar_left, parameters.ar_right), 1
394                ):
395                    value = value + a @ (values[-lag] / scale) @ b.T
396                for lag, (a, b) in enumerate(
397                    zip(parameters.ma_left, parameters.ma_right), 1
398                ):
399                    value = value - a @ (noises[-lag] / scale) @ b.T
400                value = value * scale
401            value = _represented(value, "MARMA forecast")
402            predictions.append(value)
403            values.append(value)
404            noises.append(np.zeros_like(value))
405        return np.array(predictions)

Recurse using past innovations and zero future innovations (Tsay §2.4).

def forecast_covariance(self, steps, *, max_dense_dimension=256):
407    def forecast_covariance(self, steps, *, max_dense_dimension=256):
408        """Dense horizon covariances from full impulse responses (Tsay §2.4)."""
409        return _forecast_covariance(self.parameters, steps, max_dense_dimension)

Dense horizon covariances from full impulse responses (Tsay §2.4).

@dataclass(frozen=True)
class MARMAOptimizationRun:
655@dataclass(frozen=True)
656class MARMAOptimizationRun:
657    """One local solve; convergence is numerical, not identification or globality."""
658
659    converged: bool
660    n_iter: int
661    objective_history: np.ndarray
662    gradient_norm: float
663    message: str
664    failed: bool = False
665    initialization_iterations: int = 0
666    initialization_converged: bool | None = None
667    initialization_objective_history: np.ndarray | None = None
668    initialization_message: str | None = None
669    constraint_residuals: np.ndarray | None = None
670    feasible: bool = False
671    optimizer: str = "BFGS"

One local solve; convergence is numerical, not identification or globality.

MARMAOptimizationRun( converged: bool, n_iter: int, objective_history: numpy.ndarray, gradient_norm: float, message: str, failed: bool = False, initialization_iterations: int = 0, initialization_converged: bool | None = None, initialization_objective_history: numpy.ndarray | None = None, initialization_message: str | None = None, constraint_residuals: numpy.ndarray | None = None, feasible: bool = False, optimizer: str = 'BFGS')
converged: bool
n_iter: int
objective_history: numpy.ndarray
gradient_norm: float
message: str
failed: bool = False
initialization_iterations: int = 0
initialization_converged: bool | None = None
initialization_objective_history: numpy.ndarray | None = None
initialization_message: str | None = None
constraint_residuals: numpy.ndarray | None = None
feasible: bool = False
optimizer: str = 'BFGS'
@dataclass(frozen=True)
class MARMAParameters:
 81@dataclass(frozen=True)
 82class MARMAParameters:
 83    """Parameters of ``X=C+sum A Xpast B.T+E-sum L Epast R.T``.
 84
 85    Each coefficient array has a leading lag axis; None or an empty sequence
 86    denotes zero lags. ``intercept`` is a required finite matrix, even when
 87    zero, and determines the spatial dimensions. Row/column covariances are
 88    either both omitted or both positive definite. Arrays are copied. No
 89    stability, invertibility or structural identification is presumed.
 90
 91    References
 92    ----------
 93    Tsay (2024), "Matrix-Variate Time Series Analysis: A Brief Review and Some
 94    New Developments", equations (4)--(5), https://doi.org/10.1111/insr.12558.
 95    Parameter/result methods inherit this model reference.
 96    """
 97
 98    ar_left: object
 99    ar_right: object
100    ma_left: object
101    ma_right: object
102    intercept: np.ndarray
103    row_covariance: np.ndarray | None = None
104    column_covariance: np.ndarray | None = None
105
106    def __post_init__(self):
107        raw = np.asarray(self.intercept)
108        if raw.ndim != 2 or not all(raw.shape):
109            raise ValueError("intercept must be a nonempty matrix")
110        m, n = raw.shape
111        object.__setattr__(self, "intercept", _finite(raw, (m, n), "intercept"))
112        for left_name, right_name in (("ar_left", "ar_right"), ("ma_left", "ma_right")):
113            for name, dim in ((left_name, m), (right_name, n)):
114                value = getattr(self, name)
115                raw = np.asarray([] if value is None else value)
116                if not raw.size:
117                    value = np.empty((0, dim, dim))
118                else:
119                    if raw.ndim == 2:
120                        raw = raw[None]
121                    if raw.ndim != 3:
122                        raise ValueError(f"{name} needs a leading lag axis")
123                    value = _finite(raw, (len(raw), dim, dim), name)
124                object.__setattr__(self, name, value)
125            if len(getattr(self, left_name)) != len(getattr(self, right_name)):
126                raise ValueError("left and right coefficients need matching lag counts")
127        if (self.row_covariance is None) != (self.column_covariance is None):
128            raise ValueError("supply both row and column covariances")
129        if self.row_covariance is not None:
130            for name, dim in (("row_covariance", m), ("column_covariance", n)):
131                value = _finite(getattr(self, name), (dim, dim), name)
132                _chol(value, name)
133                object.__setattr__(self, name, value)
134
135    @property
136    def ar_order(self):
137        return len(self.ar_left)
138
139    @property
140    def ma_order(self):
141        return len(self.ma_left)
142
143    @property
144    def conditioning_length(self):
145        return max(self.ar_order, self.ma_order)

Parameters of X=C+sum A Xpast B.T+E-sum L Epast R.T.

Each coefficient array has a leading lag axis; None or an empty sequence denotes zero lags. intercept is a required finite matrix, even when zero, and determines the spatial dimensions. Row/column covariances are either both omitted or both positive definite. Arrays are copied. No stability, invertibility or structural identification is presumed.

References

Tsay (2024), "Matrix-Variate Time Series Analysis: A Brief Review and Some New Developments", equations (4)--(5), https://doi.org/10.1111/insr.12558. Parameter/result methods inherit this model reference.

MARMAParameters( ar_left: object, ar_right: object, ma_left: object, ma_right: object, intercept: numpy.ndarray, row_covariance: numpy.ndarray | None = None, column_covariance: numpy.ndarray | None = None)
ar_left: object
ar_right: object
ma_left: object
ma_right: object
intercept: numpy.ndarray
row_covariance: numpy.ndarray | None = None
column_covariance: numpy.ndarray | None = None
ar_order
135    @property
136    def ar_order(self):
137        return len(self.ar_left)
ma_order
139    @property
140    def ma_order(self):
141        return len(self.ma_left)
conditioning_length
143    @property
144    def conditioning_length(self):
145        return max(self.ar_order, self.ma_order)
@dataclass
class MARMAResult:
674@dataclass
675class MARMAResult:
676    """Fitted rank-one MARMA with all local attempts and conditioning retained.
677
678    Objectives refer to numerically centered/scaled data. LS histories contain
679    mean per-time Frobenius squared errors; MLE histories contain mean per-time
680    Gaussian NLL. Convergence does not imply stability, invertibility, minimal
681    orders, left coprimeness or global optimality. Methods inherit Tsay's model
682    reference and the extension boundaries of :func:`fit_marma`.
683    """
684
685    parameters: MARMAParameters
686    filter_result: MARMAFilterResult
687    method: str
688    runs: tuple
689    selected_start: int
690    data_scale: float
691    location: np.ndarray
692    max_dense_dimension: int
693    enforce_admissibility: bool = True
694    stability_margin: float = 1e-6
695    _innovation_covariance: np.ndarray | None = field(default=None, repr=False)
696
697    @property
698    def converged(self):
699        return self.runs[self.selected_start].converged
700
701    @property
702    def n_iter(self):
703        return self.runs[self.selected_start].n_iter
704
705    @property
706    def objective_history(self):
707        return self.runs[self.selected_start].objective_history
708
709    @property
710    def objective(self):
711        return float(self.objective_history[-1])
712
713    @property
714    def residuals(self):
715        return self.filter_result.residuals[~self.filter_result.conditioning_mask]
716
717    @property
718    def fitted_values(self):
719        return self.filter_result.fitted_values[~self.filter_result.conditioning_mask]
720
721    @property
722    def log_likelihood(self):
723        return self.filter_result.log_likelihood
724
725    @property
726    def innovation_covariance(self):
727        """Dense plug-in innovation covariance; see forecast_covariance for scope."""
728        return self.forecast_covariance(1)[0]
729
730    @property
731    def diagnostics(self):
732        return marma_diagnostics(
733            self.parameters, max_dense_dimension=self.max_dense_dimension
734        )
735
736    def forecast(self, steps, history=None):
737        """Forecast after refiltering the complete supplied history, without lookahead."""
738        filtered = (
739            self.filter_result
740            if history is None
741            else filter_marma(history, self.parameters)
742        )
743        return filtered.forecast(steps)
744
745    def forecast_covariance(self, steps, *, max_dense_dimension=None):
746        """Full horizon covariance; LS uses uncentered residual second moments."""
747        maximum = (
748            self.max_dense_dimension
749            if max_dense_dimension is None
750            else max_dense_dimension
751        )
752        return _forecast_covariance(
753            self.parameters, steps, maximum, self._innovation_covariance
754        )

Fitted rank-one MARMA with all local attempts and conditioning retained.

Objectives refer to numerically centered/scaled data. LS histories contain mean per-time Frobenius squared errors; MLE histories contain mean per-time Gaussian NLL. Convergence does not imply stability, invertibility, minimal orders, left coprimeness or global optimality. Methods inherit Tsay's model reference and the extension boundaries of fit_marma().

MARMAResult( parameters: MARMAParameters, filter_result: MARMAFilterResult, method: str, runs: tuple, selected_start: int, data_scale: float, location: numpy.ndarray, max_dense_dimension: int, enforce_admissibility: bool = True, stability_margin: float = 1e-06, _innovation_covariance: numpy.ndarray | None = None)
parameters: MARMAParameters
filter_result: MARMAFilterResult
method: str
runs: tuple
selected_start: int
data_scale: float
location: numpy.ndarray
max_dense_dimension: int
enforce_admissibility: bool = True
stability_margin: float = 1e-06
converged
697    @property
698    def converged(self):
699        return self.runs[self.selected_start].converged
n_iter
701    @property
702    def n_iter(self):
703        return self.runs[self.selected_start].n_iter
objective_history
705    @property
706    def objective_history(self):
707        return self.runs[self.selected_start].objective_history
objective
709    @property
710    def objective(self):
711        return float(self.objective_history[-1])
residuals
713    @property
714    def residuals(self):
715        return self.filter_result.residuals[~self.filter_result.conditioning_mask]
fitted_values
717    @property
718    def fitted_values(self):
719        return self.filter_result.fitted_values[~self.filter_result.conditioning_mask]
log_likelihood
721    @property
722    def log_likelihood(self):
723        return self.filter_result.log_likelihood
innovation_covariance
725    @property
726    def innovation_covariance(self):
727        """Dense plug-in innovation covariance; see forecast_covariance for scope."""
728        return self.forecast_covariance(1)[0]

Dense plug-in innovation covariance; see forecast_covariance for scope.

diagnostics
730    @property
731    def diagnostics(self):
732        return marma_diagnostics(
733            self.parameters, max_dense_dimension=self.max_dense_dimension
734        )
def forecast(self, steps, history=None):
736    def forecast(self, steps, history=None):
737        """Forecast after refiltering the complete supplied history, without lookahead."""
738        filtered = (
739            self.filter_result
740            if history is None
741            else filter_marma(history, self.parameters)
742        )
743        return filtered.forecast(steps)

Forecast after refiltering the complete supplied history, without lookahead.

def forecast_covariance(self, steps, *, max_dense_dimension=None):
745    def forecast_covariance(self, steps, *, max_dense_dimension=None):
746        """Full horizon covariance; LS uses uncentered residual second moments."""
747        maximum = (
748            self.max_dense_dimension
749            if max_dense_dimension is None
750            else max_dense_dimension
751        )
752        return _forecast_covariance(
753            self.parameters, steps, maximum, self._innovation_covariance
754        )

Full horizon covariance; LS uses uncentered residual second moments.

@dataclass(frozen=True)
class MARMASimulation:
1097@dataclass(frozen=True)
1098class MARMASimulation:
1099    """Simulated observations, generating innovations and conditional means."""
1100
1101    observations: np.ndarray
1102    innovations: np.ndarray
1103    conditional_mean: np.ndarray

Simulated observations, generating innovations and conditional means.

MARMASimulation( observations: numpy.ndarray, innovations: numpy.ndarray, conditional_mean: numpy.ndarray)
observations: numpy.ndarray
innovations: numpy.ndarray
conditional_mean: numpy.ndarray
def filter_marma(X, parameters):
412def filter_marma(X, parameters):
413    """Filter the complete supplied history using the paper's zero-prefix rule.
414
415    For t0=max(p,q), condition on X[:t0], set E[:t0]=0 and evaluate only
416    X[t0:]. This is a conditional filter, not exact stationary/Kalman filtering.
417    A new call restarts the conditioning convention; do not filter chunks
418    separately to continue the same innovation state. Future observations
419    never affect an earlier innovation for fixed parameters.
420
421    References
422    ----------
423    Tsay (2024), "Matrix-Variate Time Series Analysis: A Brief Review and Some
424    New Developments", equations (4), (30), Section 2.5,
425    https://doi.org/10.1111/insr.12558. The Gaussian quadratic term uses the
426    necessary factor 1/2 omitted in printed equation (30).
427    """
428    parameters = _canonical(parameters)
429    X = as_series(X, min_samples=parameters.conditioning_length + 1)
430    if X.shape[1:] != parameters.intercept.shape:
431        raise ValueError("X and parameter spatial dimensions differ")
432    scale = max(np.max(abs(X)), np.max(abs(parameters.intercept))) or 1.0
433    arrays = (*_raw(parameters)[:4], parameters.intercept / scale)
434    errors, prediction = _innovations(X / scale, arrays, return_fitted=True)
435    residuals = _represented(_multiply(errors, scale), "physical innovations")
436    mask = np.arange(len(X)) < parameters.conditioning_length
437    fitted = _multiply(prediction, scale)
438    if parameters.conditioning_length == 0:
439        fitted = np.broadcast_to(parameters.intercept, X.shape).copy()
440    _represented(fitted[~mask], "conditional fitted values")
441    fitted[mask] = np.nan
442    likelihood = None
443    if parameters.row_covariance is not None:
444        # Roots share the data scale, avoiding covariance/data_scale**2.
445        root_u = _chol(parameters.row_covariance, "row covariance") / np.sqrt(scale)
446        root_v = _chol(parameters.column_covariance, "column covariance") / np.sqrt(
447            scale
448        )
449        loss, _ = _gaussian_loss(errors[~mask], root_u, root_v)
450        likelihood = -(loss + X.shape[1] * X.shape[2] * np.log(scale)) * (~mask).sum()
451        if not np.isfinite(likelihood):
452            raise FloatingPointError(
453                "conditional likelihood exceeds floating-point range"
454            )
455    return MARMAFilterResult(parameters, residuals, fitted, mask, likelihood, X.copy())

Filter the complete supplied history using the paper's zero-prefix rule.

For t0=max(p,q), condition on X[:t0], set E[:t0]=0 and evaluate only X[t0:]. This is a conditional filter, not exact stationary/Kalman filtering. A new call restarts the conditioning convention; do not filter chunks separately to continue the same innovation state. Future observations never affect an earlier innovation for fixed parameters.

References

Tsay (2024), "Matrix-Variate Time Series Analysis: A Brief Review and Some New Developments", equations (4), (30), Section 2.5, https://doi.org/10.1111/insr.12558. The Gaussian quadratic term uses the necessary factor 1/2 omitted in printed equation (30).

def fit_marma( X, ar_order=1, ma_order=1, *, method='ls', intercept=True, initial=None, n_starts=3, max_iter=300, tol=1e-08, random_state=0, max_dense_dimension=256, enforce_admissibility=True, stability_margin=1e-06):
 757def fit_marma(
 758    X,
 759    ar_order=1,
 760    ma_order=1,
 761    *,
 762    method="ls",
 763    intercept=True,
 764    initial=None,
 765    n_starts=3,
 766    max_iter=300,
 767    tol=1e-8,
 768    random_state=0,
 769    max_dense_dimension=256,
 770    enforce_admissibility=True,
 771    stability_margin=1e-6,
 772):
 773    """Fit recursive conditional LS or separable Gaussian rank-one MARMA.
 774
 775    Orders are nonnegative, including MA-only, AR-only and white-noise limits.
 776    The first max(p,q) observations are conditioned on and their innovations
 777    fixed at zero. Every objective and analytic gradient traverses the full
 778    residual recursion. SLSQP solves fixed-pivot factor charts subject to both
 779    full-polynomial companion radii <=1-stability_margin. Numerical constraint
 780    derivatives are used, not numerical objective derivatives. A finite feasible
 781    iterate is retained even on solver failure, with convergence false.
 782    Setting enforce_admissibility=False selects unconstrained BFGS, an explicit
 783    extension that can return unstable/noninvertible estimates. Root restrictions
 784    do not establish structural ARMA identification.
 785
 786    With a free intercept, subtracting a training location is only a numerical
 787    reparameterization; the physical intercept is restored after fitting.
 788    Initialization projects an unrestricted VAR(p) and adds small nonzero MA
 789    pairs; it is NOT the paper's suggested unrestricted VARMA initializer.
 790    Additional local starts perturb these pairs. MLE first runs at most 100
 791    coefficient-LS iterations (separately recorded), initializes covariance by
 792    separable flip-flop residual fitting, and optimizes Cholesky coordinates.
 793    Explicit initial covariance is used for the first start without this
 794    warm-up; supplied parameters are not silently discarded.
 795    max_iter limits each final solve; the separate warm-up is limited to
 796    min(max_iter,100). An unconverged finite warm-up may seed the final solve.
 797    Degenerate residual covariance is rejected, not floored. Fixed pivots can
 798    limit a local chart; use different initial parameters to assess sensitivity.
 799    Automatic starts are made strictly feasible by multiplying each lag-l right
 800    factor by c**l, which scales every companion root by c. Infeasible user
 801    starts are rejected rather than modified. tol is SLSQP's absolute scaled
 802    objective/optimality tolerance or BFGS's gradient tolerance, not a statistical
 803    test. max_dense_dimension guards initializer/operator/covariance allocations.
 804
 805    References
 806    ----------
 807    Tsay, R. S. (2024), "Matrix-Variate Time Series Analysis: A Brief Review
 808    and Some New Developments", equations (4)--(5), Section 2.5,
 809    https://doi.org/10.1111/insr.12558. Conditional Gaussian NLL corrects the
 810    missing factor 1/2 in equation (30). LS is the isotropic conditional
 811    Gaussian coefficient objective. Solver, multistart and gauge choices are
 812    package implementations; rank-r/seasonal models and inference are absent.
 813    """
 814    p, q = positive_int(ar_order, "ar_order", minimum=0), positive_int(
 815        ma_order, "ma_order", minimum=0
 816    )
 817    X = as_series(X, min_samples=max(p, q) + 2)
 818    m, n = X.shape[1:]
 819    maximum = positive_int(max_dense_dimension, "max_dense_dimension")
 820    if m * n * max(p, q, 1) > maximum:
 821        raise ValueError(
 822            "model exceeds max_dense_dimension; explicitly increase the allocation guard"
 823        )
 824    n_starts, max_iter = positive_int(n_starts, "n_starts"), positive_int(
 825        max_iter, "max_iter"
 826    )
 827    tol = finite_scalar(tol, "tol", minimum=0)
 828    if (
 829        not tol
 830        or method not in ("ls", "mle")
 831        or not isinstance(intercept, (bool, np.bool_))
 832    ):
 833        raise ValueError("require positive tol, method ls/mle and boolean intercept")
 834    if not isinstance(enforce_admissibility, (bool, np.bool_)):
 835        raise ValueError("enforce_admissibility must be boolean")
 836    stability_margin = finite_scalar(stability_margin, "stability_margin", minimum=0)
 837    if not 0 < stability_margin < 1 or 1 - stability_margin == 1:
 838        raise ValueError(
 839            "stability_margin must give a representably interior unit bound"
 840        )
 841    bound = float(np.max(abs(X))) or 1.0
 842    location = (X / bound).mean(axis=0) * bound if intercept else np.zeros((m, n))
 843    centered = X - location
 844    scale = float(np.max(abs(centered)))
 845    if not np.isfinite(scale):
 846        raise FloatingPointError("centered observations exceed floating-point range")
 847    if not scale:
 848        raise ValueError("constant or zero data do not identify MARMA innovations")
 849    work = centered / scale
 850    rng = (
 851        deepcopy(random_state)
 852        if isinstance(random_state, np.random.Generator)
 853        else np.random.default_rng(random_state)
 854    )
 855    if initial is not None:
 856        initial = _canonical(initial)
 857        if (initial.ar_order, initial.ma_order, initial.intercept.shape) != (
 858            p,
 859            q,
 860            (m, n),
 861        ):
 862            raise ValueError(
 863                "initial parameters do not match requested orders/dimensions"
 864            )
 865        if enforce_admissibility and np.any(
 866            _radii(_raw(initial)) > 1 - stability_margin
 867        ):
 868            raise ValueError(
 869                "initial parameters violate stationarity/invertibility constraints"
 870            )
 871        constant = initial.intercept / bound - location / bound
 872        for a, b in zip(initial.ar_left, initial.ar_right):
 873            constant += a @ (location / bound) @ b.T
 874        constant = _represented(
 875            _multiply(constant, bound / scale), "centered intercept"
 876        )
 877        if not intercept and np.any(initial.intercept):
 878            raise ValueError("initial intercept must be zero when intercept=False")
 879        covariance = []
 880        if method == "mle" and initial.row_covariance is not None:
 881            for value in (initial.row_covariance, initial.column_covariance):
 882                root = _chol(value, "initial covariance") / np.sqrt(scale)
 883                covariance.append(
 884                    _represented(root @ root.T, "scaled initial covariance")
 885                )
 886        base = MARMAParameters(*_raw(initial)[:4], constant, *covariance)
 887    else:
 888        base = _initial(work, p, q, intercept, rng)
 889        if enforce_admissibility:
 890            base = _admissible_start(base, stability_margin)
 891    runs, candidates = [], []
 892    for start in range(n_starts):
 893        try:
 894            initialization_iterations = 0
 895            warm = None
 896            if start:
 897                arrays = []
 898                for values in _raw(base)[:4]:
 899                    arrays.append(values + 0.15 * rng.normal(size=values.shape))
 900                current = _canonical(MARMAParameters(*arrays, base.intercept))
 901                if enforce_admissibility:
 902                    current = _admissible_start(current, stability_margin)
 903            else:
 904                current = base
 905            if method == "mle":
 906                if current.row_covariance is None:
 907                    warm = fit_marma(
 908                        work,
 909                        p,
 910                        q,
 911                        method="ls",
 912                        intercept=intercept,
 913                        initial=current,
 914                        n_starts=1,
 915                        max_iter=min(max_iter, 100),
 916                        tol=max(tol, 1e-5),
 917                        max_dense_dimension=maximum,
 918                        enforce_admissibility=enforce_admissibility,
 919                        stability_margin=stability_margin,
 920                    )
 921                    initialization_iterations = warm.n_iter
 922                    current = warm.parameters
 923                    errors = _innovations(work, _raw(current))[max(p, q) :]
 924                    u, v = _covariance_fit(errors)
 925                    current = MARMAParameters(*_raw(current), u, v)
 926            chart = _ParameterChart(
 927                current, fit_intercept=intercept, covariance=method == "mle"
 928            )
 929            vector = chart.pack(current)
 930            history = []
 931            best = [float("inf"), vector.copy()]
 932
 933            def constraints(value):
 934                try:
 935                    with np.errstate(over="ignore", invalid="ignore", divide="ignore"):
 936                        result = 1 - stability_margin - _radii(chart.decode(value)[:5])
 937                    return result if np.isfinite(result).all() else np.full(2, -1e100)
 938                except (ValueError, np.linalg.LinAlgError):
 939                    return np.full(2, -1e100)
 940
 941            def evaluate(value):
 942                try:
 943                    with np.errstate(
 944                        over="ignore", invalid="ignore", divide="ignore", under="ignore"
 945                    ):
 946                        loss, gradient = _objective_gradient(value, work, chart, method)
 947                    if loss < best[0] and (
 948                        not enforce_admissibility or np.all(constraints(value) >= 0)
 949                    ):
 950                        best[:] = loss, value.copy()
 951                    return loss, gradient
 952                except (FloatingPointError, np.linalg.LinAlgError, ValueError):
 953                    return float("inf"), np.zeros_like(value)
 954
 955            first, _ = evaluate(vector)
 956            if not np.isfinite(first):
 957                raise ValueError("nonfinite initial objective")
 958            history.append(first)
 959
 960            def callback(value):
 961                history.append(evaluate(value)[0])
 962
 963            if vector.size:
 964                optimizer = "SLSQP" if enforce_admissibility else "BFGS"
 965                solved = minimize(
 966                    evaluate,
 967                    vector,
 968                    method=optimizer,
 969                    jac=True,
 970                    callback=callback,
 971                    constraints=(
 972                        {"type": "ineq", "fun": constraints}
 973                        if enforce_admissibility
 974                        else ()
 975                    ),
 976                    options={
 977                        "maxiter": max_iter,
 978                        "ftol" if enforce_admissibility else "gtol": tol,
 979                    },
 980                )
 981                terminal_value, _ = evaluate(solved.x)
 982                chosen = solved.x
 983                converged, iterations, message = (
 984                    bool(solved.success),
 985                    int(solved.nit),
 986                    str(solved.message),
 987                )
 988                if enforce_admissibility and (
 989                    np.any(constraints(chosen) < 0) or not np.isfinite(terminal_value)
 990                ):
 991                    chosen = best[1]
 992                    converged = False
 993                    message += "; returned best strictly feasible iterate"
 994                value, gradient = evaluate(chosen)
 995                fitted = chart.parameters(chosen)
 996            else:
 997                value, gradient = evaluate(vector)
 998                fitted = current
 999                converged, iterations, message = (
1000                    True,
1001                    0,
1002                    "No free coefficient parameters",
1003                )
1004                chosen, optimizer = vector, "none"
1005            if not np.isfinite(value):
1006                raise ValueError("nonfinite terminal objective")
1007            if history[-1] != value:
1008                history.append(value)
1009            runs.append(
1010                MARMAOptimizationRun(
1011                    converged,
1012                    iterations,
1013                    np.array(history),
1014                    float(np.max(abs(gradient), initial=0)),
1015                    message,
1016                    initialization_iterations=initialization_iterations,
1017                    initialization_converged=None if warm is None else warm.converged,
1018                    initialization_objective_history=(
1019                        None if warm is None else warm.objective_history.copy()
1020                    ),
1021                    initialization_message=(
1022                        None if warm is None else warm.runs[warm.selected_start].message
1023                    ),
1024                    constraint_residuals=constraints(chosen),
1025                    feasible=bool(np.all(constraints(chosen) >= 0)),
1026                    optimizer=optimizer,
1027                )
1028            )
1029            candidates.append((value, len(runs) - 1, fitted))
1030        except (ValueError, FloatingPointError, np.linalg.LinAlgError) as exc:
1031            runs.append(
1032                MARMAOptimizationRun(
1033                    False,
1034                    0,
1035                    np.array([]),
1036                    float("inf"),
1037                    str(exc),
1038                    True,
1039                    initialization_iterations=initialization_iterations,
1040                    initialization_converged=None if warm is None else warm.converged,
1041                    initialization_objective_history=(
1042                        None if warm is None else warm.objective_history.copy()
1043                    ),
1044                    initialization_message=(
1045                        None if warm is None else warm.runs[warm.selected_start].message
1046                    ),
1047                    optimizer="SLSQP" if enforce_admissibility else "BFGS",
1048                )
1049            )
1050    if not candidates:
1051        raise ValueError(
1052            "all MARMA starts failed: " + "; ".join(run.message for run in runs)
1053        )
1054    _, selected, fitted = min(candidates, key=lambda item: item[0])
1055    constant = location / bound
1056    for a, b in zip(fitted.ar_left, fitted.ar_right):
1057        constant = constant - a @ (location / bound) @ b.T
1058    constant = _represented(
1059        _multiply(constant, bound) + _multiply(fitted.intercept, scale),
1060        "physical intercept",
1061    )
1062    u = v = None
1063    if method == "mle":
1064        # Balanced factors retain representable covariances at very large units.
1065        u = _represented(
1066            _multiply(fitted.row_covariance, scale), "physical row covariance"
1067        )
1068        v = _represented(
1069            _multiply(fitted.column_covariance, scale), "physical column covariance"
1070        )
1071    physical = MARMAParameters(*_raw(fitted)[:4], constant, u, v)
1072    filtered = filter_marma(X, physical)
1073    covariance = None
1074    if method == "ls":
1075        errors = _innovations(work, _raw(fitted))[max(p, q) :]
1076        flat = errors.transpose(0, 2, 1).reshape(len(errors), m * n)
1077        covariance = _multiply(flat.T @ flat / len(flat), scale, scale)
1078        if not np.isfinite(covariance).all() or np.any(
1079            (np.diag(flat.T @ flat) > 0) & (np.diag(covariance) == 0)
1080        ):
1081            covariance = None  # Coefficients/forecasts can remain representable.
1082    return MARMAResult(
1083        physical,
1084        filtered,
1085        method,
1086        tuple(runs),
1087        selected,
1088        scale,
1089        location,
1090        maximum,
1091        bool(enforce_admissibility),
1092        stability_margin,
1093        covariance,
1094    )

Fit recursive conditional LS or separable Gaussian rank-one MARMA.

Orders are nonnegative, including MA-only, AR-only and white-noise limits. The first max(p,q) observations are conditioned on and their innovations fixed at zero. Every objective and analytic gradient traverses the full residual recursion. SLSQP solves fixed-pivot factor charts subject to both full-polynomial companion radii <=1-stability_margin. Numerical constraint derivatives are used, not numerical objective derivatives. A finite feasible iterate is retained even on solver failure, with convergence false. Setting enforce_admissibility=False selects unconstrained BFGS, an explicit extension that can return unstable/noninvertible estimates. Root restrictions do not establish structural ARMA identification.

With a free intercept, subtracting a training location is only a numerical reparameterization; the physical intercept is restored after fitting. Initialization projects an unrestricted VAR(p) and adds small nonzero MA pairs; it is NOT the paper's suggested unrestricted VARMA initializer. Additional local starts perturb these pairs. MLE first runs at most 100 coefficient-LS iterations (separately recorded), initializes covariance by separable flip-flop residual fitting, and optimizes Cholesky coordinates. Explicit initial covariance is used for the first start without this warm-up; supplied parameters are not silently discarded. max_iter limits each final solve; the separate warm-up is limited to min(max_iter,100). An unconverged finite warm-up may seed the final solve. Degenerate residual covariance is rejected, not floored. Fixed pivots can limit a local chart; use different initial parameters to assess sensitivity. Automatic starts are made strictly feasible by multiplying each lag-l right factor by c**l, which scales every companion root by c. Infeasible user starts are rejected rather than modified. tol is SLSQP's absolute scaled objective/optimality tolerance or BFGS's gradient tolerance, not a statistical test. max_dense_dimension guards initializer/operator/covariance allocations.

References

Tsay, R. S. (2024), "Matrix-Variate Time Series Analysis: A Brief Review and Some New Developments", equations (4)--(5), Section 2.5, https://doi.org/10.1111/insr.12558. Conditional Gaussian NLL corrects the missing factor 1/2 in equation (30). LS is the isotropic conditional Gaussian coefficient objective. Solver, multistart and gauge choices are package implementations; rank-r/seasonal models and inference are absent.

def marma_diagnostics(parameters, *, tolerance=1e-08, max_dense_dimension=256):
294def marma_diagnostics(parameters, *, tolerance=1e-8, max_dense_dimension=256):
295    """Check both complete AR and minus-MA companion polynomials.
296
297    References
298    ----------
299    Tsay (2024), "Matrix-Variate Time Series Analysis: A Brief Review and Some
300    New Developments", Sections 2.1 and 2.4,
301    https://doi.org/10.1111/insr.12558. Companion eigenvalues are reciprocals
302    of polynomial roots. This tolerance-based numerical diagnostic does not
303    certify minimality, left coprimeness or identify cancelling ARMA factors.
304    """
305    parameters = _canonical(parameters)
306    maximum = positive_int(max_dense_dimension, "max_dense_dimension")
307    tolerance = finite_scalar(tolerance, "tolerance", minimum=0)
308    if tolerance >= 1:
309        raise ValueError("tolerance must be below one")
310    ar, ma = (_roots(x) for x in _operators(parameters, maximum))
311    ar_radius = float(max(abs(ar), default=0))
312    ma_radius = float(max(abs(ma), default=0))
313    return MARMADiagnostics(
314        ar,
315        ma,
316        ar_radius,
317        ma_radius,
318        ar_radius < 1 - tolerance,
319        ma_radius < 1 - tolerance,
320        tolerance,
321    )

Check both complete AR and minus-MA companion polynomials.

References

Tsay (2024), "Matrix-Variate Time Series Analysis: A Brief Review and Some New Developments", Sections 2.1 and 2.4, https://doi.org/10.1111/insr.12558. Companion eigenvalues are reciprocals of polynomial roots. This tolerance-based numerical diagnostic does not certify minimality, left coprimeness or identify cancelling ARMA factors.

def simulate_marma( n_samples, parameters, *, burnin=300, random_state=None, max_dense_dimension=256):
1106def simulate_marma(
1107    n_samples, parameters, *, burnin=300, random_state=None, max_dense_dimension=256
1108):
1109    """Simulate a stable MARMA model with independent matrix-normal innovations.
1110
1111    Omitted covariance factors mean identity covariance. Presample observations
1112    and innovations are zero; burn-in is a caller-selected transient reduction,
1113    not exact stationary initialization. Noninvertible MA parameters are allowed
1114    for simulation, but unstable AR parameters are rejected.
1115
1116    References
1117    ----------
1118    Tsay (2024), "Matrix-Variate Time Series Analysis: A Brief Review and Some
1119    New Developments", equation (4), https://doi.org/10.1111/insr.12558.
1120    Gaussian draws and explicit burn-in generate this model family; they do
1121    not reproduce a particular paper experiment.
1122    """
1123    parameters = _canonical(parameters)
1124    n_samples, burnin = positive_int(n_samples, "n_samples"), positive_int(
1125        burnin, "burnin", minimum=0
1126    )
1127    if not marma_diagnostics(
1128        parameters, max_dense_dimension=max_dense_dimension
1129    ).is_stable:
1130        raise ValueError("simulation requires a stable AR polynomial")
1131    m, n = parameters.intercept.shape
1132    u = np.eye(m) if parameters.row_covariance is None else parameters.row_covariance
1133    v = (
1134        np.eye(n)
1135        if parameters.column_covariance is None
1136        else parameters.column_covariance
1137    )
1138    rng = (
1139        deepcopy(random_state)
1140        if isinstance(random_state, np.random.Generator)
1141        else np.random.default_rng(random_state)
1142    )
1143    count = n_samples + burnin
1144    t0 = parameters.conditioning_length
1145    X, errors = np.zeros((count + t0, m, n)), np.zeros((count + t0, m, n))
1146    row, col = _chol(u, "row covariance"), _chol(v, "column covariance")
1147    errors[t0:] = row @ rng.normal(size=(count, m, n)) @ col.T
1148    means = np.empty((count, m, n))
1149    for t in range(t0, count + t0):
1150        value = parameters.intercept.copy()
1151        for lag, (a, b) in enumerate(zip(parameters.ar_left, parameters.ar_right), 1):
1152            value += a @ X[t - lag] @ b.T
1153        for lag, (a, b) in enumerate(zip(parameters.ma_left, parameters.ma_right), 1):
1154            value -= a @ errors[t - lag] @ b.T
1155        means[t - t0] = value
1156        X[t] = value + errors[t]
1157    _represented(X, "simulation")
1158    return MARMASimulation(X[t0 + burnin :], errors[t0 + burnin :], means[burnin:])

Simulate a stable MARMA model with independent matrix-normal innovations.

Omitted covariance factors mean identity covariance. Presample observations and innovations are zero; burn-in is a caller-selected transient reduction, not exact stationary initialization. Noninvertible MA parameters are allowed for simulation, but unstable AR parameters are rejected.

References

Tsay (2024), "Matrix-Variate Time Series Analysis: A Brief Review and Some New Developments", equation (4), https://doi.org/10.1111/insr.12558. Gaussian draws and explicit burn-in generate this model family; they do not reproduce a particular paper experiment.

@dataclass(frozen=True)
class CMARI1Diagnostics:
281@dataclass(frozen=True)
282class CMARI1Diagnostics:
283    """Tolerance-dependent checks of fitted coefficients, not a statistical test.
284
285    Companion eigenvalues are inverse polynomial roots. ``compatible`` requires
286    exactly dimension-rank(Pi) roots near +1, all other roots strictly inside
287    1-tolerance, and a nonsingular full-complement I(1) condition. A near-boundary
288    or defective system may require higher precision or substantive analysis.
289    """
290
291    compatible: bool
292    long_run_rank: int
293    unit_roots_expected: int
294    unit_roots_observed: int
295    eigenvalues: np.ndarray
296    stable_roots_radius: float
297    unstable_roots_count: int
298    boundary_roots_count: int
299    complement_singular_values: np.ndarray
300    complement_singular_ratio: float
301    long_run_impact: np.ndarray | None
302    tolerance: float
303    rcond: float

Tolerance-dependent checks of fitted coefficients, not a statistical test.

Companion eigenvalues are inverse polynomial roots. compatible requires exactly dimension-rank(Pi) roots near +1, all other roots strictly inside 1-tolerance, and a nonsingular full-complement I(1) condition. A near-boundary or defective system may require higher precision or substantive analysis.

CMARI1Diagnostics( compatible: bool, long_run_rank: int, unit_roots_expected: int, unit_roots_observed: int, eigenvalues: numpy.ndarray, stable_roots_radius: float, unstable_roots_count: int, boundary_roots_count: int, complement_singular_values: numpy.ndarray, complement_singular_ratio: float, long_run_impact: numpy.ndarray | None, tolerance: float, rcond: float)
compatible: bool
long_run_rank: int
unit_roots_expected: int
unit_roots_observed: int
eigenvalues: numpy.ndarray
stable_roots_radius: float
unstable_roots_count: int
boundary_roots_count: int
complement_singular_values: numpy.ndarray
complement_singular_ratio: float
long_run_impact: numpy.ndarray | None
tolerance: float
rcond: float
@dataclass
class CMARResult:
433@dataclass
434class CMARResult:
435    """Structured error-correction estimates with fixed positive mode ranks.
436
437    A_j=alpha_j beta_j.T; beta_j has orthonormal columns spanning the RIGHT
438    singular space. Identification fixes ||A1||_F and each ||B_i1||_F to one
439    and a deterministic paired sign. Cointegration spaces, not individual
440    rotated vectors, are invariant estimands.
441
442    Histories/objective are scaled mean Frobenius loss for LS, or mean Gaussian
443    negative log likelihood in X/data_scale units for MLE. ``log_likelihood``
444    restores physical units and includes Gaussian constants. No ridge,
445    covariance floor, stationarity projection, rank test or standard errors
446    are silently applied. A converged local optimizer is not an I(1) certificate.
447    Spaces, diagnostics and forecasts inherit the model reference documented
448    in :func:`fit_cmar` and this module.
449    """
450
451    A1: np.ndarray
452    A2: np.ndarray
453    short_run_left: np.ndarray
454    short_run_right: np.ndarray
455    intercept: np.ndarray
456    alpha1: np.ndarray
457    alpha2: np.ndarray
458    beta1: np.ndarray
459    beta2: np.ndarray
460    fitted_differences: np.ndarray
461    residuals: np.ndarray
462    history: np.ndarray
463    method: str
464    data_scale: float
465    row_covariance: np.ndarray | None
466    column_covariance: np.ndarray | None
467    runs: tuple
468    selected_start: int
469    rcond: float
470    max_dense_dimension: int
471
472    @property
473    def ranks(self):
474        return self.beta1.shape[1], self.beta2.shape[1]
475
476    @property
477    def difference_lags(self):
478        return len(self.short_run_left)
479
480    @property
481    def converged(self):
482        return self.runs[self.selected_start].converged
483
484    @property
485    def n_iter(self):
486        return self.runs[self.selected_start].n_iter
487
488    @property
489    def objective(self):
490        return self.runs[self.selected_start].objective
491
492    @property
493    def objective_history(self):
494        return self.runs[self.selected_start].objective_history
495
496    @property
497    def cointegration_rank(self):
498        return self.ranks[0] * self.ranks[1]
499
500    @property
501    def cointegrating_vectors(self):
502        self._guard_dense()
503        return np.kron(self.beta2, self.beta1)
504
505    @property
506    def cointegration_projectors(self):
507        return self.beta1 @ self.beta1.T, self.beta2 @ self.beta2.T
508
509    @property
510    def long_run_operator(self):
511        self._guard_dense()
512        return np.kron(self.A2, self.A1)
513
514    @property
515    def short_run_operators(self):
516        self._guard_dense(max(1, self.difference_lags))
517        d = self.A1.shape[0] * self.A2.shape[0]
518        return np.array(
519            [
520                np.kron(b2, b1)
521                for b1, b2 in zip(self.short_run_left, self.short_run_right)
522            ]
523        ).reshape(-1, d, d)
524
525    @property
526    def level_coefficients(self):
527        self._guard_dense(self.difference_lags + 1)
528        return _level_operators(self.long_run_operator, self.short_run_operators)
529
530    def _guard_dense(self, order=1):
531        if self.A1.shape[0] * self.A2.shape[0] * order > self.max_dense_dimension:
532            raise ValueError(
533                "dense representation exceeds max_dense_dimension; increase the explicit result limit if appropriate"
534            )
535
536    @property
537    def log_likelihood(self):
538        if self.method != "mle":
539            return None
540        d = self.A1.shape[0] * self.A2.shape[0]
541        return -len(self.residuals) * (self.objective + d * np.log(self.data_scale))
542
543    def cointegrating_scores(self, X):
544        """Return beta1.T X_t beta2, without centering or statistical testing."""
545        X = as_series(X, min_samples=1)
546        if X.shape[1:] != self.intercept.shape:
547            raise ValueError("observations must match fitted matrix dimensions")
548        scale = float(np.max(np.abs(X)))
549        if scale == 0:
550            return np.zeros((len(X), *self.ranks))
551        with np.errstate(over="ignore", invalid="ignore"):
552            scores = (self.beta1.T @ (X / scale) @ self.beta2) * scale
553        if not np.isfinite(scores).all():
554            raise FloatingPointError("cointegrating scores exceed physical data units")
555        return scores
556
557    def i1_diagnostics(self, *, tolerance=1e-6, max_dense_dimension=None):
558        return cmar_i1_diagnostics(
559            self.A1,
560            self.A2,
561            self.short_run_left,
562            self.short_run_right,
563            tolerance=tolerance,
564            rcond=self.rcond,
565            max_dense_dimension=(
566                self.max_dense_dimension
567                if max_dense_dimension is None
568                else max_dense_dimension
569            ),
570        )
571
572    def forecast(self, steps, history=None):
573        """Recursive LEVEL forecasts; only supplied past observations are used.
574
575        Need difference_lags+1 chronological levels. Parameters are never
576        refitted, and future innovation means are zero. An unrestricted
577        intercept is carried in the difference equation, not added just once.
578        """
579        steps = positive_int(steps, "steps")
580        past = (
581            self.history
582            if history is None
583            else as_series(history, min_samples=self.difference_lags + 1)
584        )
585        if past.shape[1:] != self.intercept.shape:
586            raise ValueError("history must match fitted matrix dimensions")
587        states = list(past[-self.difference_lags - 1 :].copy())
588        output = []
589        for _ in range(steps):
590            scale = (
591                max(
592                    float(np.max(np.abs(states[-self.difference_lags - 1 :]))),
593                    float(np.max(np.abs(self.intercept))),
594                )
595                or 1.0
596            )
597            work = np.array(states[-self.difference_lags - 1 :]) / scale
598            difference = self.A1 @ work[-1] @ self.A2.T + self.intercept / scale
599            for j, (left, right) in enumerate(
600                zip(self.short_run_left, self.short_run_right), start=1
601            ):
602                difference += left @ (work[-j] - work[-j - 1]) @ right.T
603            with np.errstate(over="ignore", invalid="ignore"):
604                prediction = (work[-1] + difference) * scale
605            if not np.isfinite(prediction).all():
606                raise FloatingPointError("CMAR forecast exceeds floating-point range")
607            states.append(prediction)
608            output.append(prediction)
609        return np.array(output)

Structured error-correction estimates with fixed positive mode ranks.

A_j=alpha_j beta_j.T; beta_j has orthonormal columns spanning the RIGHT singular space. Identification fixes ||A1||_F and each ||B_i1||_F to one and a deterministic paired sign. Cointegration spaces, not individual rotated vectors, are invariant estimands.

Histories/objective are scaled mean Frobenius loss for LS, or mean Gaussian negative log likelihood in X/data_scale units for MLE. log_likelihood restores physical units and includes Gaussian constants. No ridge, covariance floor, stationarity projection, rank test or standard errors are silently applied. A converged local optimizer is not an I(1) certificate. Spaces, diagnostics and forecasts inherit the model reference documented in fit_cmar() and this module.

CMARResult( A1: numpy.ndarray, A2: numpy.ndarray, short_run_left: numpy.ndarray, short_run_right: numpy.ndarray, intercept: numpy.ndarray, alpha1: numpy.ndarray, alpha2: numpy.ndarray, beta1: numpy.ndarray, beta2: numpy.ndarray, fitted_differences: numpy.ndarray, residuals: numpy.ndarray, history: numpy.ndarray, method: str, data_scale: float, row_covariance: numpy.ndarray | None, column_covariance: numpy.ndarray | None, runs: tuple, selected_start: int, rcond: float, max_dense_dimension: int)
A1: numpy.ndarray
A2: numpy.ndarray
short_run_left: numpy.ndarray
short_run_right: numpy.ndarray
intercept: numpy.ndarray
alpha1: numpy.ndarray
alpha2: numpy.ndarray
beta1: numpy.ndarray
beta2: numpy.ndarray
fitted_differences: numpy.ndarray
residuals: numpy.ndarray
history: numpy.ndarray
method: str
data_scale: float
row_covariance: numpy.ndarray | None
column_covariance: numpy.ndarray | None
runs: tuple
selected_start: int
rcond: float
max_dense_dimension: int
ranks
472    @property
473    def ranks(self):
474        return self.beta1.shape[1], self.beta2.shape[1]
difference_lags
476    @property
477    def difference_lags(self):
478        return len(self.short_run_left)
converged
480    @property
481    def converged(self):
482        return self.runs[self.selected_start].converged
n_iter
484    @property
485    def n_iter(self):
486        return self.runs[self.selected_start].n_iter
objective
488    @property
489    def objective(self):
490        return self.runs[self.selected_start].objective
objective_history
492    @property
493    def objective_history(self):
494        return self.runs[self.selected_start].objective_history
cointegration_rank
496    @property
497    def cointegration_rank(self):
498        return self.ranks[0] * self.ranks[1]
cointegrating_vectors
500    @property
501    def cointegrating_vectors(self):
502        self._guard_dense()
503        return np.kron(self.beta2, self.beta1)
cointegration_projectors
505    @property
506    def cointegration_projectors(self):
507        return self.beta1 @ self.beta1.T, self.beta2 @ self.beta2.T
long_run_operator
509    @property
510    def long_run_operator(self):
511        self._guard_dense()
512        return np.kron(self.A2, self.A1)
short_run_operators
514    @property
515    def short_run_operators(self):
516        self._guard_dense(max(1, self.difference_lags))
517        d = self.A1.shape[0] * self.A2.shape[0]
518        return np.array(
519            [
520                np.kron(b2, b1)
521                for b1, b2 in zip(self.short_run_left, self.short_run_right)
522            ]
523        ).reshape(-1, d, d)
level_coefficients
525    @property
526    def level_coefficients(self):
527        self._guard_dense(self.difference_lags + 1)
528        return _level_operators(self.long_run_operator, self.short_run_operators)
log_likelihood
536    @property
537    def log_likelihood(self):
538        if self.method != "mle":
539            return None
540        d = self.A1.shape[0] * self.A2.shape[0]
541        return -len(self.residuals) * (self.objective + d * np.log(self.data_scale))
def cointegrating_scores(self, X):
543    def cointegrating_scores(self, X):
544        """Return beta1.T X_t beta2, without centering or statistical testing."""
545        X = as_series(X, min_samples=1)
546        if X.shape[1:] != self.intercept.shape:
547            raise ValueError("observations must match fitted matrix dimensions")
548        scale = float(np.max(np.abs(X)))
549        if scale == 0:
550            return np.zeros((len(X), *self.ranks))
551        with np.errstate(over="ignore", invalid="ignore"):
552            scores = (self.beta1.T @ (X / scale) @ self.beta2) * scale
553        if not np.isfinite(scores).all():
554            raise FloatingPointError("cointegrating scores exceed physical data units")
555        return scores

Return beta1.T X_t beta2, without centering or statistical testing.

def i1_diagnostics(self, *, tolerance=1e-06, max_dense_dimension=None):
557    def i1_diagnostics(self, *, tolerance=1e-6, max_dense_dimension=None):
558        return cmar_i1_diagnostics(
559            self.A1,
560            self.A2,
561            self.short_run_left,
562            self.short_run_right,
563            tolerance=tolerance,
564            rcond=self.rcond,
565            max_dense_dimension=(
566                self.max_dense_dimension
567                if max_dense_dimension is None
568                else max_dense_dimension
569            ),
570        )
def forecast(self, steps, history=None):
572    def forecast(self, steps, history=None):
573        """Recursive LEVEL forecasts; only supplied past observations are used.
574
575        Need difference_lags+1 chronological levels. Parameters are never
576        refitted, and future innovation means are zero. An unrestricted
577        intercept is carried in the difference equation, not added just once.
578        """
579        steps = positive_int(steps, "steps")
580        past = (
581            self.history
582            if history is None
583            else as_series(history, min_samples=self.difference_lags + 1)
584        )
585        if past.shape[1:] != self.intercept.shape:
586            raise ValueError("history must match fitted matrix dimensions")
587        states = list(past[-self.difference_lags - 1 :].copy())
588        output = []
589        for _ in range(steps):
590            scale = (
591                max(
592                    float(np.max(np.abs(states[-self.difference_lags - 1 :]))),
593                    float(np.max(np.abs(self.intercept))),
594                )
595                or 1.0
596            )
597            work = np.array(states[-self.difference_lags - 1 :]) / scale
598            difference = self.A1 @ work[-1] @ self.A2.T + self.intercept / scale
599            for j, (left, right) in enumerate(
600                zip(self.short_run_left, self.short_run_right), start=1
601            ):
602                difference += left @ (work[-j] - work[-j - 1]) @ right.T
603            with np.errstate(over="ignore", invalid="ignore"):
604                prediction = (work[-1] + difference) * scale
605            if not np.isfinite(prediction).all():
606                raise FloatingPointError("CMAR forecast exceeds floating-point range")
607            states.append(prediction)
608            output.append(prediction)
609        return np.array(output)

Recursive LEVEL forecasts; only supplied past observations are used.

Need difference_lags+1 chronological levels. Parameters are never refitted, and future innovation means are zero. An unrestricted intercept is carried in the difference equation, not added just once.

def cmar_i1_diagnostics( A1, A2, short_run_left=None, short_run_right=None, *, tolerance=1e-06, rcond=1e-12, max_dense_dimension=256):
316def cmar_i1_diagnostics(
317    A1,
318    A2,
319    short_run_left=None,
320    short_run_right=None,
321    *,
322    tolerance=1e-6,
323    rcond=1e-12,
324    max_dense_dimension=256,
325):
326    """Inspect the complete vectorized CMAR companion and I(1) rank condition.
327
328    Implements the coefficient conditions in Li--Xiao Assumption 2/Theorem 1.
329    No mode-wise root shortcut is used. The full complements of
330    alpha2 kron alpha1 and beta2 kron beta1 have dimension p*q-r1*r2;
331    they are NOT products of the separate mode complements. This dense
332    diagnostic costs cubic time in p*q*(difference_lags+1). Its companion
333    dimension cannot exceed max_dense_dimension (default 256) without an
334    explicit caller override.
335
336    References
337    ----------
338    Li, Z. and Xiao, H. (2024), "Cointegrated Matrix Autoregression Models",
339    Assumption 2 and Theorem 1, https://arxiv.org/abs/2409.10860v1.
340    The tolerance-based numerical checks implement coefficient conditions;
341    they are not a paper-derived hypothesis test or an empirical I(1) proof.
342    """
343    raw1, raw2 = np.asarray(A1), np.asarray(A2)
344    if (
345        raw1.ndim != 2
346        or raw2.ndim != 2
347        or raw1.shape[0] != raw1.shape[1]
348        or raw2.shape[0] != raw2.shape[1]
349    ):
350        raise ValueError("A1 and A2 must be square matrices")
351    p, q = len(raw1), len(raw2)
352    if not p or not q:
353        raise ValueError("coefficient modes must be nonempty")
354    A1, A2 = _array(A1, (p, p), "A1"), _array(A2, (q, q), "A2")
355    if (short_run_left is None) != (short_run_right is None):
356        raise ValueError("supply both short-run coefficient arrays")
357    if short_run_left is not None and np.asarray(short_run_left).ndim != 3:
358        raise ValueError("short-run coefficient arrays must have a leading lag axis")
359    k = 0 if short_run_left is None else len(short_run_left)
360    max_dense_dimension = positive_int(max_dense_dimension, "max_dense_dimension")
361    if p * q * (k + 1) > max_dense_dimension:
362        raise ValueError(
363            "dense companion exceeds max_dense_dimension; explicitly increase the limit if appropriate"
364        )
365    B1 = (
366        np.empty((0, p, p))
367        if short_run_left is None
368        else _array(short_run_left, (k, p, p), "short_run_left")
369    )
370    B2 = (
371        np.empty((0, q, q))
372        if short_run_right is None
373        else _array(short_run_right, (k, q, q), "short_run_right")
374    )
375    tolerance = finite_scalar(tolerance, "tolerance", minimum=0)
376    rcond = finite_scalar(rcond, "rcond", minimum=0)
377    if not 0 < tolerance < 1 or not 0 < rcond < 1:
378        raise ValueError("tolerance and rcond must be strictly between zero and one")
379    pi = np.kron(A2, A1)
380    short = np.array([np.kron(b2, b1) for b1, b2 in zip(B1, B2)])
381    coefficients = _level_operators(pi, short)
382    d, order = p * q, k + 1
383    companion = np.zeros((d * order, d * order))
384    companion[:d] = np.concatenate(coefficients, axis=1)
385    if order > 1:
386        companion[d:, :-d] = np.eye(d * (order - 1))
387    if not np.isfinite(companion).all():
388        raise FloatingPointError("dense CMAR operator exceeds floating-point range")
389    eigenvalues = np.linalg.eigvals(companion)
390    u, singular, vt = np.linalg.svd(pi)
391    rank = int(np.sum(singular > rcond * singular[0])) if singular[0] else 0
392    alpha_perp, beta_perp = u[:, rank:], vt[rank:].T
393    gamma = np.eye(d) - (short.sum(axis=0) if k else 0)
394    complement = alpha_perp.T @ gamma @ beta_perp
395    singular_complement = np.linalg.svd(complement, compute_uv=False)
396    ratio = (
397        float(
398            singular_complement[-1]
399            / max(np.linalg.norm(gamma, 2), np.finfo(float).tiny)
400        )
401        if len(singular_complement)
402        else 0.0
403    )
404    full = ratio > rcond
405    impact = beta_perp @ np.linalg.solve(complement, alpha_perp.T) if full else None
406    unit = np.abs(eigenvalues - 1) <= tolerance
407    others = np.abs(eigenvalues[~unit])
408    radius = float(np.max(others)) if len(others) else 0.0
409    return CMARI1Diagnostics(
410        compatible=bool(
411            d - rank > 0
412            and np.sum(unit) == d - rank
413            and radius < 1 - tolerance
414            and full
415        ),
416        long_run_rank=rank,
417        unit_roots_expected=d - rank,
418        unit_roots_observed=int(np.sum(unit)),
419        eigenvalues=eigenvalues,
420        stable_roots_radius=radius,
421        unstable_roots_count=int(np.sum(others > 1 + tolerance)),
422        boundary_roots_count=int(
423            np.sum((others >= 1 - tolerance) & (others <= 1 + tolerance))
424        ),
425        complement_singular_values=singular_complement,
426        complement_singular_ratio=ratio,
427        long_run_impact=impact,
428        tolerance=tolerance,
429        rcond=rcond,
430    )

Inspect the complete vectorized CMAR companion and I(1) rank condition.

Implements the coefficient conditions in Li--Xiao Assumption 2/Theorem 1. No mode-wise root shortcut is used. The full complements of alpha2 kron alpha1 and beta2 kron beta1 have dimension pq-r1r2; they are NOT products of the separate mode complements. This dense diagnostic costs cubic time in pq(difference_lags+1). Its companion dimension cannot exceed max_dense_dimension (default 256) without an explicit caller override.

References

Li, Z. and Xiao, H. (2024), "Cointegrated Matrix Autoregression Models", Assumption 2 and Theorem 1, https://arxiv.org/abs/2409.10860v1. The tolerance-based numerical checks implement coefficient conditions; they are not a paper-derived hypothesis test or an empirical I(1) proof.

def fit_cmar( X, ranks, *, difference_lags=0, intercept=True, method='ls', initial=None, n_starts=3, max_iter=200, tol=1e-08, rcond=1e-12, random_state=0, init='ols', max_dense_dimension=256):
612def fit_cmar(
613    X,
614    ranks,
615    *,
616    difference_lags=0,
617    intercept=True,
618    method="ls",
619    initial=None,
620    n_starts=3,
621    max_iter=200,
622    tol=1e-8,
623    rcond=1e-12,
624    random_state=0,
625    init="ols",
626    max_dense_dimension=256,
627):
628    """Fit Li--Xiao's fixed-rank matrix VECM by alternating exact block updates.
629
630    ``difference_lags=k`` means a level VAR order of k+1; observations are
631    time-first and the first usable response has index k+2 (one-based).
632    ``intercept=True`` estimates an unrestricted matrix D in differences.
633    Restricted cointegration intercepts and deterministic time trends are not
634    implemented; no centering or detrending is implicit.
635
636    Positive ``ranks=(r1,r2)`` are fixed before fitting, rj<=dimension_j and
637    r1*r2<p*q. One full mode, including the vector limit, is permitted; zero
638    ranks and the full-rank stationary boundary are outside this interface.
639    Rank selection/Johansen tests and asymptotic inference are NOT provided.
640
641    ``method='ls'`` minimizes Frobenius residual loss with unrestricted
642    innovation covariance. ``'mle'`` profiles separable Gaussian covariance
643    (Sigma2 kron Sigma1), with trace(Sigma1)=p for identification. Its finite
644    positive-definite conditional MLE need not exist for degenerate samples.
645    Rank-deficient block designs and singular residual covariance are rejected
646    at the relative ``rcond`` tolerance, without generalized-inverse or ridge
647    substitution. See notes for manuscript corrections and assumptions.
648
649    Default init='ols' is dense unrestricted VECM OLS followed by nearest-Kronecker
650    and mode-rank truncation; this is ONLY a start for structured optimization.
651    Its predictor dimension p*q*(k+1) is limited by max_dense_dimension=256.
652    Set init='random' for dimension-sized rank-projector starts without a dense
653    vector fit, or increase the explicit allocation limit. The limit also
654    guards result dense operators and I(1) companion diagnostics.
655    Additional starts perturb the base using an independent local RNG. ``initial``
656    may instead be a mapping with A1/A2 and, for k>0, short_run_left/right;
657    optional intercept is in physical data units. All arrays are copied.
658    Every start and failed attempt is retained. The smallest finite completed
659    objective is selected even if its optimizer did not converge. Tolerance is
660    relative mean loss for LS, absolute mean NLL increment per scalar entry for
661    MLE, so additive likelihood unit constants do not alter stopping. Together
662    with max_iter it governs local convergence, not global optimality.
663
664    Global nonzero rescaling uses X/max(abs(X)); dimensionless coefficients and
665    subspaces are unchanged up to numerical error. MLE covariance arrays must
666    remain representable in physical units; otherwise rescale the input.
667
668    References
669    ----------
670    Li, Z. and Xiao, H. (2024), "Cointegrated Matrix Autoregression Models",
671    Sections 3.1--3.2, https://arxiv.org/abs/2409.10860v1.
672    The implementation follows the structured residual objectives with the
673    algebraic corrections recorded in docs/cointegration-notes.md; numerical
674    initialization, multistart selection and stopping rules are explicit
675    package choices, not additional rank-selection or inference results.
676    """
677    k = positive_int(difference_lags, "difference_lags", minimum=0)
678    X = as_series(X, min_samples=k + 3)
679    p, q = X.shape[1:]
680    ranks = ranks_tuple(ranks, (p, q))
681    if ranks is None or ranks[0] * ranks[1] >= p * q:
682        raise ValueError("supply positive mode ranks with product below p*q")
683    if not isinstance(intercept, (bool, np.bool_)):
684        raise ValueError("intercept must be boolean")
685    if not isinstance(method, str) or method not in ("ls", "mle"):
686        raise ValueError("method must be 'ls' or 'mle'")
687    n_starts = positive_int(n_starts, "n_starts")
688    max_iter = positive_int(max_iter, "max_iter")
689    max_dense_dimension = positive_int(max_dense_dimension, "max_dense_dimension")
690    if not isinstance(init, str) or init not in ("ols", "random"):
691        raise ValueError("init must be 'ols' or 'random'")
692    tol = finite_scalar(tol, "tol", minimum=0)
693    rcond = finite_scalar(rcond, "rcond", minimum=0)
694    if tol == 0 or not 0 < rcond < 1:
695        raise ValueError("tol must be positive and rcond must lie in (0,1)")
696    scale = float(np.max(np.abs(X)))
697    if scale == 0:
698        raise ValueError("zero data have no identifiable cointegration parameters")
699    Y, levels, differences = _design(X / scale, k)
700    rng = (
701        deepcopy(random_state)
702        if isinstance(random_state, np.random.Generator)
703        else np.random.default_rng(random_state)
704    )
705    if initial is None:
706        if init == "ols":
707            if p * q * (k + 1) > max_dense_dimension:
708                raise ValueError(
709                    "dense initialization exceeds max_dense_dimension; use init='random', explicit initial coefficients, or increase the limit"
710                )
711            base = _initial(Y, levels, differences, ranks, intercept)
712        else:
713            q1 = np.linalg.qr(rng.normal(size=(p, ranks[0])))[0]
714            q2 = np.linalg.qr(rng.normal(size=(q, ranks[1])))[0]
715            b1 = np.repeat(np.eye(p)[None], k, axis=0)
716            b2 = np.repeat((0.05 * np.eye(q))[None], k, axis=0)
717            base = (
718                *_normalize(-q1 @ q1.T, 0.2 * q2 @ q2.T, b1, b2),
719                Y.mean(axis=0) if intercept else np.zeros((p, q)),
720            )
721    else:
722        if not isinstance(initial, dict):
723            raise ValueError("initial must be a coefficient mapping")
724        unknown = set(initial) - {
725            "A1",
726            "A2",
727            "short_run_left",
728            "short_run_right",
729            "intercept",
730        }
731        if unknown:
732            raise ValueError(f"unknown initialization fields: {sorted(unknown)}")
733        try:
734            a1 = _array(initial["A1"], (p, p), "initial A1")
735            a2 = _array(initial["A2"], (q, q), "initial A2")
736            b1 = _array(
737                initial.get("short_run_left", np.empty((0, p, p))),
738                (k, p, p),
739                "initial short_run_left",
740            )
741            b2 = _array(
742                initial.get("short_run_right", np.empty((0, q, q))),
743                (k, q, q),
744                "initial short_run_right",
745            )
746            constant = (
747                _array(
748                    initial.get("intercept", np.zeros((p, q))),
749                    (p, q),
750                    "initial intercept",
751                )
752                / scale
753            )
754        except KeyError as exc:
755            raise ValueError("initial must supply A1 and A2") from exc
756        if not intercept and np.any(constant):
757            raise ValueError("initial intercept must be zero when intercept=False")
758        for a, r in zip((a1, a2), ranks):
759            entry_scale = float(np.max(np.abs(a)))
760            s = (
761                np.linalg.svd(a / entry_scale, compute_uv=False)
762                if entry_scale
763                else np.zeros(len(a))
764            )
765            if np.sum(s > rcond * s[0]) != r:
766                raise ValueError("initial coefficient ranks must equal the fixed ranks")
767        base = (*_normalize(a1, a2, b1, b2), constant)
768    runs, solutions = [], []
769    for start in range(n_starts):
770        parameters = tuple(a.copy() for a in base)
771        if start:
772            a1, a2, b1, b2, constant = parameters
773            a1 = _reduced(
774                a1 + 0.3 * np.linalg.norm(a1) * rng.normal(size=a1.shape) / np.sqrt(p),
775                ranks[0],
776            )
777            a2 = _reduced(
778                a2 + 0.3 * np.linalg.norm(a2) * rng.normal(size=a2.shape) / np.sqrt(q),
779                ranks[1],
780            )
781            for b, dim in ((b1, p), (b2, q)):
782                for j in range(k):
783                    b[j] += (
784                        0.3
785                        * np.linalg.norm(b[j])
786                        * rng.normal(size=b[j].shape)
787                        / np.sqrt(dim)
788                    )
789            parameters = (*_normalize(a1, a2, b1, b2), constant)
790        covariances = (
791            None
792            if method == "ls"
793            else (
794                np.eye(p),
795                np.eye(q) * max(float(np.mean(Y**2)), np.finfo(float).tiny),
796            )
797        )
798        history, completed, converged, failure, ratio = [], 0, False, None, 1.0
799        try:
800            history.append(
801                _objective(
802                    _residual(Y, levels, differences, parameters), covariances, rcond
803                )
804            )
805            if not np.isfinite(history[-1]):
806                raise FloatingPointError("initial objective is not finite")
807            for iteration in range(1, max_iter + 1):
808                a1, a2, b1, b2, constant = (a.copy() for a in parameters)
809                row, column = covariances if covariances is not None else (None, None)
810                a1, b1, constant, row, ratio1 = _block_update(
811                    Y, levels, differences, a2, b2, ranks[0], intercept, column, rcond
812                )
813                a2, b2, constant_t, column, ratio2 = _block_update(
814                    Y.transpose(0, 2, 1),
815                    levels.transpose(0, 2, 1),
816                    [x.transpose(0, 2, 1) for x in differences],
817                    a1,
818                    b1,
819                    ranks[1],
820                    intercept,
821                    row,
822                    rcond,
823                )
824                candidate = (*_normalize(a1, a2, b1, b2), constant_t.T)
825                next_covariances = None
826                if method == "mle":
827                    normalization = np.trace(row) / p
828                    next_covariances = row / normalization, column * normalization
829                objective = _objective(
830                    _residual(Y, levels, differences, candidate),
831                    next_covariances,
832                    rcond,
833                )
834                # LS uses relative loss, never an additive 1 that turns tiny
835                # scaled residuals into an absolute early-stop tolerance.
836                # Gaussian NLL increments per entry are invariant to additive
837                # unit/Jacobian constants; relative NLL increments are not.
838                stopping_scale = (
839                    max(abs(history[-1]), np.finfo(float).tiny)
840                    if method == "ls"
841                    else p * q
842                )
843                if (
844                    not np.isfinite(objective)
845                    or objective > history[-1] + 1e-8 * stopping_scale
846                ):
847                    raise FloatingPointError(
848                        "alternating objective increased or became nonfinite"
849                    )
850                parameters, covariances = candidate, next_covariances
851                completed = iteration
852                ratio = min(ratio, ratio1, ratio2)
853                history.append(objective)
854                if abs(history[-2] - objective) <= tol * stopping_scale:
855                    converged = True
856                    break
857        except (ValueError, np.linalg.LinAlgError, FloatingPointError) as exc:
858            failure = str(exc)
859        runs.append(
860            CMAROptimizationRun(
861                np.asarray(history),
862                converged,
863                completed,
864                failure,
865                ratio if completed else None,
866            )
867        )
868        solutions.append((parameters, covariances) if completed else None)
869    eligible = [i for i, solution in enumerate(solutions) if solution is not None]
870    if not eligible:
871        reasons = "; ".join(
872            dict.fromkeys(run.failure or "no completed update" for run in runs)
873        )
874        raise ValueError(
875            f"no CMAR start completed a finite identified update: {reasons}"
876        )
877    selected = min(eligible, key=lambda i: runs[i].objective)
878    parameters, covariances = solutions[selected]
879    a1, a2, b1, b2, constant = parameters
880    beta1 = np.linalg.svd(a1, full_matrices=False)[2][: ranks[0]].T
881    beta2 = np.linalg.svd(a2, full_matrices=False)[2][: ranks[1]].T
882    residual = _residual(Y, levels, differences, parameters)
883    with np.errstate(over="ignore", under="ignore", invalid="ignore"):
884        physical_residual, fitted, constant = (
885            residual * scale,
886            (Y - residual) * scale,
887            constant * scale,
888        )
889        row, column = (
890            (None, None)
891            if covariances is None
892            else (covariances[0], (covariances[1] * scale) * scale)
893        )
894    if not all(np.isfinite(a).all() for a in (physical_residual, fitted, constant)):
895        raise FloatingPointError(
896            "CMAR residuals or intercept exceed physical data units"
897        )
898    if column is not None:
899        if not np.isfinite(column).all():
900            raise FloatingPointError(
901                "CMAR physical covariance overflows; rescale input"
902            )
903        _chol(column, rcond, "physical column covariance")
904    return CMARResult(
905        a1,
906        a2,
907        b1,
908        b2,
909        constant,
910        a1 @ beta1,
911        a2 @ beta2,
912        beta1,
913        beta2,
914        fitted,
915        physical_residual,
916        X[-k - 1 :].copy(),
917        method,
918        scale,
919        row,
920        column,
921        tuple(runs),
922        selected,
923        rcond,
924        max_dense_dimension,
925    )

Fit Li--Xiao's fixed-rank matrix VECM by alternating exact block updates.

difference_lags=k means a level VAR order of k+1; observations are time-first and the first usable response has index k+2 (one-based). intercept=True estimates an unrestricted matrix D in differences. Restricted cointegration intercepts and deterministic time trends are not implemented; no centering or detrending is implicit.

Positive ranks=(r1,r2) are fixed before fitting, rj<=dimension_j and r1r2q. One full mode, including the vector limit, is permitted; zero ranks and the full-rank stationary boundary are outside this interface. Rank selection/Johansen tests and asymptotic inference are NOT provided.

method='ls' minimizes Frobenius residual loss with unrestricted innovation covariance. 'mle' profiles separable Gaussian covariance (Sigma2 kron Sigma1), with trace(Sigma1)=p for identification. Its finite positive-definite conditional MLE need not exist for degenerate samples. Rank-deficient block designs and singular residual covariance are rejected at the relative rcond tolerance, without generalized-inverse or ridge substitution. See notes for manuscript corrections and assumptions.

Default init='ols' is dense unrestricted VECM OLS followed by nearest-Kronecker and mode-rank truncation; this is ONLY a start for structured optimization. Its predictor dimension pq(k+1) is limited by max_dense_dimension=256. Set init='random' for dimension-sized rank-projector starts without a dense vector fit, or increase the explicit allocation limit. The limit also guards result dense operators and I(1) companion diagnostics. Additional starts perturb the base using an independent local RNG. initial may instead be a mapping with A1/A2 and, for k>0, short_run_left/right; optional intercept is in physical data units. All arrays are copied. Every start and failed attempt is retained. The smallest finite completed objective is selected even if its optimizer did not converge. Tolerance is relative mean loss for LS, absolute mean NLL increment per scalar entry for MLE, so additive likelihood unit constants do not alter stopping. Together with max_iter it governs local convergence, not global optimality.

Global nonzero rescaling uses X/max(abs(X)); dimensionless coefficients and subspaces are unchanged up to numerical error. MLE covariance arrays must remain representable in physical units; otherwise rescale the input.

References

Li, Z. and Xiao, H. (2024), "Cointegrated Matrix Autoregression Models", Sections 3.1--3.2, https://arxiv.org/abs/2409.10860v1. The implementation follows the structured residual objectives with the algebraic corrections recorded in docs/cointegration-notes.md; numerical initialization, multistart selection and stopping rules are explicit package choices, not additional rank-selection or inference results.

@dataclass
class IHRFactorResult(mavats.FactorResult):
203@dataclass
204class IHRFactorResult(FactorResult):
205    """Entrywise Huber decomposition with robust (not projected) factor scores.
206
207    Threshold is in original entry units. Objective history and eigenvalues
208    use X/data_scale units; eigenvalues additionally use the paper's loading
209    normalization R'R=p1*I, C'C=p2*I. No inference is supplied. `block_history`
210    records (row, column, factor) diagnostic tuples for each attempted sweep.
211    """
212
213    threshold: float = 0.0
214    objective_history: np.ndarray | None = None
215    block_history: tuple = ()
216    stopping_reason: str = "max_iter"
217    inner_max_iter: int = 100
218    inner_tol: float = 1e-9
219
220    def transform(self, X, *, return_diagnostics=False):
221        """Fit each new core by entrywise Huber regression at the training threshold.
222
223        Uses only that observation and fitted loadings/mean. By default a warning
224        reports unfinished inner solves; return_diagnostics=True instead returns
225        (factors, per-observation diagnostics). No refit or future observations.
226
227        References
228        ----------
229        He, Kong, Liu and Zhao (2023), Robust Statistical Inference for
230        Large-Dimensional Matrix-Valued Time Series via Iterative Huber
231        Regression, Algorithm 1 factor-regression block.
232        https://arxiv.org/abs/2306.03317v1
233        Holding training loadings and threshold fixed is the out-of-sample
234        scoring convention used here, not a forecasting or inference method.
235        """
236        X = as_series(X, min_samples=1)
237        if X.shape[1:] != self.mean.shape:
238            raise ValueError("observation dimensions must match the fitted model")
239        if not isinstance(return_diagnostics, (bool, np.bool_)):
240            raise ValueError("return_diagnostics must be boolean")
241        cores, diagnostics = [], []
242        for x in X:
243            # Per-observation scaling keeps numerical stopping decisions
244            # independent of other (possibly future) transform observations.
245            scale = max(
246                float(np.max(np.abs(x))),
247                float(np.max(np.abs(self.mean))),
248                np.finfo(float).tiny,
249            )
250            with np.errstate(over="ignore"):
251                threshold = self.threshold / scale
252            if threshold == 0:
253                raise ValueError(
254                    "threshold is not representable relative to transform data"
255                )
256            core, info = _factor_regressions(
257                (x / scale - self.mean / scale)[None],
258                *self.loadings,
259                threshold,
260                self.inner_max_iter,
261                self.inner_tol,
262            )
263            with np.errstate(over="ignore", invalid="ignore"):
264                core *= scale
265            if not np.isfinite(core).all():
266                raise FloatingPointError(
267                    "robust factor scores exceed original data units"
268                )
269            cores.append(core[0])
270            diagnostics.extend(info)
271        core, diagnostics = np.asarray(cores), tuple(diagnostics)
272        if return_diagnostics:
273            return core, diagnostics
274        if not all(info.converged for info in diagnostics):
275            warnings.warn(
276                "some robust factor regressions did not converge; request diagnostics",
277                RuntimeWarning,
278                stacklevel=2,
279            )
280        return core
281
282    def inverse_transform(self, factors):
283        """Reconstruct robust cores, rejecting unrepresentable original-unit output.
284
285        References
286        ----------
287        He, Kong, Liu and Zhao (2023), Robust Statistical Inference for
288        Large-Dimensional Matrix-Valued Time Series via Iterative Huber
289        Regression. https://arxiv.org/abs/2306.03317v1
290        The optional training-mean restoration is an implementation convention.
291        """
292        factors = as_series(factors, min_samples=1)
293        if factors.shape[1:] != self.ranks:
294            raise ValueError("factor dimensions must match fitted ranks")
295        scale = max(
296            float(np.max(np.abs(factors))),
297            float(np.max(np.abs(self.mean))),
298            np.finfo(float).tiny,
299        )
300        with np.errstate(over="ignore", invalid="ignore"):
301            signal = (
302                self.loadings[0] @ (factors / scale) @ self.loadings[1].T
303                + self.mean / scale
304            ) * scale
305        if not np.isfinite(signal).all():
306            raise FloatingPointError("reconstruction exceeds original data units")
307        return signal

Entrywise Huber decomposition with robust (not projected) factor scores.

Threshold is in original entry units. Objective history and eigenvalues use X/data_scale units; eigenvalues additionally use the paper's loading normalization R'R=p1I, C'C=p2I. No inference is supplied. block_history records (row, column, factor) diagnostic tuples for each attempted sweep.

IHRFactorResult( factors: numpy.ndarray, signal: numpy.ndarray, loadings: tuple, residuals: numpy.ndarray, mean: numpy.ndarray, eigenvalues: tuple, method: str, data_scale: float = 1.0, n_iter: int = 0, converged: bool = True, threshold: float = 0.0, objective_history: numpy.ndarray | None = None, block_history: tuple = (), stopping_reason: str = 'max_iter', inner_max_iter: int = 100, inner_tol: float = 1e-09)
threshold: float = 0.0
objective_history: numpy.ndarray | None = None
block_history: tuple = ()
stopping_reason: str = 'max_iter'
inner_max_iter: int = 100
inner_tol: float = 1e-09
def transform(self, X, *, return_diagnostics=False):
220    def transform(self, X, *, return_diagnostics=False):
221        """Fit each new core by entrywise Huber regression at the training threshold.
222
223        Uses only that observation and fitted loadings/mean. By default a warning
224        reports unfinished inner solves; return_diagnostics=True instead returns
225        (factors, per-observation diagnostics). No refit or future observations.
226
227        References
228        ----------
229        He, Kong, Liu and Zhao (2023), Robust Statistical Inference for
230        Large-Dimensional Matrix-Valued Time Series via Iterative Huber
231        Regression, Algorithm 1 factor-regression block.
232        https://arxiv.org/abs/2306.03317v1
233        Holding training loadings and threshold fixed is the out-of-sample
234        scoring convention used here, not a forecasting or inference method.
235        """
236        X = as_series(X, min_samples=1)
237        if X.shape[1:] != self.mean.shape:
238            raise ValueError("observation dimensions must match the fitted model")
239        if not isinstance(return_diagnostics, (bool, np.bool_)):
240            raise ValueError("return_diagnostics must be boolean")
241        cores, diagnostics = [], []
242        for x in X:
243            # Per-observation scaling keeps numerical stopping decisions
244            # independent of other (possibly future) transform observations.
245            scale = max(
246                float(np.max(np.abs(x))),
247                float(np.max(np.abs(self.mean))),
248                np.finfo(float).tiny,
249            )
250            with np.errstate(over="ignore"):
251                threshold = self.threshold / scale
252            if threshold == 0:
253                raise ValueError(
254                    "threshold is not representable relative to transform data"
255                )
256            core, info = _factor_regressions(
257                (x / scale - self.mean / scale)[None],
258                *self.loadings,
259                threshold,
260                self.inner_max_iter,
261                self.inner_tol,
262            )
263            with np.errstate(over="ignore", invalid="ignore"):
264                core *= scale
265            if not np.isfinite(core).all():
266                raise FloatingPointError(
267                    "robust factor scores exceed original data units"
268                )
269            cores.append(core[0])
270            diagnostics.extend(info)
271        core, diagnostics = np.asarray(cores), tuple(diagnostics)
272        if return_diagnostics:
273            return core, diagnostics
274        if not all(info.converged for info in diagnostics):
275            warnings.warn(
276                "some robust factor regressions did not converge; request diagnostics",
277                RuntimeWarning,
278                stacklevel=2,
279            )
280        return core

Fit each new core by entrywise Huber regression at the training threshold.

Uses only that observation and fitted loadings/mean. By default a warning reports unfinished inner solves; return_diagnostics=True instead returns (factors, per-observation diagnostics). No refit or future observations.

References

He, Kong, Liu and Zhao (2023), Robust Statistical Inference for Large-Dimensional Matrix-Valued Time Series via Iterative Huber Regression, Algorithm 1 factor-regression block. https://arxiv.org/abs/2306.03317v1 Holding training loadings and threshold fixed is the out-of-sample scoring convention used here, not a forecasting or inference method.

def inverse_transform(self, factors):
282    def inverse_transform(self, factors):
283        """Reconstruct robust cores, rejecting unrepresentable original-unit output.
284
285        References
286        ----------
287        He, Kong, Liu and Zhao (2023), Robust Statistical Inference for
288        Large-Dimensional Matrix-Valued Time Series via Iterative Huber
289        Regression. https://arxiv.org/abs/2306.03317v1
290        The optional training-mean restoration is an implementation convention.
291        """
292        factors = as_series(factors, min_samples=1)
293        if factors.shape[1:] != self.ranks:
294            raise ValueError("factor dimensions must match fitted ranks")
295        scale = max(
296            float(np.max(np.abs(factors))),
297            float(np.max(np.abs(self.mean))),
298            np.finfo(float).tiny,
299        )
300        with np.errstate(over="ignore", invalid="ignore"):
301            signal = (
302                self.loadings[0] @ (factors / scale) @ self.loadings[1].T
303                + self.mean / scale
304            ) * scale
305        if not np.isfinite(signal).all():
306            raise FloatingPointError("reconstruction exceeds original data units")
307        return signal

Reconstruct robust cores, rejecting unrepresentable original-unit output.

References

He, Kong, Liu and Zhao (2023), Robust Statistical Inference for Large-Dimensional Matrix-Valued Time Series via Iterative Huber Regression. https://arxiv.org/abs/2306.03317v1 The optional training-mean restoration is an implementation convention.

@dataclass
class IHRRankResult:
474@dataclass
475class IHRRankResult:
476    """Preprint Section 3.2 rank criteria from an overfitted IHR model.
477
478    `log_ratios` avoids overflow; `thresholds` uses pilot normalized variance
479    units. The pilot need not converge: inspect pilot.converged. Zero selected
480    ranks under the threshold rule are not silently changed to one.
481    """
482
483    ranks: tuple
484    method: str
485    pilot: IHRFactorResult
486    log_ratios: tuple | None
487    thresholds: tuple | None

Preprint Section 3.2 rank criteria from an overfitted IHR model.

log_ratios avoids overflow; thresholds uses pilot normalized variance units. The pilot need not converge: inspect pilot.converged. Zero selected ranks under the threshold rule are not silently changed to one.

IHRRankResult( ranks: tuple, method: str, pilot: IHRFactorResult, log_ratios: tuple | None, thresholds: tuple | None)
ranks: tuple
method: str
log_ratios: tuple | None
thresholds: tuple | None
def fit_ihr_factor( X, ranks, *, threshold=None, center=False, initial=None, max_iter=100, inner_max_iter=100, tol=1e-07, inner_tol=1e-09):
310def fit_ihr_factor(
311    X,
312    ranks,
313    *,
314    threshold=None,
315    center=False,
316    initial=None,
317    max_iter=100,
318    inner_max_iter=100,
319    tol=1e-7,
320    inner_tol=1e-9,
321):
322    """Fit fixed-threshold entrywise IHR, arXiv:2306.03317v1, Eq. (2.2)/Alg. 1.
323
324    Row, column and factor blocks are convex Huber regressions, not matrixwise
325    weighted projections. Each accepted sweep includes signal-preserving SVD
326    normalization. A local convergence flag requires small relative signal/loss
327    changes and successful inner scores; it does not establish a global optimum.
328
329    `threshold=None` freezes 1.345*1.483*median(abs(residual)) from a one-sweep
330    projected-PCA pilot, following Section 4.4's fixed-threshold calibration.
331    A numerically zero pilot scale requires an explicit positive threshold.
332    This is NOT Section 2's repeatedly updated regression-specific MAD rule.
333    Explicit thresholds have original ENTRY units, not matrix Frobenius units.
334    `initial` is a pair of full-rank loading bases; otherwise alpha=0 PCA.
335    `center=True` removes an ordinary, nonrobust training mean.
336
337    The accepted work's new title is Winsorized Mean Matrix Factor Model; its
338    full text was unavailable for reconciliation. No equivalence, inferential
339    standard errors or guarantees for the finite algorithm path are claimed.
340    References
341    ----------
342    He, Kong, Liu and Zhao (2023), Robust Statistical Inference for
343    Large-Dimensional Matrix-Valued Time Series via Iterative Huber Regression,
344    equation (2.2), Algorithm 1 and Section 4.4.
345    https://arxiv.org/html/2306.03317v1
346    """
347    X, work, mean, scale = _prepare(X, center)
348    ranks = ranks_tuple(ranks, X.shape[1:])
349    if ranks is None:
350        raise ValueError("provide two explicit ranks, or use select_ihr_ranks")
351    max_iter = positive_int(max_iter, "max_iter")
352    inner_max_iter = positive_int(inner_max_iter, "inner_max_iter")
353    tol, inner_tol = _positive(tol, "tol"), _positive(inner_tol, "inner_tol")
354    row, column = _initial_spaces(initial, work, ranks)
355    core = row.T @ work @ column
356    if threshold is None:
357        pilot = fit_projected_pca(work, ranks, max_iter=1)
358        tau = 1.345 * 1.483 * float(np.median(np.abs(pilot.residuals)))
359        if tau <= 64 * np.finfo(float).eps * max(float(np.max(np.abs(work))), 1):
360            raise ValueError(
361                "pilot residual scale is numerically zero; supply threshold"
362            )
363        threshold = tau * scale
364        if not np.isfinite(threshold) or threshold == 0:
365            raise ValueError(
366                "automatic threshold is not representable in original units"
367            )
368    else:
369        threshold = _positive(threshold, "threshold")
370        with np.errstate(over="ignore"):
371            tau = threshold / scale
372        # +infinity means exactly quadratic loss at all finite residuals.
373        if tau == 0:
374            raise ValueError("threshold is not representable relative to data scale")
375    row, column, core, spectra = _normalize(row, column, core)
376    signal = row @ core @ column.T
377    history = [_loss(work - signal, tau)]
378    blocks, converged, reason, iteration = [], False, "max_iter", 0
379    for iteration in range(1, max_iter + 1):
380        row_design = (core @ column.T).transpose(0, 2, 1).reshape(-1, ranks[0])
381        updated_rows, row_info = [], []
382        for i in range(X.shape[1]):
383            beta, info = _regression(
384                row_design,
385                work[:, i, :].ravel(),
386                tau,
387                initial=row[i],
388                max_iter=inner_max_iter,
389                tol=inner_tol,
390            )
391            updated_rows.append(beta)
392            row_info.append(info)
393        new_row = np.asarray(updated_rows)
394        column_design = (new_row @ core).reshape(-1, ranks[1])
395        updated_columns, column_info = [], []
396        for j in range(X.shape[2]):
397            beta, info = _regression(
398                column_design,
399                work[:, :, j].ravel(),
400                tau,
401                initial=column[j],
402                max_iter=inner_max_iter,
403                tol=inner_tol,
404            )
405            updated_columns.append(beta)
406            column_info.append(info)
407        new_column = np.asarray(updated_columns)
408        new_core, factor_info = _factor_regressions(
409            work, new_row, new_column, tau, inner_max_iter, inner_tol, core
410        )
411        blocks.append((tuple(row_info), tuple(column_info), factor_info))
412        try:
413            new_row, new_column, new_core, new_spectra = _normalize(
414                new_row, new_column, new_core
415            )
416        except ValueError:
417            reason = "rank_deficient_update"
418            iteration -= 1
419            break
420        candidate = new_row @ new_core @ new_column.T
421        value = _loss(work - candidate, tau)
422        change = np.linalg.norm(candidate - signal) / max(
423            np.linalg.norm(signal), np.finfo(float).tiny
424        )
425        inner_ok = all(info.converged for group in blocks[-1] for info in group)
426        if not np.isfinite(value) or value > history[-1]:
427            roundoff = 64 * np.finfo(float).eps * max(history[-1], np.finfo(float).tiny)
428            converged = bool(
429                np.isfinite(value)
430                and value - history[-1] <= roundoff
431                and change <= np.sqrt(tol)
432                and inner_ok
433            )
434            reason = "numerical_tolerance" if converged else "descent_stalled"
435            iteration -= 1
436            break
437        improvement = (history[-1] - value) / max(history[-1], np.finfo(float).tiny)
438        row, column, core, spectra, signal = (
439            new_row,
440            new_column,
441            new_core,
442            new_spectra,
443            candidate,
444        )
445        history.append(value)
446        if improvement <= tol and change <= np.sqrt(tol) and inner_ok:
447            converged, reason = True, "converged"
448            break
449    with np.errstate(over="ignore", invalid="ignore"):
450        factors, signal = core * scale, signal * scale + mean
451        residuals = X - signal
452    if not all(np.isfinite(a).all() for a in (factors, signal, residuals)):
453        raise FloatingPointError("IHR decomposition exceeds original data units")
454    return IHRFactorResult(
455        factors,
456        signal,
457        (row, column),
458        residuals,
459        mean,
460        spectra,
461        "entrywise-ihr-2023",
462        scale,
463        iteration,
464        converged,
465        threshold,
466        np.asarray(history),
467        tuple(blocks),
468        reason,
469        inner_max_iter,
470        inner_tol,
471    )

Fit fixed-threshold entrywise IHR, arXiv:2306.03317v1, Eq. (2.2)/Alg. 1.

Row, column and factor blocks are convex Huber regressions, not matrixwise weighted projections. Each accepted sweep includes signal-preserving SVD normalization. A local convergence flag requires small relative signal/loss changes and successful inner scores; it does not establish a global optimum.

threshold=None freezes 1.3451.483median(abs(residual)) from a one-sweep projected-PCA pilot, following Section 4.4's fixed-threshold calibration. A numerically zero pilot scale requires an explicit positive threshold. This is NOT Section 2's repeatedly updated regression-specific MAD rule. Explicit thresholds have original ENTRY units, not matrix Frobenius units. initial is a pair of full-rank loading bases; otherwise alpha=0 PCA. center=True removes an ordinary, nonrobust training mean.

The accepted work's new title is Winsorized Mean Matrix Factor Model; its full text was unavailable for reconciliation. No equivalence, inferential standard errors or guarantees for the finite algorithm path are claimed.

References

He, Kong, Liu and Zhao (2023), Robust Statistical Inference for Large-Dimensional Matrix-Valued Time Series via Iterative Huber Regression, equation (2.2), Algorithm 1 and Section 4.4. https://arxiv.org/html/2306.03317v1

def select_ihr_ranks(X, max_ranks, *, method='ratio', ridge=0.0001, **fit_options):
490def select_ihr_ranks(X, max_ranks, *, method="ratio", ridge=1e-4, **fit_options):
491    """Fit oversized ranks and apply the 2023 preprint's two-way rank rules.
492
493    `max_ranks` are fitted dimensions m1,m2, strictly above the true ranks for
494    the theory. Ratio searches 1..m_l-1 and uses sigma_i/(sigma_(i+1)+ridge/D²)
495    where D²=min(T*p1,T*p2,p1*p2). Ridge has ORIGINAL variance units; scale it
496    by a² when scaling X by a to retain the same ratio criterion.
497    Threshold selection implements Section 4.3's P_l=sigma_(l,1)*D^(-2/3).
498    It can select zero and is scale invariant. Neither rule tests a no-factor
499    null with calibrated size. No post-selection inference is provided.
500
501    References
502    ----------
503    He, Kong, Liu and Zhao (2023), Robust Statistical Inference for
504    Large-Dimensional Matrix-Valued Time Series via Iterative Huber Regression,
505    Section 4.3. https://arxiv.org/abs/2306.03317v1
506    The pilot uses the fixed-threshold implementation in ``fit_ihr_factor``;
507    equivalence to the renamed accepted successor remains unverified.
508    """
509    if method not in ("ratio", "threshold"):
510        raise ValueError("method must be 'ratio' or 'threshold'")
511    ridge = _positive(ridge, "ridge")
512    pilot = fit_ihr_factor(X, max_ranks, **fit_options)
513    t, p, q = pilot.signal.shape
514    d2 = min(t * p, t * q, p * q)
515    if method == "threshold":
516        cutoffs = tuple(values[0] * d2 ** (-1 / 3) for values in pilot.eigenvalues)
517        ranks = tuple(
518            int(np.count_nonzero(values > cutoff))
519            for values, cutoff in zip(pilot.eigenvalues, cutoffs)
520        )
521        return IHRRankResult(ranks, method, pilot, None, cutoffs)
522    if any(rank < 2 for rank in pilot.ranks):
523        raise ValueError("ratio selection needs max_ranks >=2 in both modes")
524    log_ridge = np.log(ridge) - np.log(d2) - 2 * np.log(pilot.data_scale)
525    logs = []
526    for values in pilot.eigenvalues:
527        with np.errstate(divide="ignore"):
528            logarithms = np.log(values)
529        logs.append(logarithms[:-1] - np.logaddexp(logarithms[1:], log_ridge))
530    return IHRRankResult(
531        tuple(int(np.argmax(v)) + 1 for v in logs), method, pilot, tuple(logs), None
532    )

Fit oversized ranks and apply the 2023 preprint's two-way rank rules.

max_ranks are fitted dimensions m1,m2, strictly above the true ranks for the theory. Ratio searches 1..m_l-1 and uses sigma_i/(sigma_(i+1)+ridge/D²) where D²=min(Tp1,Tp2,p1*p2). Ridge has ORIGINAL variance units; scale it by a² when scaling X by a to retain the same ratio criterion. Threshold selection implements Section 4.3's P_l=sigma_(l,1)*D^(-2/3). It can select zero and is scale invariant. Neither rule tests a no-factor null with calibrated size. No post-selection inference is provided.

References

He, Kong, Liu and Zhao (2023), Robust Statistical Inference for Large-Dimensional Matrix-Valued Time Series via Iterative Huber Regression, Section 4.3. https://arxiv.org/abs/2306.03317v1 The pilot uses the fixed-threshold implementation in fit_ihr_factor; equivalence to the renamed accepted successor remains unverified.

@dataclass
class TensorARResult:
444@dataclass
445class TensorARResult:
446    """Time-first TenAR fit; coefficients[lag-index][term][mode].
447
448    Histories/objective are SSE or Gaussian NLL after dividing observations
449    by data_scale. Coefficients do not change units. MLE covariance factors
450    have unit Frobenius norm in all but the final mode, which carries physical
451    variance units. Stability is diagnosed, not enforced. Convergence refers
452    to objective change, not global optimality, parameter identification or
453    valid asymptotic inference. center=True is a fixed sample-mean extension.
454    All model-derived accessors inherit the reference and scope in
455    :func:`fit_tensor_ar` and this module.
456    """
457
458    coefficients: tuple
459    lags: tuple
460    terms: tuple
461    method: str
462    fitted_values: np.ndarray
463    residuals: np.ndarray
464    mean: np.ndarray
465    data_scale: float
466    covariance_factors: tuple | None
467    runs: tuple
468    selected_start: int
469    identification_diagnostics: tuple
470    max_dense_dimension: int
471    _history: np.ndarray = field(repr=False)
472
473    @property
474    def order(self):
475        return max(self.lags)
476
477    @property
478    def converged(self):
479        return self.runs[self.selected_start].converged
480
481    @property
482    def n_iter(self):
483        return self.runs[self.selected_start].n_iter
484
485    @property
486    def objective_history(self):
487        return self.runs[self.selected_start].objective_history
488
489    @property
490    def objective(self):
491        return self.runs[self.selected_start].objective
492
493    @property
494    def covariance_regularized(self):
495        return self.runs[self.selected_start].covariance_regularized
496
497    @property
498    def log_likelihood(self):
499        if self.method != "mle":
500            return None
501        return -float(self.objective + self.residuals.size * np.log(self.data_scale))
502
503    def transition_matrices(self, *, max_dimension=None):
504        """Dense vec_F transitions for the specified lags, with an allocation guard."""
505        limit = (
506            self.max_dense_dimension
507            if max_dimension is None
508            else positive_int(max_dimension, "max_dimension")
509        )
510        dimension = prod(self.mean.shape)
511        if dimension > limit:
512            raise ValueError("dense transition exceeds max_dimension")
513        result = []
514        for lag in self.coefficients:
515            transition = np.zeros((dimension, dimension))
516            for term in lag:
517                product = np.ones((1, 1))
518                for matrix in reversed(term):
519                    product = np.kron(product, matrix)
520                transition += product
521            result.append(transition)
522        if not np.isfinite(result).all():
523            raise FloatingPointError("dense tensor transition overflowed")
524        return np.asarray(result)
525
526    def companion_matrix(self, *, max_dimension=None):
527        """Full-sum VAR companion including zero blocks for omitted lags."""
528        limit = (
529            self.max_dense_dimension
530            if max_dimension is None
531            else positive_int(max_dimension, "max_dimension")
532        )
533        dimension = prod(self.mean.shape)
534        if dimension * self.order > limit:
535            raise ValueError(
536                "dense companion exceeds max_dimension; stability is not certified"
537            )
538        companion = np.zeros((dimension * self.order,) * 2)
539        for lag, transition in zip(
540            self.lags, self.transition_matrices(max_dimension=limit)
541        ):
542            companion[:dimension, (lag - 1) * dimension : lag * dimension] = transition
543        companion[dimension:, :-dimension] = np.eye(dimension * (self.order - 1))
544        return companion
545
546    @property
547    def spectral_radius(self):
548        if self.lags == (1,) and self.terms == (1,):
549            radii = [
550                float(np.max(np.abs(np.linalg.eigvals(a))))
551                for a in self.coefficients[0][0]
552            ]
553            if not np.isfinite(radii).all():
554                raise FloatingPointError("mode spectral radius is not finite")
555            if any(radius == 0 for radius in radii):
556                return 0.0
557            with np.errstate(over="ignore", under="ignore"):
558                radius = float(np.exp(sum(np.log(radii))))
559            if not np.isfinite(radius):
560                raise FloatingPointError("tensor transition spectral radius overflowed")
561            return radius
562        return float(np.max(np.abs(np.linalg.eigvals(self.companion_matrix()))))
563
564    @property
565    def is_stable(self):
566        return self.spectral_radius < 1
567
568    def innovation_covariance(self, *, max_dimension=None):
569        if self.covariance_factors is None:
570            raise ValueError(
571                "separable innovation covariance is available only for MLE"
572            )
573        limit = (
574            self.max_dense_dimension
575            if max_dimension is None
576            else positive_int(max_dimension, "max_dimension")
577        )
578        if prod(self.mean.shape) > limit:
579            raise ValueError("dense covariance exceeds max_dimension")
580        covariance = np.ones((1, 1))
581        for factor in reversed(self.covariance_factors):
582            covariance = np.kron(covariance, factor)
583        if not np.isfinite(covariance).all():
584            raise FloatingPointError(
585                "innovation covariance exceeds physical data units"
586            )
587        try:
588            np.linalg.cholesky(covariance)
589        except np.linalg.LinAlgError as exc:
590            raise FloatingPointError(
591                "innovation covariance is not representably positive definite"
592            ) from exc
593        return covariance
594
595    def forecast(self, steps, history=None):
596        """Recursive linear forecast; conditional mean under martingale errors.
597
598        Serially uncorrelated noise alone does not establish a conditional
599        mean interpretation. Parameter uncertainty is not included.
600        """
601        steps = positive_int(steps, "steps")
602        observed = (
603            self._history
604            if history is None
605            else as_series(history, min_samples=self.order, ndim=self.mean.ndim + 1)
606        )
607        if observed.shape[1:] != self.mean.shape:
608            raise ValueError("forecast history dimensions do not match fit")
609        values = [x.copy() for x in observed[-self.order :]]
610        output = []
611        for _ in range(steps):
612            scale = max(
613                float(np.max(np.abs(self.mean))),
614                max(float(np.max(np.abs(x))) for x in values[-self.order :]),
615                np.finfo(float).tiny,
616            )
617            lagged = [
618                (values[-lag] / scale - self.mean / scale)[None] for lag in self.lags
619            ]
620            with np.errstate(over="ignore", invalid="ignore"):
621                prediction = (
622                    _prediction(lagged, self.coefficients)[0] + self.mean / scale
623                ) * scale
624            if not np.isfinite(prediction).all():
625                raise FloatingPointError(
626                    "tensor AR forecast exceeds floating-point range"
627                )
628            output.append(prediction)
629            values.append(prediction)
630        return np.asarray(output)

Time-first TenAR fit; coefficients[lag-index][term][mode].

Histories/objective are SSE or Gaussian NLL after dividing observations by data_scale. Coefficients do not change units. MLE covariance factors have unit Frobenius norm in all but the final mode, which carries physical variance units. Stability is diagnosed, not enforced. Convergence refers to objective change, not global optimality, parameter identification or valid asymptotic inference. center=True is a fixed sample-mean extension. All model-derived accessors inherit the reference and scope in fit_tensor_ar() and this module.

TensorARResult( coefficients: tuple, lags: tuple, terms: tuple, method: str, fitted_values: numpy.ndarray, residuals: numpy.ndarray, mean: numpy.ndarray, data_scale: float, covariance_factors: tuple | None, runs: tuple, selected_start: int, identification_diagnostics: tuple, max_dense_dimension: int, _history: numpy.ndarray)
coefficients: tuple
lags: tuple
terms: tuple
method: str
fitted_values: numpy.ndarray
residuals: numpy.ndarray
mean: numpy.ndarray
data_scale: float
covariance_factors: tuple | None
runs: tuple
selected_start: int
identification_diagnostics: tuple
max_dense_dimension: int
order
473    @property
474    def order(self):
475        return max(self.lags)
converged
477    @property
478    def converged(self):
479        return self.runs[self.selected_start].converged
n_iter
481    @property
482    def n_iter(self):
483        return self.runs[self.selected_start].n_iter
objective_history
485    @property
486    def objective_history(self):
487        return self.runs[self.selected_start].objective_history
objective
489    @property
490    def objective(self):
491        return self.runs[self.selected_start].objective
covariance_regularized
493    @property
494    def covariance_regularized(self):
495        return self.runs[self.selected_start].covariance_regularized
log_likelihood
497    @property
498    def log_likelihood(self):
499        if self.method != "mle":
500            return None
501        return -float(self.objective + self.residuals.size * np.log(self.data_scale))
def transition_matrices(self, *, max_dimension=None):
503    def transition_matrices(self, *, max_dimension=None):
504        """Dense vec_F transitions for the specified lags, with an allocation guard."""
505        limit = (
506            self.max_dense_dimension
507            if max_dimension is None
508            else positive_int(max_dimension, "max_dimension")
509        )
510        dimension = prod(self.mean.shape)
511        if dimension > limit:
512            raise ValueError("dense transition exceeds max_dimension")
513        result = []
514        for lag in self.coefficients:
515            transition = np.zeros((dimension, dimension))
516            for term in lag:
517                product = np.ones((1, 1))
518                for matrix in reversed(term):
519                    product = np.kron(product, matrix)
520                transition += product
521            result.append(transition)
522        if not np.isfinite(result).all():
523            raise FloatingPointError("dense tensor transition overflowed")
524        return np.asarray(result)

Dense vec_F transitions for the specified lags, with an allocation guard.

def companion_matrix(self, *, max_dimension=None):
526    def companion_matrix(self, *, max_dimension=None):
527        """Full-sum VAR companion including zero blocks for omitted lags."""
528        limit = (
529            self.max_dense_dimension
530            if max_dimension is None
531            else positive_int(max_dimension, "max_dimension")
532        )
533        dimension = prod(self.mean.shape)
534        if dimension * self.order > limit:
535            raise ValueError(
536                "dense companion exceeds max_dimension; stability is not certified"
537            )
538        companion = np.zeros((dimension * self.order,) * 2)
539        for lag, transition in zip(
540            self.lags, self.transition_matrices(max_dimension=limit)
541        ):
542            companion[:dimension, (lag - 1) * dimension : lag * dimension] = transition
543        companion[dimension:, :-dimension] = np.eye(dimension * (self.order - 1))
544        return companion

Full-sum VAR companion including zero blocks for omitted lags.

spectral_radius
546    @property
547    def spectral_radius(self):
548        if self.lags == (1,) and self.terms == (1,):
549            radii = [
550                float(np.max(np.abs(np.linalg.eigvals(a))))
551                for a in self.coefficients[0][0]
552            ]
553            if not np.isfinite(radii).all():
554                raise FloatingPointError("mode spectral radius is not finite")
555            if any(radius == 0 for radius in radii):
556                return 0.0
557            with np.errstate(over="ignore", under="ignore"):
558                radius = float(np.exp(sum(np.log(radii))))
559            if not np.isfinite(radius):
560                raise FloatingPointError("tensor transition spectral radius overflowed")
561            return radius
562        return float(np.max(np.abs(np.linalg.eigvals(self.companion_matrix()))))
is_stable
564    @property
565    def is_stable(self):
566        return self.spectral_radius < 1
def innovation_covariance(self, *, max_dimension=None):
568    def innovation_covariance(self, *, max_dimension=None):
569        if self.covariance_factors is None:
570            raise ValueError(
571                "separable innovation covariance is available only for MLE"
572            )
573        limit = (
574            self.max_dense_dimension
575            if max_dimension is None
576            else positive_int(max_dimension, "max_dimension")
577        )
578        if prod(self.mean.shape) > limit:
579            raise ValueError("dense covariance exceeds max_dimension")
580        covariance = np.ones((1, 1))
581        for factor in reversed(self.covariance_factors):
582            covariance = np.kron(covariance, factor)
583        if not np.isfinite(covariance).all():
584            raise FloatingPointError(
585                "innovation covariance exceeds physical data units"
586            )
587        try:
588            np.linalg.cholesky(covariance)
589        except np.linalg.LinAlgError as exc:
590            raise FloatingPointError(
591                "innovation covariance is not representably positive definite"
592            ) from exc
593        return covariance
def forecast(self, steps, history=None):
595    def forecast(self, steps, history=None):
596        """Recursive linear forecast; conditional mean under martingale errors.
597
598        Serially uncorrelated noise alone does not establish a conditional
599        mean interpretation. Parameter uncertainty is not included.
600        """
601        steps = positive_int(steps, "steps")
602        observed = (
603            self._history
604            if history is None
605            else as_series(history, min_samples=self.order, ndim=self.mean.ndim + 1)
606        )
607        if observed.shape[1:] != self.mean.shape:
608            raise ValueError("forecast history dimensions do not match fit")
609        values = [x.copy() for x in observed[-self.order :]]
610        output = []
611        for _ in range(steps):
612            scale = max(
613                float(np.max(np.abs(self.mean))),
614                max(float(np.max(np.abs(x))) for x in values[-self.order :]),
615                np.finfo(float).tiny,
616            )
617            lagged = [
618                (values[-lag] / scale - self.mean / scale)[None] for lag in self.lags
619            ]
620            with np.errstate(over="ignore", invalid="ignore"):
621                prediction = (
622                    _prediction(lagged, self.coefficients)[0] + self.mean / scale
623                ) * scale
624            if not np.isfinite(prediction).all():
625                raise FloatingPointError(
626                    "tensor AR forecast exceeds floating-point range"
627                )
628            output.append(prediction)
629            values.append(prediction)
630        return np.asarray(output)

Recursive linear forecast; conditional mean under martingale errors.

Serially uncorrelated noise alone does not establish a conditional mean interpretation. Parameter uncertainty is not included.

def fit_tensor_ar( X, terms=(1,), *, lags=None, method='ls', init='projection', initial=None, covariance_initial=None, n_starts=1, max_iter=100, tol=1e-07, projection_max_iter=100, projection_tol=1e-08, max_dense_dimension=256, covariance_floor=0.0, center=False, random_state=0):
 633def fit_tensor_ar(
 634    X,
 635    terms=(1,),
 636    *,
 637    lags=None,
 638    method="ls",
 639    init="projection",
 640    initial=None,
 641    covariance_initial=None,
 642    n_starts=1,
 643    max_iter=100,
 644    tol=1e-7,
 645    projection_max_iter=100,
 646    projection_tol=1e-8,
 647    max_dense_dimension=256,
 648    covariance_floor=0.0,
 649    center=False,
 650    random_state=0,
 651):
 652    """Fit Li--Xiao multilag, multiterm tensor autoregression.
 653
 654    terms gives positive component counts for corresponding positive increasing
 655    lags (default 1,...,len(terms)); gaps are allowed through explicit lags.
 656    X has shape (T,d1,...,dK), K>=2. method is 'ls', 'mle', or 'projection'.
 657    initial has nested [lag][term][mode] square matrices in public mode order.
 658    It replaces init, and later starts perturb it without using true parameters.
 659    init='projection' requires an identified unrestricted VAR design and a
 660    bounded dense allocation; it never silently falls back to another method.
 661    K=2 projection uses exact SVD, K>=3 uses explicitly local CP-ALS. Projection
 662    method reports its own convergence, not convergence of the LS objective.
 663
 664    MLE assumes iid Gaussian innovations with separable covariance. Supplied
 665    covariance_initial factors are in physical units; otherwise projection
 666    initialization uses hierarchical SVD of unrestricted VAR residual moments.
 667    Random/user coefficient starts default to isotropic residual covariance,
 668    an explicit computational alternative. covariance_floor is a relative
 669    mode eigenvalue floor (default zero); singular unregularized updates fail.
 670    Mean centering is optional, not fitted-intercept profiling. All starts and
 671    failed sweeps remain visible; increasing objectives are rolled back.
 672    tol controls relative SSE change for LS and change in mean negative log
 673    likelihood per observed scalar for MLE (invariant to unit log constants).
 674    Automatic order/rank selection and inference are not implemented.
 675
 676    References
 677    ----------
 678    Li, Z. and Xiao, H. (2021), "Multi-Linear Tensor Autoregressive Models",
 679    Sections 3.1--3.3, https://arxiv.org/abs/2110.00928v1.
 680    Fixed sample-mean centering, local CP-ALS projection, multistart choices
 681    and optional covariance flooring are documented computational choices;
 682    a local projection is not a certified best CP approximation. See
 683    docs/tensor-autoregression-notes.md for manuscript indexing corrections.
 684    """
 685    X = as_series(X, min_samples=3, ndim=None)
 686    if X.ndim < 3:
 687        raise ValueError("X needs at least two spatial modes")
 688    try:
 689        terms = tuple(positive_int(r, "terms") for r in terms)
 690        lags = (
 691            tuple(range(1, len(terms) + 1))
 692            if lags is None
 693            else tuple(positive_int(lag, "lag") for lag in lags)
 694        )
 695    except TypeError as exc:
 696        raise ValueError("terms and lags must be sequences") from exc
 697    if not terms or len(terms) != len(lags) or tuple(sorted(set(lags))) != lags:
 698        raise ValueError(
 699            "terms and increasing unique lags must have equal nonzero length"
 700        )
 701    order, shape = max(lags), X.shape[1:]
 702    if len(X) <= order:
 703        raise ValueError("insufficient observations for the largest lag")
 704    if len(shape) == 2 and max(terms) > min(d * d for d in shape):
 705        raise ValueError(
 706            "matrix term counts cannot exceed the maximum rearranged matrix rank"
 707        )
 708    if method not in ("ls", "mle", "projection") or init not in (
 709        "projection",
 710        "random",
 711    ):
 712        raise ValueError(
 713            "method must be ls/mle/projection; init must be projection/random"
 714        )
 715    if method == "projection" and (initial is not None or init != "projection"):
 716        raise ValueError(
 717            "projection estimator requires projection initialization without initial"
 718        )
 719    if not isinstance(center, (bool, np.bool_)):
 720        raise ValueError("center must be boolean")
 721    n_starts = positive_int(n_starts, "n_starts")
 722    max_iter = positive_int(max_iter, "max_iter")
 723    projection_max_iter = positive_int(projection_max_iter, "projection_max_iter")
 724    max_dense_dimension = positive_int(max_dense_dimension, "max_dense_dimension")
 725    tol, projection_tol = finite_scalar(tol, "tol", minimum=0), finite_scalar(
 726        projection_tol, "projection_tol", minimum=0
 727    )
 728    if not tol or not projection_tol:
 729        raise ValueError("tolerances must be positive")
 730    floor = finite_scalar(covariance_floor, "covariance_floor", minimum=0)
 731    if floor >= 1 or (method != "mle" and (floor or covariance_initial is not None)):
 732        raise ValueError("covariance options require mle and covariance_floor < 1")
 733    scale = float(np.max(np.abs(X)))
 734    if scale == 0:
 735        raise ValueError("zero observations do not identify tensor AR coefficients")
 736    work = X / scale
 737    mean = work.mean(axis=0) if center else np.zeros(shape)
 738    work = work - mean
 739    if not np.any(work):
 740        raise ValueError(
 741            "constant centered observations do not identify tensor AR coefficients"
 742        )
 743    response = work[order:]
 744    lagged = [work[order - lag : len(work) - lag] for lag in lags]
 745    rng = np.random.default_rng(random_state)
 746    supplied = None
 747    if initial is not None:
 748        try:
 749            if len(initial) != len(terms) or any(
 750                len(lag) != count for lag, count in zip(initial, terms)
 751            ):
 752                raise ValueError("initial lag/term structure must match terms")
 753            supplied = []
 754            for lag in initial:
 755                one = []
 756                for term in lag:
 757                    if len(term) != len(shape):
 758                        raise ValueError("initial must contain one matrix per mode")
 759                    matrices = []
 760                    for a, d in zip(term, shape):
 761                        raw = np.asarray(a)
 762                        if (
 763                            raw.dtype.kind not in "iuf"
 764                            or raw.shape != (d, d)
 765                            or not np.isfinite(raw).all()
 766                        ):
 767                            raise ValueError(
 768                                "initial coefficient matrices must be finite real square arrays"
 769                            )
 770                        matrices.append(np.array(raw, dtype=float, copy=True))
 771                    one.append(matrices)
 772                supplied.append(one)
 773            supplied = _canonicalize(supplied)
 774        except TypeError as exc:
 775            raise ValueError("initial must be nested [lag][term][mode]") from exc
 776    supplied_covariance = None
 777    if covariance_initial is not None:
 778        if len(covariance_initial) != len(shape):
 779            raise ValueError("one covariance_initial factor is required per mode")
 780        supplied_covariance = []
 781        for s, d in zip(covariance_initial, shape):
 782            raw = np.asarray(s)
 783            if (
 784                raw.dtype.kind not in "iuf"
 785                or raw.shape != (d, d)
 786                or not np.isfinite(raw).all()
 787                or not np.allclose(raw, raw.T, rtol=1e-12, atol=0)
 788            ):
 789                raise ValueError(
 790                    "covariance_initial factors must be finite real symmetric matrices"
 791                )
 792            supplied_covariance.append(_spd(np.array(raw, dtype=float), 0)[0])
 793        supplied_covariance = _normalize_covariances(supplied_covariance)
 794        supplied_covariance[-1] = (supplied_covariance[-1] / scale) / scale
 795        _whitener(supplied_covariance[-1])
 796    runs, solutions = [], []
 797    for attempt in range(n_starts):
 798        history, diagnostics = [], ()
 799        regularized, max_condition, max_cancellation = False, 1.0, 1.0
 800        coefficients, covariances, residuals = None, None, None
 801        status, message, converged = "iteration_limit", "maximum sweeps reached", False
 802        try:
 803            if supplied is not None:
 804                coefficients = _copy_coefficients(supplied)
 805                if attempt:
 806                    for lag in coefficients:
 807                        for term in lag:
 808                            for k in range(len(term)):
 809                                term[k] += rng.normal(size=term[k].shape) * (
 810                                    0.05
 811                                    * max(_norm(term[k]), 0.1)
 812                                    / np.sqrt(term[k].size)
 813                                )
 814                    coefficients = _canonicalize(coefficients)
 815                var_residual = None
 816            elif init == "projection":
 817                coefficients, diagnostics, var_residual = _projection(
 818                    lagged,
 819                    response,
 820                    terms,
 821                    rng,
 822                    projection_max_iter,
 823                    projection_tol,
 824                    max_dense_dimension,
 825                )
 826                if attempt and method != "projection":
 827                    for lag in coefficients:
 828                        for term in lag:
 829                            for k in range(len(term)):
 830                                term[k] += rng.normal(size=term[k].shape) * (
 831                                    0.05
 832                                    * max(_norm(term[k]), 0.1)
 833                                    / np.sqrt(term[k].size)
 834                                )
 835                    coefficients = _canonicalize(coefficients)
 836            else:
 837                coefficients = _canonicalize(
 838                    [
 839                        [
 840                            [rng.normal(size=(d, d)) / np.sqrt(d) for d in shape]
 841                            for _ in range(count)
 842                        ]
 843                        for count in terms
 844                    ]
 845                )
 846                for lag in coefficients:
 847                    for term in lag:
 848                        term[-1] *= (
 849                            0.2
 850                            / max(_norm(term[-1]), np.finfo(float).tiny)
 851                            / sum(terms)
 852                        )
 853                var_residual = None
 854            residuals = response - _prediction(lagged, coefficients)
 855            if method == "mle":
 856                if supplied_covariance is not None:
 857                    covariances = [s.copy() for s in supplied_covariance]
 858                elif var_residual is not None:
 859                    covariances, regularized = _covariance_projection(
 860                        var_residual, shape, floor
 861                    )
 862                else:
 863                    energy = float(np.mean(residuals**2))
 864                    if energy <= 0:
 865                        raise ValueError(
 866                            "Gaussian likelihood is undefined at zero residual variance"
 867                        )
 868                    covariances = [np.eye(d) for d in shape]
 869                    covariances[-1] *= energy
 870                    covariances = _normalize_covariances(covariances)
 871            objective = (
 872                _negative_loglike(residuals, covariances)
 873                if method == "mle"
 874                else float(np.sum(residuals**2))
 875            )
 876            history.append(objective)
 877            if method == "projection":
 878                converged = all(d.converged for d in diagnostics)
 879                status, message = (
 880                    ("projection_converged", "projection objective converged")
 881                    if converged
 882                    else (
 883                        "projection_iteration_limit",
 884                        "local CP projection reached iteration limit",
 885                    )
 886                )
 887            else:
 888                for iteration in range(1, max_iter + 1):
 889                    old_coefficients = _copy_coefficients(coefficients)
 890                    old_covariances = (
 891                        None if covariances is None else [s.copy() for s in covariances]
 892                    )
 893                    old_residuals = residuals.copy()
 894                    try:
 895                        for i, lag in enumerate(coefficients):
 896                            for r, term in enumerate(lag):
 897                                for k in range(len(shape)):
 898                                    partial = residuals + _apply(lagged[i], term)
 899                                    predictor = _apply(lagged[i], term, skip=k)
 900                                    term[k], condition = _coefficient_block(
 901                                        partial, predictor, k, covariances
 902                                    )
 903                                    max_condition = max(max_condition, condition)
 904                                    term = _normalize_term(term)
 905                                    coefficients[i][r] = term
 906                                    residuals = partial - _apply(lagged[i], term)
 907                        coefficients = _canonicalize(coefficients)
 908                        residuals = response - _prediction(lagged, coefficients)
 909                        if method == "mle":
 910                            for k in range(len(shape) - 1, -1, -1):
 911                                covariances[k], active = _covariance_block(
 912                                    residuals, covariances, k, floor
 913                                )
 914                                regularized |= active
 915                            covariances = _normalize_covariances(covariances)
 916                        value = (
 917                            _negative_loglike(residuals, covariances)
 918                            if method == "mle"
 919                            else float(np.sum(residuals**2))
 920                        )
 921                        cancellation = max(
 922                            d["cancellation_ratio"]
 923                            for d in _coefficient_diagnostics(coefficients)
 924                        )
 925                        max_cancellation = max(max_cancellation, cancellation)
 926                        if not np.isfinite(value) or cancellation > 1e8:
 927                            raise ValueError(
 928                                "numerically degenerate fit: nonfinite objective or component cancellation"
 929                            )
 930                        objective_scale = (
 931                            residuals.size
 932                            if method == "mle"
 933                            else max(abs(history[-1]), abs(value), np.finfo(float).tiny)
 934                        )
 935                        if value > history[-1] + 1e-9 * objective_scale:
 936                            raise ValueError(
 937                                "objective increased; last complete sweep retained"
 938                            )
 939                    except (ValueError, FloatingPointError, np.linalg.LinAlgError):
 940                        coefficients, covariances, residuals = (
 941                            old_coefficients,
 942                            old_covariances,
 943                            old_residuals,
 944                        )
 945                        raise
 946                    history.append(value)
 947                    if abs(history[-2] - value) <= tol * objective_scale:
 948                        converged, status, message = (
 949                            True,
 950                            "converged",
 951                            (
 952                                "mean likelihood change below tolerance"
 953                                if method == "mle"
 954                                else "relative SSE change below tolerance"
 955                            ),
 956                        )
 957                        break
 958        except (ValueError, FloatingPointError, np.linalg.LinAlgError) as exc:
 959            status, message, converged = "failed", str(exc), False
 960        runs.append(
 961            TensorARRun(
 962                converged,
 963                max(0, len(history) - 1),
 964                np.asarray(history),
 965                status,
 966                message,
 967                regularized,
 968                max_condition,
 969                max_cancellation,
 970                diagnostics,
 971            )
 972        )
 973        solutions.append((coefficients, covariances, residuals))
 974    eligible = [i for i, run in enumerate(runs) if np.isfinite(run.objective)]
 975    if not eligible:
 976        raise ValueError(
 977            "no finite tensor AR start: " + "; ".join(run.message for run in runs)
 978        )
 979    selected = min(
 980        eligible,
 981        key=lambda i: (
 982            sum(d.objective_history[-1] for d in runs[i].projection)
 983            if method == "projection"
 984            else runs[i].objective
 985        ),
 986    )
 987    coefficients, covariances, residuals = solutions[selected]
 988    with np.errstate(over="ignore", under="ignore", invalid="ignore"):
 989        fitted = (_prediction(lagged, coefficients) + mean) * scale
 990        physical_residuals = residuals * scale
 991        physical_mean = mean * scale
 992        if covariances is not None:
 993            covariances[-1] = (covariances[-1] * scale) * scale
 994            for covariance in covariances:
 995                _whitener(covariance)
 996    if not np.isfinite(fitted).all() or not np.isfinite(physical_residuals).all():
 997        raise FloatingPointError("tensor AR output exceeds physical data units")
 998    return TensorARResult(
 999        tuple(
1000            tuple(tuple(a.copy() for a in term) for term in lag) for lag in coefficients
1001        ),
1002        lags,
1003        terms,
1004        method,
1005        fitted,
1006        physical_residuals,
1007        physical_mean,
1008        scale,
1009        None if covariances is None else tuple(covariances),
1010        tuple(runs),
1011        selected,
1012        _coefficient_diagnostics(coefficients),
1013        max_dense_dimension,
1014        X[-order:].copy(),
1015    )

Fit Li--Xiao multilag, multiterm tensor autoregression.

terms gives positive component counts for corresponding positive increasing lags (default 1,...,len(terms)); gaps are allowed through explicit lags. X has shape (T,d1,...,dK), K>=2. method is 'ls', 'mle', or 'projection'. initial has nested [lag][term][mode] square matrices in public mode order. It replaces init, and later starts perturb it without using true parameters. init='projection' requires an identified unrestricted VAR design and a bounded dense allocation; it never silently falls back to another method. K=2 projection uses exact SVD, K>=3 uses explicitly local CP-ALS. Projection method reports its own convergence, not convergence of the LS objective.

MLE assumes iid Gaussian innovations with separable covariance. Supplied covariance_initial factors are in physical units; otherwise projection initialization uses hierarchical SVD of unrestricted VAR residual moments. Random/user coefficient starts default to isotropic residual covariance, an explicit computational alternative. covariance_floor is a relative mode eigenvalue floor (default zero); singular unregularized updates fail. Mean centering is optional, not fitted-intercept profiling. All starts and failed sweeps remain visible; increasing objectives are rolled back. tol controls relative SSE change for LS and change in mean negative log likelihood per observed scalar for MLE (invariant to unit log constants). Automatic order/rank selection and inference are not implemented.

References

Li, Z. and Xiao, H. (2021), "Multi-Linear Tensor Autoregressive Models", Sections 3.1--3.3, https://arxiv.org/abs/2110.00928v1. Fixed sample-mean centering, local CP-ALS projection, multistart choices and optional covariance flooring are documented computational choices; a local projection is not a certified best CP approximation. See docs/tensor-autoregression-notes.md for manuscript indexing corrections.

class MatrixFactorMonitor:
151class MatrixFactorMonitor:
152    """Monitor a fixed matrix factor rank using a genuinely sequential state.
153
154    Parameters
155    ----------
156    training : array_like, shape (m, rows, columns)
157        Finite real observations from a stable training period, with m >= 2.
158        The paper assumes zero means. No centering, standardization, rank
159        selection, or retrospective change-point search is performed.
160    rank : int
161        Baseline rank of the monitored mode, fixed before monitoring. Must be
162        positive and below its dimension. The (rank+1)-st eigenvalue is monitored.
163    projection_rank : int
164        Number of opposite-mode PCA directions used in every rolling window.
165        Must be positive and below that mode's dimension. It should cover the
166        true opposite-mode factor rank. Both ranks are supplied using training
167        information only and remain fixed throughout monitoring.
168    horizon : int
169        Number of future observations allowed, fixed before monitoring. The
170        maximum procedure requires at least 2; eta=0.5 requires at least 3.
171        Alarm probability refers to this whole horizon, not one time point.
172    mode : {"row", "column"}, default "row"
173        The column procedure applies the same equations to transposed matrices.
174    power : float, default 8
175        Power r >= 2 in accepted-manuscript (3.10), not the exponential
176        transformation in arXiv v1. The value 8 reproduces the Gaussian
177        simulation choice; its theory requires finite 32nd moments of both
178        factors and errors. Power 2 still requires finite eighth moments.
179    epsilon : float, default 0.05
180        Positive smoothing in delta=epsilon+max(0,1-1/(2*beta)), with
181        beta=log(monitored_dimension)/log(opposite_dimension*m). The resulting
182        delta must be below one. A fixed epsilon does not guarantee finite
183        sample calibration.
184    statistic : {"maximum", "partial_sum"}, default "maximum"
185        Maximum uses the manuscript's one-sided Gumbel-normalized y statistic.
186        Partial sums use absolute cumulative sums and the eta-specific scaling.
187    eta : float, default 0.25
188        Partial-sum exponent in [0,1). Eta<0.5 uses horizon scaling; eta=0.5
189        uses Darling--Erdos normalization; eta>0.5 uses the Renyi procedure.
190        Ignored by the maximum procedure.
191    delay : int, optional
192        First eligible monitoring step for eta>0.5. Defaults to
193        max(1,floor(log(horizon))), following the reference software where
194        positive. It must not exceed the horizon. Partial sums include draws
195        before this step. Not accepted for other procedures.
196    alpha : float, default 0.05
197        Requested horizon-wide asymptotic false-alarm level. Used to generate
198        the Gumbel critical value if critical_value is not supplied.
199    critical_value : float, optional
200        On the normalized statistic scale: Gumbel for maximum and eta=0.5;
201        weighted Brownian-supremum for eta<0.5; Brownian exponent 1-eta for
202        Renyi. Required for partial sums with eta != 0.5. A supplied value can
203        also be a separately calibrated finite-horizon Gaussian reference
204        threshold; this does not establish exact finite-sample matrix-data
205        false-alarm control. The caller owns its calibration.
206    random_state : int, numpy.random.Generator, or None
207        Independent local randomization. A supplied Generator is copied, so
208        its state is not advanced. Each accepted observation consumes one
209        standard normal draw unless noise is explicitly supplied to update.
210
211    Notes
212    -----
213    At step tau, only observations tau+1,...,m+tau enter the window. Re-estimate
214    opposite-mode PCA on that window, then recompute the projected second
215    moment for ALL window observations using the new projection. Normalize
216    its (rank+1)-st eigenvalue by its trace/monitored_dimension, multiply by
217    monitored_dimension**(-delta), raise to power, and add independent N(0,1).
218    The resulting draws are conditionally independent N(drift_tau,1), not
219    exactly iid standard normal under a finite-sample null.
220
221    Monitoring ends at its first strict boundary crossing, or on consuming the
222    horizon. update then raises RuntimeError; update_many returns the consumed
223    prefix and leaves later observations untouched. No automatic restart or
224    re-estimation of the baseline rank occurs. The reported detection time is
225    an alarm time, not an estimate corrected for detection delay.
226
227    Supports changes that enlarge the joint pre/post loading span and newly
228    appearing factors. Disappearing factors, zero baseline ranks, weak-factor
229    extensions, and simultaneous row/column multiplicity calibration are not
230    implemented. See docs/monitoring-notes.md for population assumptions.
231
232    References
233    ----------
234    He, Y., Kong, X., Trapani, L. and Yu, L. (2024).
235    Online Change-Point Detection for Matrix-Valued Time Series with
236    Latent Two-Way Factor Structure. Annals of Statistics, 52, 1646-1670.
237    https://doi.org/10.1214/24-AOS2410
238    Uses the accepted-manuscript power transformation and equations
239    (3.9)-(3.20), (3.28)-(3.32), not the earlier arXiv v1 transformation.
240    State management is a software interface, not an additional estimator.
241    """
242
243    def __init__(
244        self,
245        training,
246        rank,
247        projection_rank,
248        horizon,
249        *,
250        mode="row",
251        power=8,
252        epsilon=0.05,
253        statistic="maximum",
254        eta=0.25,
255        delay=None,
256        alpha=0.05,
257        critical_value=None,
258        random_state=None,
259    ):
260        training = as_series(training)
261        if not isinstance(mode, str) or mode not in ("row", "column"):
262            raise ValueError("mode must be 'row' or 'column'")
263        if not isinstance(statistic, str) or statistic not in (
264            "maximum",
265            "partial_sum",
266        ):
267            raise ValueError("statistic must be 'maximum' or 'partial_sum'")
268        self.mode = mode
269        self.statistic = statistic
270        self.training_length = len(training)
271        self.shape = training.shape[1:]
272        p, q = self.shape if mode == "row" else self.shape[::-1]
273        self.rank = positive_int(rank, "rank")
274        self.projection_rank = positive_int(projection_rank, "projection_rank")
275        if self.rank >= p or self.projection_rank >= q:
276            raise ValueError(
277                "rank and projection_rank must be below their mode dimensions"
278            )
279        if self.rank + 1 > self.training_length * self.projection_rank:
280            raise ValueError("window cannot support the monitored eigenvalue")
281        self.horizon = positive_int(horizon, "horizon")
282        self.power = finite_scalar(power, "power", minimum=2)
283        self.epsilon = finite_scalar(epsilon, "epsilon", minimum=0)
284        if self.epsilon == 0:
285            raise ValueError("epsilon must be strictly positive")
286        beta = np.log(p) / np.log(q * self.training_length)
287        self.delta = float(self.epsilon + max(0, 1 - 1 / (2 * beta)))
288        if self.delta >= 1:
289            raise ValueError("epsilon and dimensions must give delta < 1")
290        self.eta = finite_scalar(eta, "eta", minimum=0)
291        if self.eta >= 1:
292            raise ValueError("eta must lie in [0,1)")
293        self.alpha = finite_scalar(alpha, "alpha", minimum=0)
294        if not 0 < self.alpha < 1:
295            raise ValueError("alpha must lie strictly between zero and one")
296        if statistic == "maximum" and self.horizon < 2:
297            raise ValueError("maximum normalization requires horizon >= 2")
298        if statistic == "partial_sum" and self.eta == 0.5 and self.horizon < 3:
299            raise ValueError("eta=0.5 normalization requires horizon >= 3")
300        renyi = statistic == "partial_sum" and self.eta > 0.5
301        if delay is not None and not renyi:
302            raise ValueError("delay applies only to partial sums with eta > 0.5")
303        self.delay = (
304            (
305                max(1, int(np.log(self.horizon)))
306                if delay is None
307                else positive_int(delay, "delay")
308            )
309            if renyi
310            else 1
311        )
312        if self.delay > self.horizon:
313            raise ValueError("delay cannot exceed the monitoring horizon")
314        if critical_value is None:
315            if statistic == "partial_sum" and self.eta != 0.5:
316                raise ValueError(
317                    "critical_value is required for eta != 0.5; supply a calibrated "
318                    "Brownian or finite-horizon Gaussian reference quantile"
319                )
320            self.critical_value = float(-np.log(-np.log1p(-self.alpha)))
321            self.calibration = "asymptotic-gumbel"
322        else:
323            self.critical_value = finite_scalar(critical_value, "critical_value")
324            if (
325                statistic == "partial_sum"
326                and self.eta != 0.5
327                and self.critical_value <= 0
328            ):
329                raise ValueError(
330                    "Brownian-scale critical_value must be strictly positive"
331                )
332            self.calibration = "caller-supplied"
333        self._configure_normalizers()
334        self._window = training.copy()
335        self._rng = (
336            deepcopy(random_state)
337            if isinstance(random_state, np.random.Generator)
338            else np.random.default_rng(random_state)
339        )
340        self._steps = []
341        self._sum = (0, -np.inf)
342        # Reject a zero-trace training sample before an unusable monitor is
343        # handed back. No random draw or monitoring observation is consumed.
344        self._window_eigenvalue(self._window)
345
346    def _configure_normalizers(self):
347        if self.statistic == "maximum":
348            root = np.sqrt(2 * np.log(self.horizon))
349            self._b = float(
350                root - (np.log(np.log(self.horizon)) + np.log(4 * np.pi)) / (2 * root)
351            )
352            self._a = float(self._b / (1 + self._b**2))
353        elif self.eta == 0.5:
354            nested = np.log(np.log(self.horizon))
355            self._a = float(np.sqrt(2 * nested))
356            self._b = float(2 * nested + 0.5 * np.log(nested) - 0.5 * np.log(np.pi))
357        else:
358            self._a, self._b = 1.0, 0.0
359
360    @property
361    def n_processed(self):
362        """Number of consumed post-training observations."""
363        return len(self._steps)
364
365    @property
366    def steps(self):
367        """Immutable sequence of accepted monitoring diagnostics."""
368        return tuple(self._steps)
369
370    @property
371    def alarm_step(self):
372        """First alarm's one-based monitoring index, or None."""
373        return (
374            self._steps[-1].monitoring_index
375            if self._steps and self._steps[-1].alarm
376            else None
377        )
378
379    @property
380    def alarm_observation(self):
381        """First alarm's one-based index in training + monitoring, or None."""
382        return (
383            self.training_length + self.alarm_step
384            if self.alarm_step is not None
385            else None
386        )
387
388    @property
389    def stop_reason(self):
390        """'alarm', 'horizon', or None while monitoring remains active."""
391        if self.alarm_step is not None:
392            return "alarm"
393        return "horizon" if self.n_processed == self.horizon else None
394
395    @property
396    def is_stopped(self):
397        return self.stop_reason is not None
398
399    @property
400    def window(self):
401        """Detached copy of the current chronological trailing window."""
402        return self._window.copy()
403
404    def _window_eigenvalue(self, window):
405        scale = float(np.max(np.abs(window)))
406        if scale == 0:
407            raise ValueError("projected window has zero trace; monitoring is undefined")
408        work = window / scale
409        if self.mode == "column":
410            work = work.transpose(0, 2, 1)
411        _, p, q = work.shape
412        gram = np.einsum("tij,tik->jk", work, work) / (len(work) * p)
413        _, vectors = np.linalg.eigh((gram + gram.T) * 0.5)
414        projection = vectors[:, -self.projection_rank :]
415        projected = work @ projection
416        moment = np.einsum("tij,tkj->ik", projected, projected) / (len(work) * q)
417        spectrum = np.maximum(np.linalg.eigvalsh((moment + moment.T) * 0.5), 0)
418        trace = float(np.trace(moment))
419        if not np.isfinite(trace) or trace <= 0:
420            raise ValueError("projected window has zero trace; monitoring is undefined")
421        eigenvalue = float(spectrum[-self.rank - 1])
422        normalized = float(p ** (1 - self.delta) * (eigenvalue / trace))
423        if normalized == 0:
424            log_drift = -np.inf
425        else:
426            with np.errstate(over="ignore"):
427                log_drift = float(self.power * np.log(normalized))
428        return scale, eigenvalue, trace, normalized, log_drift
429
430    def _score(self, randomized, partial_sum, step):
431        if self.statistic == "maximum":
432            return _scale_log(_add_logs(randomized, _signed_log(-self._b)), 1 / self._a)
433        absolute_sum = (abs(partial_sum[0]), partial_sum[1])
434        if self.eta == 0.5:
435            return _add_logs(
436                _scale_log(absolute_sum, self._a / np.sqrt(step)),
437                _signed_log(-self._b),
438            )
439        reference = self.horizon if self.eta < 0.5 else self.delay
440        sign, logabs = absolute_sum
441        return sign, float(
442            logabs + (self.eta - 0.5) * np.log(reference) - self.eta * np.log(step)
443        )
444
445    def update(self, matrix, *, noise=None):
446        """Consume one matrix; rejected inputs do not advance the state or RNG.
447
448        ``noise`` optionally supplies the standardized Gaussian draw. It is
449        intended for reproducible external randomization and equation oracles;
450        supplying deterministic/non-Gaussian values forfeits Gaussian reference
451        calibration. An explicit value does not consume the monitor's RNG.
452
453        References
454        ----------
455        He, Y., Kong, X., Trapani, L. and Yu, L. (2024).
456        Online Change-Point Detection for Matrix-Valued Time Series with
457        Latent Two-Way Factor Structure. Annals of Statistics, 52, 1646-1670.
458        https://doi.org/10.1214/24-AOS2410
459        Uses the accepted-manuscript power transformation and equations
460        (3.9)-(3.20), (3.28)-(3.32), not the earlier arXiv v1 transformation.
461        State management is a software interface, not an additional estimator.
462        """
463        if self.is_stopped:
464            raise RuntimeError(f"monitoring already stopped: {self.stop_reason}")
465        raw = np.asarray(matrix)
466        if raw.ndim != 2 or raw.shape != self.shape:
467            raise ValueError("matrix dimensions must match the training observations")
468        observation = as_series(raw[None], min_samples=1)[0]
469        supplied = None if noise is None else finite_scalar(noise, "noise")
470        candidate = np.concatenate((self._window[1:], observation[None]))
471        scale, eigenvalue, trace, normalized, log_drift = self._window_eigenvalue(
472            candidate
473        )
474        # Nothing below can fail for validated real scalars. Draw only after
475        # the complete data-dependent calculation has succeeded.
476        gaussian = float(self._rng.standard_normal()) if supplied is None else supplied
477        drift_log = (0, -np.inf) if log_drift == -np.inf else (1, log_drift)
478        randomized = _add_logs(drift_log, _signed_log(gaussian))
479        partial_sum = _add_logs(self._sum, randomized)
480        index = self.n_processed + 1
481        score = self._score(randomized, partial_sum, index)
482        eligible = index >= self.delay
483        alarm = bool(eligible and _greater(score, _signed_log(self.critical_value)))
484        displayed = [
485            _display(value) for value in (drift_log, randomized, partial_sum, score)
486        ]
487        result = MonitorStep(
488            monitoring_index=index,
489            observation_index=self.training_length + index,
490            window_start_index=index + 1,
491            data_scale=scale,
492            eigenvalue_scaled=eigenvalue,
493            trace_scaled=trace,
494            normalized_eigenvalue=normalized,
495            log_drift=log_drift,
496            drift=displayed[0][0],
497            noise=gaussian,
498            randomized_value=displayed[1][0],
499            partial_sum=displayed[2][0],
500            statistic=displayed[3][0],
501            critical_value=self.critical_value,
502            eligible=eligible,
503            alarm=alarm,
504            saturated=any(item[1] for item in displayed),
505        )
506        self._window = candidate
507        self._sum = partial_sum
508        self._steps.append(result)
509        return result
510
511    def update_many(self, observations, *, noise=None):
512        """Consume an array's prefix until the first alarm or horizon exhaustion.
513
514        The return tuple contains only consumed observations. Validation is
515        per observation, matching successive update calls: a bad later value
516        does not roll back earlier updates, and values after stopping are not
517        inspected. An empty batch or an already stopped monitor returns ().
518
519        References
520        ----------
521        He, Y., Kong, X., Trapani, L. and Yu, L. (2024).
522        Online Change-Point Detection for Matrix-Valued Time Series with
523        Latent Two-Way Factor Structure. Annals of Statistics, 52, 1646-1670.
524        https://doi.org/10.1214/24-AOS2410
525        Uses the accepted-manuscript power transformation and equations
526        (3.9)-(3.20), (3.28)-(3.32), not the earlier arXiv v1 transformation.
527        State management is a software interface, not an additional estimator.
528        """
529        if self.is_stopped:
530            return ()
531        batch = np.asarray(observations)
532        if batch.ndim != 3 or batch.shape[1:] != self.shape:
533            raise ValueError(
534                "observations must be a time-first array matching training"
535            )
536        if noise is not None:
537            draws = np.asarray(noise)
538            if draws.ndim != 1 or len(draws) != len(batch):
539                raise ValueError("noise must have one entry per supplied observation")
540        results = []
541        for index, matrix in enumerate(batch):
542            if self.is_stopped:
543                break
544            results.append(
545                self.update(matrix, noise=None if noise is None else draws[index])
546            )
547        return tuple(results)
548
549    def state_dict(self):
550        """Return detached, JSON-compatible state for exact continuation.
551
552        Includes the trailing window, all diagnostics, configuration and local
553        bit-generator state. No executable objects or pickle are needed.
554        Infinite log drift is encoded as a string so strict JSON is supported.
555        """
556        configuration = dict(
557            rank=self.rank,
558            projection_rank=self.projection_rank,
559            horizon=self.horizon,
560            mode=self.mode,
561            power=self.power,
562            epsilon=self.epsilon,
563            statistic=self.statistic,
564            eta=self.eta,
565            delay=(
566                self.delay
567                if self.statistic == "partial_sum" and self.eta > 0.5
568                else None
569            ),
570            alpha=self.alpha,
571            critical_value=self.critical_value,
572        )
573        return _encode(
574            dict(
575                schema_version=1,
576                configuration=configuration,
577                calibration=self.calibration,
578                window=self._window,
579                steps=[asdict(step) for step in self._steps],
580                rng_state=deepcopy(self._rng.bit_generator.state),
581            )
582        )
583
584    @classmethod
585    def from_state(cls, state):
586        """Restore a state_dict snapshot without consuming observations or draws.
587
588        Snapshots are checked for format and basic consistency. They should
589        come from this implementation; this is not a validator of scientific
590        provenance for manually constructed or modified histories.
591        """
592        try:
593            state = deepcopy(state)
594            if not isinstance(state, dict) or state["schema_version"] != 1:
595                raise ValueError("unsupported monitoring state schema")
596            rng_state = state["rng_state"]
597            kind = rng_state["bit_generator"]
598            if kind not in ("PCG64", "PCG64DXSM", "MT19937", "Philox", "SFC64"):
599                raise ValueError("unsupported random bit generator in state")
600            bit_generator = getattr(np.random, kind)()
601            bit_generator.state = rng_state
602            result = cls(
603                state["window"],
604                **state["configuration"],
605                random_state=np.random.Generator(bit_generator),
606            )
607            if state["calibration"] not in ("asymptotic-gumbel", "caller-supplied"):
608                raise ValueError("unknown critical-value calibration in state")
609            result.calibration = state["calibration"]
610            steps = state["steps"]
611            if not isinstance(steps, list) or len(steps) > result.horizon:
612                raise ValueError("inconsistent number of monitoring steps")
613            for index, entry in enumerate(steps, start=1):
614                if result.is_stopped:
615                    raise ValueError("state has observations after monitoring stopped")
616                if entry["log_drift"] == "-Infinity":
617                    entry["log_drift"] = -np.inf
618                elif entry["log_drift"] == "Infinity":
619                    entry["log_drift"] = np.inf
620                step = MonitorStep(**entry)
621                if (
622                    step.monitoring_index != index
623                    or step.observation_index != result.training_length + index
624                    or step.window_start_index != index + 1
625                ):
626                    raise ValueError("inconsistent monitoring indices in state")
627                if np.isnan(step.log_drift) or not np.isfinite(step.noise):
628                    raise ValueError("invalid randomization history in state")
629                numeric_fields = (
630                    step.data_scale,
631                    step.eigenvalue_scaled,
632                    step.trace_scaled,
633                    step.normalized_eigenvalue,
634                    step.drift,
635                    step.randomized_value,
636                    step.partial_sum,
637                    step.statistic,
638                    step.critical_value,
639                )
640                if (
641                    not all(np.isfinite(value) for value in numeric_fields)
642                    or step.data_scale <= 0
643                    or step.trace_scaled <= 0
644                    or step.eigenvalue_scaled < 0
645                    or step.normalized_eigenvalue < 0
646                    or step.drift < 0
647                    or step.critical_value != result.critical_value
648                    or not all(
649                        isinstance(value, bool)
650                        for value in (step.eligible, step.alarm, step.saturated)
651                    )
652                ):
653                    raise ValueError("invalid monitoring diagnostics in state")
654                drift = (
655                    (0, -np.inf) if step.log_drift == -np.inf else (1, step.log_drift)
656                )
657                randomized = _add_logs(drift, _signed_log(step.noise))
658                result._sum = _add_logs(result._sum, randomized)
659                expected_alarm = index >= result.delay and _greater(
660                    result._score(randomized, result._sum, index),
661                    _signed_log(result.critical_value),
662                )
663                if (
664                    step.eligible != (index >= result.delay)
665                    or step.alarm != expected_alarm
666                ):
667                    raise ValueError("inconsistent stopping decision in state")
668                result._steps.append(step)
669            return result
670        except (KeyError, TypeError, AttributeError, OverflowError) as exc:
671            raise ValueError("invalid monitoring state") from exc

Monitor a fixed matrix factor rank using a genuinely sequential state.

Parameters
  • training (array_like, shape (m, rows, columns)): Finite real observations from a stable training period, with m >= 2. The paper assumes zero means. No centering, standardization, rank selection, or retrospective change-point search is performed.
  • rank (int): Baseline rank of the monitored mode, fixed before monitoring. Must be positive and below its dimension. The (rank+1)-st eigenvalue is monitored.
  • projection_rank (int): Number of opposite-mode PCA directions used in every rolling window. Must be positive and below that mode's dimension. It should cover the true opposite-mode factor rank. Both ranks are supplied using training information only and remain fixed throughout monitoring.
  • horizon (int): Number of future observations allowed, fixed before monitoring. The maximum procedure requires at least 2; eta=0.5 requires at least 3. Alarm probability refers to this whole horizon, not one time point.
  • mode ({"row", "column"}, default "row"): The column procedure applies the same equations to transposed matrices.
  • power (float, default 8): Power r >= 2 in accepted-manuscript (3.10), not the exponential transformation in arXiv v1. The value 8 reproduces the Gaussian simulation choice; its theory requires finite 32nd moments of both factors and errors. Power 2 still requires finite eighth moments.
  • epsilon (float, default 0.05): Positive smoothing in delta=epsilon+max(0,1-1/(2beta)), with beta=log(monitored_dimension)/log(opposite_dimensionm). The resulting delta must be below one. A fixed epsilon does not guarantee finite sample calibration.
  • statistic ({"maximum", "partial_sum"}, default "maximum"): Maximum uses the manuscript's one-sided Gumbel-normalized y statistic. Partial sums use absolute cumulative sums and the eta-specific scaling.
  • eta (float, default 0.25): Partial-sum exponent in [0,1). Eta<0.5 uses horizon scaling; eta=0.5 uses Darling--Erdos normalization; eta>0.5 uses the Renyi procedure. Ignored by the maximum procedure.
  • delay (int, optional): First eligible monitoring step for eta>0.5. Defaults to max(1,floor(log(horizon))), following the reference software where positive. It must not exceed the horizon. Partial sums include draws before this step. Not accepted for other procedures.
  • alpha (float, default 0.05): Requested horizon-wide asymptotic false-alarm level. Used to generate the Gumbel critical value if critical_value is not supplied.
  • critical_value (float, optional): On the normalized statistic scale: Gumbel for maximum and eta=0.5; weighted Brownian-supremum for eta<0.5; Brownian exponent 1-eta for Renyi. Required for partial sums with eta != 0.5. A supplied value can also be a separately calibrated finite-horizon Gaussian reference threshold; this does not establish exact finite-sample matrix-data false-alarm control. The caller owns its calibration.
  • random_state (int, numpy.random.Generator, or None): Independent local randomization. A supplied Generator is copied, so its state is not advanced. Each accepted observation consumes one standard normal draw unless noise is explicitly supplied to update.
Notes

At step tau, only observations tau+1,...,m+tau enter the window. Re-estimate opposite-mode PCA on that window, then recompute the projected second moment for ALL window observations using the new projection. Normalize its (rank+1)-st eigenvalue by its trace/monitored_dimension, multiply by monitored_dimension**(-delta), raise to power, and add independent N(0,1). The resulting draws are conditionally independent N(drift_tau,1), not exactly iid standard normal under a finite-sample null.

Monitoring ends at its first strict boundary crossing, or on consuming the horizon. update then raises RuntimeError; update_many returns the consumed prefix and leaves later observations untouched. No automatic restart or re-estimation of the baseline rank occurs. The reported detection time is an alarm time, not an estimate corrected for detection delay.

Supports changes that enlarge the joint pre/post loading span and newly appearing factors. Disappearing factors, zero baseline ranks, weak-factor extensions, and simultaneous row/column multiplicity calibration are not implemented. See docs/monitoring-notes.md for population assumptions.

References

He, Y., Kong, X., Trapani, L. and Yu, L. (2024). Online Change-Point Detection for Matrix-Valued Time Series with Latent Two-Way Factor Structure. Annals of Statistics, 52, 1646-1670. https://doi.org/10.1214/24-AOS2410 Uses the accepted-manuscript power transformation and equations (3.9)-(3.20), (3.28)-(3.32), not the earlier arXiv v1 transformation. State management is a software interface, not an additional estimator.

MatrixFactorMonitor( training, rank, projection_rank, horizon, *, mode='row', power=8, epsilon=0.05, statistic='maximum', eta=0.25, delay=None, alpha=0.05, critical_value=None, random_state=None)
243    def __init__(
244        self,
245        training,
246        rank,
247        projection_rank,
248        horizon,
249        *,
250        mode="row",
251        power=8,
252        epsilon=0.05,
253        statistic="maximum",
254        eta=0.25,
255        delay=None,
256        alpha=0.05,
257        critical_value=None,
258        random_state=None,
259    ):
260        training = as_series(training)
261        if not isinstance(mode, str) or mode not in ("row", "column"):
262            raise ValueError("mode must be 'row' or 'column'")
263        if not isinstance(statistic, str) or statistic not in (
264            "maximum",
265            "partial_sum",
266        ):
267            raise ValueError("statistic must be 'maximum' or 'partial_sum'")
268        self.mode = mode
269        self.statistic = statistic
270        self.training_length = len(training)
271        self.shape = training.shape[1:]
272        p, q = self.shape if mode == "row" else self.shape[::-1]
273        self.rank = positive_int(rank, "rank")
274        self.projection_rank = positive_int(projection_rank, "projection_rank")
275        if self.rank >= p or self.projection_rank >= q:
276            raise ValueError(
277                "rank and projection_rank must be below their mode dimensions"
278            )
279        if self.rank + 1 > self.training_length * self.projection_rank:
280            raise ValueError("window cannot support the monitored eigenvalue")
281        self.horizon = positive_int(horizon, "horizon")
282        self.power = finite_scalar(power, "power", minimum=2)
283        self.epsilon = finite_scalar(epsilon, "epsilon", minimum=0)
284        if self.epsilon == 0:
285            raise ValueError("epsilon must be strictly positive")
286        beta = np.log(p) / np.log(q * self.training_length)
287        self.delta = float(self.epsilon + max(0, 1 - 1 / (2 * beta)))
288        if self.delta >= 1:
289            raise ValueError("epsilon and dimensions must give delta < 1")
290        self.eta = finite_scalar(eta, "eta", minimum=0)
291        if self.eta >= 1:
292            raise ValueError("eta must lie in [0,1)")
293        self.alpha = finite_scalar(alpha, "alpha", minimum=0)
294        if not 0 < self.alpha < 1:
295            raise ValueError("alpha must lie strictly between zero and one")
296        if statistic == "maximum" and self.horizon < 2:
297            raise ValueError("maximum normalization requires horizon >= 2")
298        if statistic == "partial_sum" and self.eta == 0.5 and self.horizon < 3:
299            raise ValueError("eta=0.5 normalization requires horizon >= 3")
300        renyi = statistic == "partial_sum" and self.eta > 0.5
301        if delay is not None and not renyi:
302            raise ValueError("delay applies only to partial sums with eta > 0.5")
303        self.delay = (
304            (
305                max(1, int(np.log(self.horizon)))
306                if delay is None
307                else positive_int(delay, "delay")
308            )
309            if renyi
310            else 1
311        )
312        if self.delay > self.horizon:
313            raise ValueError("delay cannot exceed the monitoring horizon")
314        if critical_value is None:
315            if statistic == "partial_sum" and self.eta != 0.5:
316                raise ValueError(
317                    "critical_value is required for eta != 0.5; supply a calibrated "
318                    "Brownian or finite-horizon Gaussian reference quantile"
319                )
320            self.critical_value = float(-np.log(-np.log1p(-self.alpha)))
321            self.calibration = "asymptotic-gumbel"
322        else:
323            self.critical_value = finite_scalar(critical_value, "critical_value")
324            if (
325                statistic == "partial_sum"
326                and self.eta != 0.5
327                and self.critical_value <= 0
328            ):
329                raise ValueError(
330                    "Brownian-scale critical_value must be strictly positive"
331                )
332            self.calibration = "caller-supplied"
333        self._configure_normalizers()
334        self._window = training.copy()
335        self._rng = (
336            deepcopy(random_state)
337            if isinstance(random_state, np.random.Generator)
338            else np.random.default_rng(random_state)
339        )
340        self._steps = []
341        self._sum = (0, -np.inf)
342        # Reject a zero-trace training sample before an unusable monitor is
343        # handed back. No random draw or monitoring observation is consumed.
344        self._window_eigenvalue(self._window)
mode
statistic
training_length
shape
rank
projection_rank
horizon
power
epsilon
delta
eta
alpha
delay
n_processed
360    @property
361    def n_processed(self):
362        """Number of consumed post-training observations."""
363        return len(self._steps)

Number of consumed post-training observations.

steps
365    @property
366    def steps(self):
367        """Immutable sequence of accepted monitoring diagnostics."""
368        return tuple(self._steps)

Immutable sequence of accepted monitoring diagnostics.

alarm_step
370    @property
371    def alarm_step(self):
372        """First alarm's one-based monitoring index, or None."""
373        return (
374            self._steps[-1].monitoring_index
375            if self._steps and self._steps[-1].alarm
376            else None
377        )

First alarm's one-based monitoring index, or None.

alarm_observation
379    @property
380    def alarm_observation(self):
381        """First alarm's one-based index in training + monitoring, or None."""
382        return (
383            self.training_length + self.alarm_step
384            if self.alarm_step is not None
385            else None
386        )

First alarm's one-based index in training + monitoring, or None.

stop_reason
388    @property
389    def stop_reason(self):
390        """'alarm', 'horizon', or None while monitoring remains active."""
391        if self.alarm_step is not None:
392            return "alarm"
393        return "horizon" if self.n_processed == self.horizon else None

'alarm', 'horizon', or None while monitoring remains active.

is_stopped
395    @property
396    def is_stopped(self):
397        return self.stop_reason is not None
window
399    @property
400    def window(self):
401        """Detached copy of the current chronological trailing window."""
402        return self._window.copy()

Detached copy of the current chronological trailing window.

def update(self, matrix, *, noise=None):
445    def update(self, matrix, *, noise=None):
446        """Consume one matrix; rejected inputs do not advance the state or RNG.
447
448        ``noise`` optionally supplies the standardized Gaussian draw. It is
449        intended for reproducible external randomization and equation oracles;
450        supplying deterministic/non-Gaussian values forfeits Gaussian reference
451        calibration. An explicit value does not consume the monitor's RNG.
452
453        References
454        ----------
455        He, Y., Kong, X., Trapani, L. and Yu, L. (2024).
456        Online Change-Point Detection for Matrix-Valued Time Series with
457        Latent Two-Way Factor Structure. Annals of Statistics, 52, 1646-1670.
458        https://doi.org/10.1214/24-AOS2410
459        Uses the accepted-manuscript power transformation and equations
460        (3.9)-(3.20), (3.28)-(3.32), not the earlier arXiv v1 transformation.
461        State management is a software interface, not an additional estimator.
462        """
463        if self.is_stopped:
464            raise RuntimeError(f"monitoring already stopped: {self.stop_reason}")
465        raw = np.asarray(matrix)
466        if raw.ndim != 2 or raw.shape != self.shape:
467            raise ValueError("matrix dimensions must match the training observations")
468        observation = as_series(raw[None], min_samples=1)[0]
469        supplied = None if noise is None else finite_scalar(noise, "noise")
470        candidate = np.concatenate((self._window[1:], observation[None]))
471        scale, eigenvalue, trace, normalized, log_drift = self._window_eigenvalue(
472            candidate
473        )
474        # Nothing below can fail for validated real scalars. Draw only after
475        # the complete data-dependent calculation has succeeded.
476        gaussian = float(self._rng.standard_normal()) if supplied is None else supplied
477        drift_log = (0, -np.inf) if log_drift == -np.inf else (1, log_drift)
478        randomized = _add_logs(drift_log, _signed_log(gaussian))
479        partial_sum = _add_logs(self._sum, randomized)
480        index = self.n_processed + 1
481        score = self._score(randomized, partial_sum, index)
482        eligible = index >= self.delay
483        alarm = bool(eligible and _greater(score, _signed_log(self.critical_value)))
484        displayed = [
485            _display(value) for value in (drift_log, randomized, partial_sum, score)
486        ]
487        result = MonitorStep(
488            monitoring_index=index,
489            observation_index=self.training_length + index,
490            window_start_index=index + 1,
491            data_scale=scale,
492            eigenvalue_scaled=eigenvalue,
493            trace_scaled=trace,
494            normalized_eigenvalue=normalized,
495            log_drift=log_drift,
496            drift=displayed[0][0],
497            noise=gaussian,
498            randomized_value=displayed[1][0],
499            partial_sum=displayed[2][0],
500            statistic=displayed[3][0],
501            critical_value=self.critical_value,
502            eligible=eligible,
503            alarm=alarm,
504            saturated=any(item[1] for item in displayed),
505        )
506        self._window = candidate
507        self._sum = partial_sum
508        self._steps.append(result)
509        return result

Consume one matrix; rejected inputs do not advance the state or RNG.

noise optionally supplies the standardized Gaussian draw. It is intended for reproducible external randomization and equation oracles; supplying deterministic/non-Gaussian values forfeits Gaussian reference calibration. An explicit value does not consume the monitor's RNG.

References

He, Y., Kong, X., Trapani, L. and Yu, L. (2024). Online Change-Point Detection for Matrix-Valued Time Series with Latent Two-Way Factor Structure. Annals of Statistics, 52, 1646-1670. https://doi.org/10.1214/24-AOS2410 Uses the accepted-manuscript power transformation and equations (3.9)-(3.20), (3.28)-(3.32), not the earlier arXiv v1 transformation. State management is a software interface, not an additional estimator.

def update_many(self, observations, *, noise=None):
511    def update_many(self, observations, *, noise=None):
512        """Consume an array's prefix until the first alarm or horizon exhaustion.
513
514        The return tuple contains only consumed observations. Validation is
515        per observation, matching successive update calls: a bad later value
516        does not roll back earlier updates, and values after stopping are not
517        inspected. An empty batch or an already stopped monitor returns ().
518
519        References
520        ----------
521        He, Y., Kong, X., Trapani, L. and Yu, L. (2024).
522        Online Change-Point Detection for Matrix-Valued Time Series with
523        Latent Two-Way Factor Structure. Annals of Statistics, 52, 1646-1670.
524        https://doi.org/10.1214/24-AOS2410
525        Uses the accepted-manuscript power transformation and equations
526        (3.9)-(3.20), (3.28)-(3.32), not the earlier arXiv v1 transformation.
527        State management is a software interface, not an additional estimator.
528        """
529        if self.is_stopped:
530            return ()
531        batch = np.asarray(observations)
532        if batch.ndim != 3 or batch.shape[1:] != self.shape:
533            raise ValueError(
534                "observations must be a time-first array matching training"
535            )
536        if noise is not None:
537            draws = np.asarray(noise)
538            if draws.ndim != 1 or len(draws) != len(batch):
539                raise ValueError("noise must have one entry per supplied observation")
540        results = []
541        for index, matrix in enumerate(batch):
542            if self.is_stopped:
543                break
544            results.append(
545                self.update(matrix, noise=None if noise is None else draws[index])
546            )
547        return tuple(results)

Consume an array's prefix until the first alarm or horizon exhaustion.

The return tuple contains only consumed observations. Validation is per observation, matching successive update calls: a bad later value does not roll back earlier updates, and values after stopping are not inspected. An empty batch or an already stopped monitor returns ().

References

He, Y., Kong, X., Trapani, L. and Yu, L. (2024). Online Change-Point Detection for Matrix-Valued Time Series with Latent Two-Way Factor Structure. Annals of Statistics, 52, 1646-1670. https://doi.org/10.1214/24-AOS2410 Uses the accepted-manuscript power transformation and equations (3.9)-(3.20), (3.28)-(3.32), not the earlier arXiv v1 transformation. State management is a software interface, not an additional estimator.

def state_dict(self):
549    def state_dict(self):
550        """Return detached, JSON-compatible state for exact continuation.
551
552        Includes the trailing window, all diagnostics, configuration and local
553        bit-generator state. No executable objects or pickle are needed.
554        Infinite log drift is encoded as a string so strict JSON is supported.
555        """
556        configuration = dict(
557            rank=self.rank,
558            projection_rank=self.projection_rank,
559            horizon=self.horizon,
560            mode=self.mode,
561            power=self.power,
562            epsilon=self.epsilon,
563            statistic=self.statistic,
564            eta=self.eta,
565            delay=(
566                self.delay
567                if self.statistic == "partial_sum" and self.eta > 0.5
568                else None
569            ),
570            alpha=self.alpha,
571            critical_value=self.critical_value,
572        )
573        return _encode(
574            dict(
575                schema_version=1,
576                configuration=configuration,
577                calibration=self.calibration,
578                window=self._window,
579                steps=[asdict(step) for step in self._steps],
580                rng_state=deepcopy(self._rng.bit_generator.state),
581            )
582        )

Return detached, JSON-compatible state for exact continuation.

Includes the trailing window, all diagnostics, configuration and local bit-generator state. No executable objects or pickle are needed. Infinite log drift is encoded as a string so strict JSON is supported.

@classmethod
def from_state(cls, state):
584    @classmethod
585    def from_state(cls, state):
586        """Restore a state_dict snapshot without consuming observations or draws.
587
588        Snapshots are checked for format and basic consistency. They should
589        come from this implementation; this is not a validator of scientific
590        provenance for manually constructed or modified histories.
591        """
592        try:
593            state = deepcopy(state)
594            if not isinstance(state, dict) or state["schema_version"] != 1:
595                raise ValueError("unsupported monitoring state schema")
596            rng_state = state["rng_state"]
597            kind = rng_state["bit_generator"]
598            if kind not in ("PCG64", "PCG64DXSM", "MT19937", "Philox", "SFC64"):
599                raise ValueError("unsupported random bit generator in state")
600            bit_generator = getattr(np.random, kind)()
601            bit_generator.state = rng_state
602            result = cls(
603                state["window"],
604                **state["configuration"],
605                random_state=np.random.Generator(bit_generator),
606            )
607            if state["calibration"] not in ("asymptotic-gumbel", "caller-supplied"):
608                raise ValueError("unknown critical-value calibration in state")
609            result.calibration = state["calibration"]
610            steps = state["steps"]
611            if not isinstance(steps, list) or len(steps) > result.horizon:
612                raise ValueError("inconsistent number of monitoring steps")
613            for index, entry in enumerate(steps, start=1):
614                if result.is_stopped:
615                    raise ValueError("state has observations after monitoring stopped")
616                if entry["log_drift"] == "-Infinity":
617                    entry["log_drift"] = -np.inf
618                elif entry["log_drift"] == "Infinity":
619                    entry["log_drift"] = np.inf
620                step = MonitorStep(**entry)
621                if (
622                    step.monitoring_index != index
623                    or step.observation_index != result.training_length + index
624                    or step.window_start_index != index + 1
625                ):
626                    raise ValueError("inconsistent monitoring indices in state")
627                if np.isnan(step.log_drift) or not np.isfinite(step.noise):
628                    raise ValueError("invalid randomization history in state")
629                numeric_fields = (
630                    step.data_scale,
631                    step.eigenvalue_scaled,
632                    step.trace_scaled,
633                    step.normalized_eigenvalue,
634                    step.drift,
635                    step.randomized_value,
636                    step.partial_sum,
637                    step.statistic,
638                    step.critical_value,
639                )
640                if (
641                    not all(np.isfinite(value) for value in numeric_fields)
642                    or step.data_scale <= 0
643                    or step.trace_scaled <= 0
644                    or step.eigenvalue_scaled < 0
645                    or step.normalized_eigenvalue < 0
646                    or step.drift < 0
647                    or step.critical_value != result.critical_value
648                    or not all(
649                        isinstance(value, bool)
650                        for value in (step.eligible, step.alarm, step.saturated)
651                    )
652                ):
653                    raise ValueError("invalid monitoring diagnostics in state")
654                drift = (
655                    (0, -np.inf) if step.log_drift == -np.inf else (1, step.log_drift)
656                )
657                randomized = _add_logs(drift, _signed_log(step.noise))
658                result._sum = _add_logs(result._sum, randomized)
659                expected_alarm = index >= result.delay and _greater(
660                    result._score(randomized, result._sum, index),
661                    _signed_log(result.critical_value),
662                )
663                if (
664                    step.eligible != (index >= result.delay)
665                    or step.alarm != expected_alarm
666                ):
667                    raise ValueError("inconsistent stopping decision in state")
668                result._steps.append(step)
669            return result
670        except (KeyError, TypeError, AttributeError, OverflowError) as exc:
671            raise ValueError("invalid monitoring state") from exc

Restore a state_dict snapshot without consuming observations or draws.

Snapshots are checked for format and basic consistency. They should come from this implementation; this is not a validator of scientific provenance for manually constructed or modified histories.

@dataclass(frozen=True)
class MonitorStep:
103@dataclass(frozen=True)
104class MonitorStep:
105    """One consumed observation and its complete monitoring diagnostics.
106
107    Indices are one-based: ``observation_index = training_length + monitoring_index``.
108    ``eigenvalue_scaled`` and ``trace_scaled`` are computed after dividing the
109    current window by ``data_scale``; multiplying by its square restores data
110    units when representable. All other statistics are invariant to global
111    nonzero scaling of observations.
112
113    ``statistic`` is compared strictly with ``critical_value``, on the scale
114    specified by ``MatrixFactorMonitor``. Before a Renyi delay, it is recorded
115    but not eligible to trigger an alarm. ``log_drift`` can be -inf for exactly
116    zero drift or +inf for an unrepresentably large logarithm. Other numeric
117    diagnostics are finite. If ``saturated`` is true, an under/overflowed
118    diagnostic has been clipped; the crossing decision still uses signed-log
119    arithmetic, not the clipped value.
120
121    References
122    ----------
123    He, Y., Kong, X., Trapani, L. and Yu, L. (2024).
124    Online Change-Point Detection for Matrix-Valued Time Series with
125    Latent Two-Way Factor Structure. Annals of Statistics, 52, 1646-1670.
126    https://doi.org/10.1214/24-AOS2410
127    Uses the accepted-manuscript power transformation and equations
128    (3.9)-(3.20), (3.28)-(3.32), not the earlier arXiv v1 transformation.
129    State management is a software interface, not an additional estimator.
130    """
131
132    monitoring_index: int
133    observation_index: int
134    window_start_index: int
135    data_scale: float
136    eigenvalue_scaled: float
137    trace_scaled: float
138    normalized_eigenvalue: float
139    log_drift: float
140    drift: float
141    noise: float
142    randomized_value: float
143    partial_sum: float
144    statistic: float
145    critical_value: float
146    eligible: bool
147    alarm: bool
148    saturated: bool

One consumed observation and its complete monitoring diagnostics.

Indices are one-based: observation_index = training_length + monitoring_index. eigenvalue_scaled and trace_scaled are computed after dividing the current window by data_scale; multiplying by its square restores data units when representable. All other statistics are invariant to global nonzero scaling of observations.

statistic is compared strictly with critical_value, on the scale specified by MatrixFactorMonitor. Before a Renyi delay, it is recorded but not eligible to trigger an alarm. log_drift can be -inf for exactly zero drift or +inf for an unrepresentably large logarithm. Other numeric diagnostics are finite. If saturated is true, an under/overflowed diagnostic has been clipped; the crossing decision still uses signed-log arithmetic, not the clipped value.

References

He, Y., Kong, X., Trapani, L. and Yu, L. (2024). Online Change-Point Detection for Matrix-Valued Time Series with Latent Two-Way Factor Structure. Annals of Statistics, 52, 1646-1670. https://doi.org/10.1214/24-AOS2410 Uses the accepted-manuscript power transformation and equations (3.9)-(3.20), (3.28)-(3.32), not the earlier arXiv v1 transformation. State management is a software interface, not an additional estimator.

MonitorStep( monitoring_index: int, observation_index: int, window_start_index: int, data_scale: float, eigenvalue_scaled: float, trace_scaled: float, normalized_eigenvalue: float, log_drift: float, drift: float, noise: float, randomized_value: float, partial_sum: float, statistic: float, critical_value: float, eligible: bool, alarm: bool, saturated: bool)
monitoring_index: int
observation_index: int
window_start_index: int
data_scale: float
eigenvalue_scaled: float
trace_scaled: float
normalized_eigenvalue: float
log_drift: float
drift: float
noise: float
randomized_value: float
partial_sum: float
statistic: float
critical_value: float
eligible: bool
alarm: bool
saturated: bool
@dataclass(frozen=True)
class MonitorCalibration:
28@dataclass(frozen=True)
29class MonitorCalibration:
30    """A critical value on the monitor's transformed-statistic scale.
31
32    ``quantile_interval`` is a Monte Carlo confidence interval for the true
33    Gaussian-reference quantile, not a confidence interval for matrix-data
34    false-alarm probability. Infinite endpoints indicate insufficient tail
35    replications. No continuous-time Brownian supremum is approximated here:
36    the discrete observation times and finite horizon are the target.
37
38    References
39    ----------
40    He, Y., Kong, X., Trapani, L. and Yu, L. (2024).
41    Online Change-Point Detection for Matrix-Valued Time Series with
42    Latent Two-Way Factor Structure. Annals of Statistics, 52, 1646-1670.
43    https://doi.org/10.1214/24-AOS2410
44    This paper supplies the monitoring statistic, NOT the finite-horizon
45    calibration implemented here. Exact Gaussian maxima and simulated
46    zero-drift path quantiles are explicit package extensions; they do
47    not establish finite-sample matrix-data false-alarm control.
48    """
49
50    critical_value: float
51    alpha: float
52    horizon: int
53    statistic: str
54    eta: float
55    delay: int | None
56    method: str
57    replications: int
58    confidence: float
59    quantile_interval: tuple
60    order_index: int | None
61
62    @property
63    def monitor_kwargs(self):
64        """Constructor options for a monitor using exactly this calibration."""
65        return dict(
66            horizon=self.horizon,
67            statistic=self.statistic,
68            eta=self.eta,
69            delay=self.delay,
70            alpha=self.alpha,
71            critical_value=self.critical_value,
72        )

A critical value on the monitor's transformed-statistic scale.

quantile_interval is a Monte Carlo confidence interval for the true Gaussian-reference quantile, not a confidence interval for matrix-data false-alarm probability. Infinite endpoints indicate insufficient tail replications. No continuous-time Brownian supremum is approximated here: the discrete observation times and finite horizon are the target.

References

He, Y., Kong, X., Trapani, L. and Yu, L. (2024). Online Change-Point Detection for Matrix-Valued Time Series with Latent Two-Way Factor Structure. Annals of Statistics, 52, 1646-1670. https://doi.org/10.1214/24-AOS2410 This paper supplies the monitoring statistic, NOT the finite-horizon calibration implemented here. Exact Gaussian maxima and simulated zero-drift path quantiles are explicit package extensions; they do not establish finite-sample matrix-data false-alarm control.

MonitorCalibration( critical_value: float, alpha: float, horizon: int, statistic: str, eta: float, delay: int | None, method: str, replications: int, confidence: float, quantile_interval: tuple, order_index: int | None)
critical_value: float
alpha: float
horizon: int
statistic: str
eta: float
delay: int | None
method: str
replications: int
confidence: float
quantile_interval: tuple
order_index: int | None
monitor_kwargs
62    @property
63    def monitor_kwargs(self):
64        """Constructor options for a monitor using exactly this calibration."""
65        return dict(
66            horizon=self.horizon,
67            statistic=self.statistic,
68            eta=self.eta,
69            delay=self.delay,
70            alpha=self.alpha,
71            critical_value=self.critical_value,
72        )

Constructor options for a monitor using exactly this calibration.

def calibrate_monitor( horizon, *, statistic='maximum', eta=0.25, delay=None, alpha=0.05, replications=20000, confidence=0.95, random_state=None, batch_size=256):
 96def calibrate_monitor(
 97    horizon,
 98    *,
 99    statistic="maximum",
100    eta=0.25,
101    delay=None,
102    alpha=0.05,
103    replications=20000,
104    confidence=0.95,
105    random_state=None,
106    batch_size=256,
107):
108    """Calibrate a finite-horizon zero-drift iid N(0,1) reference monitor.
109
110    Maximum monitoring uses the exact Gaussian maximum quantile, expressed
111    on the paper's Gumbel-normalized statistic scale. Partial-sum monitoring
112    simulates whole Gaussian paths and records each path's maximum statistic.
113    It covers eta<1/2, eta=1/2 and delayed Renyi eta>1/2. Pass the result's
114    ``monitor_kwargs`` to ``MatrixFactorMonitor``.
115
116    The simulated critical value is order statistic
117    ceil((replications+1)*(1-alpha)). For a fresh independent Gaussian path,
118    this gives an unconditional exceedance probability at most alpha, averaging
119    over the calibration simulation too. It is not conditional exact control
120    for the realized critical value. Binomial order-statistic bounds quantify
121    its Monte Carlo uncertainty. Replications too few to give a finite
122    conservative critical value are rejected. Reuse one calibration for a
123    fixed design, never tune it to observed monitoring paths or alarm outcomes.
124
125    Matrix monitoring adds a data-dependent nonnegative drift. Its actual
126    false-alarm probability can differ even under an intended no-break model.
127    Multiple monitors, modes or restarts require separate multiplicity control.
128    Memory is bounded by at most one million Gaussian draws per batch.
129    Seed reproducibility is invariant to ``batch_size``.
130
131    References
132    ----------
133    He, Y., Kong, X., Trapani, L. and Yu, L. (2024).
134    Online Change-Point Detection for Matrix-Valued Time Series with
135    Latent Two-Way Factor Structure. Annals of Statistics, 52, 1646-1670.
136    https://doi.org/10.1214/24-AOS2410
137    This paper supplies the monitoring statistic, NOT the finite-horizon
138    calibration implemented here. Exact Gaussian maxima and simulated
139    zero-drift path quantiles are explicit package extensions; they do
140    not establish finite-sample matrix-data false-alarm control.
141    """
142    horizon = positive_int(horizon, "horizon")
143    eta = finite_scalar(eta, "eta", minimum=0)
144    if eta >= 1:
145        raise ValueError("eta must lie in [0,1)")
146    alpha = finite_scalar(alpha, "alpha")
147    confidence = finite_scalar(confidence, "confidence")
148    if not 0 < alpha < 1 or not 0 < confidence < 1:
149        raise ValueError("alpha and confidence must lie strictly between zero and one")
150    if statistic not in ("maximum", "partial_sum"):
151        raise ValueError("statistic must be 'maximum' or 'partial_sum'")
152    if statistic == "maximum" and horizon < 2:
153        raise ValueError("maximum monitoring requires horizon >= 2")
154    if horizon > 1000000:
155        raise ValueError(
156            "horizon exceeds the one-million-step calibration allocation limit"
157        )
158    if statistic == "partial_sum" and eta == 0.5 and horizon < 3:
159        raise ValueError("eta=.5 requires horizon >= 3")
160    renyi = statistic == "partial_sum" and eta > 0.5
161    if delay is not None and not renyi:
162        raise ValueError("delay applies only to partial sums with eta > .5")
163    delay = (
164        (
165            max(1, int(np.floor(np.log(horizon))))
166            if delay is None
167            else positive_int(delay, "delay")
168        )
169        if renyi
170        else None
171    )
172    if delay is not None and delay > horizon:
173        raise ValueError("delay cannot exceed horizon")
174    replications = positive_int(replications, "replications", minimum=2)
175    batch_size = positive_int(batch_size, "batch_size")
176    if statistic == "maximum":
177        # Avoid forming 1-(1-alpha)**(1/H), which loses tiny tail probabilities.
178        tail = -np.expm1(np.log1p(-alpha) / horizon)
179        a, b = _maximum_normalizers(horizon)
180        critical = float((norm.isf(tail) - b) / a)
181        if not np.isfinite(critical):
182            raise ValueError(
183                "alpha is too extreme for a finite floating-point boundary"
184            )
185        return MonitorCalibration(
186            critical,
187            alpha,
188            horizon,
189            statistic,
190            eta,
191            delay,
192            "exact-gaussian-maximum",
193            0,
194            confidence,
195            (critical, critical),
196            None,
197        )
198    order = int(np.ceil((replications + 1) * (1 - alpha)))
199    if order > replications:
200        raise ValueError(
201            "replications are too few for a finite conservative alpha quantile"
202        )
203    rng = random_generator(random_state)
204    values = np.empty(replications)
205    batch_size = min(batch_size, max(1, 1000000 // horizon))
206    for start in range(0, replications, batch_size):
207        stop = min(start + batch_size, replications)
208        draws = rng.normal(size=(stop - start, horizon))
209        values[start:stop] = _reference_supremum(draws, eta, delay)
210    values.sort()
211    tail = (1 - confidence) / 2
212    lower = int(binom.ppf(tail, replications, 1 - alpha))
213    upper = int(binom.ppf(1 - tail, replications, 1 - alpha)) + 1
214    interval = (
215        float(values[lower - 1]) if lower else -np.inf,
216        float(values[upper - 1]) if upper <= replications else np.inf,
217    )
218    return MonitorCalibration(
219        float(values[order - 1]),
220        alpha,
221        horizon,
222        statistic,
223        eta,
224        delay,
225        "monte-carlo-gaussian-paths",
226        replications,
227        confidence,
228        interval,
229        order,
230    )

Calibrate a finite-horizon zero-drift iid N(0,1) reference monitor.

Maximum monitoring uses the exact Gaussian maximum quantile, expressed on the paper's Gumbel-normalized statistic scale. Partial-sum monitoring simulates whole Gaussian paths and records each path's maximum statistic. It covers eta<1/2, eta=1/2 and delayed Renyi eta>1/2. Pass the result's monitor_kwargs to MatrixFactorMonitor.

The simulated critical value is order statistic ceil((replications+1)*(1-alpha)). For a fresh independent Gaussian path, this gives an unconditional exceedance probability at most alpha, averaging over the calibration simulation too. It is not conditional exact control for the realized critical value. Binomial order-statistic bounds quantify its Monte Carlo uncertainty. Replications too few to give a finite conservative critical value are rejected. Reuse one calibration for a fixed design, never tune it to observed monitoring paths or alarm outcomes.

Matrix monitoring adds a data-dependent nonnegative drift. Its actual false-alarm probability can differ even under an intended no-break model. Multiple monitors, modes or restarts require separate multiplicity control. Memory is bounded by at most one million Gaussian draws per batch. Seed reproducibility is invariant to batch_size.

References

He, Y., Kong, X., Trapani, L. and Yu, L. (2024). Online Change-Point Detection for Matrix-Valued Time Series with Latent Two-Way Factor Structure. Annals of Statistics, 52, 1646-1670. https://doi.org/10.1214/24-AOS2410 This paper supplies the monitoring statistic, NOT the finite-horizon calibration implemented here. Exact Gaussian maxima and simulated zero-drift path quantiles are explicit package extensions; they do not establish finite-sample matrix-data false-alarm control.

@dataclass
class TwoWayDynamicResult:
304@dataclass
305class TwoWayDynamicResult:
306    """Additive factor estimates and pooled diagonal AR dynamics.
307
308    Public ranks/orders use (row, column) order: paper (c,r)/(q,p).
309    F has shape (T,n,r), G (T,m,c); signal = mean + F L.T + Lambda G.T.
310    Covariances/innovation variances and scores use physical data units.
311    Objective histories and rank spectra use X/data_scale units. Add
312    -2*n*m*log(data_scale) to objective history for the physical quasi-
313    likelihood. This objective omits fixed constants and is not the joint
314    temporal likelihood. History tolerances do not certify global optima.
315
316    References
317    ----------
318    Yuan, C., Gao, Z., He, X., Huang, W. and Guo, J. (2023).
319    Two-Way Dynamic Factor Models for High-Dimensional Matrix-Valued
320    Time Series. JRSS B, 85, 1517-1537.
321    https://doi.org/10.1093/jrsssb/qkad077
322    Algorithms 1/2 and supplement Algorithm A1; centering and numerical
323    safeguards are implementation extensions described in the module notes.
324    """
325
326    row_loadings: np.ndarray
327    column_loadings: np.ndarray
328    F: np.ndarray
329    G: np.ndarray
330    signal: np.ndarray
331    residuals: np.ndarray
332    mean: np.ndarray
333    row_factor_covariance: np.ndarray
334    column_factor_covariance: np.ndarray
335    noise_variance: float
336    row_ar: np.ndarray
337    column_ar: np.ndarray
338    row_innovation_variance: np.ndarray
339    column_innovation_variance: np.ndarray
340    objective_history: np.ndarray
341    em_objective_histories: tuple
342    inner_iterations: np.ndarray
343    inner_converged: np.ndarray
344    rank_history: np.ndarray
345    rank_spectra: tuple | None
346    rank_converged: bool
347    rank_cycle: bool
348    initialization_history: np.ndarray
349    converged: bool
350    n_iter: int
351    variance_regularized: bool
352    data_scale: float
353    _psi_f: np.ndarray = field(repr=False)
354    _psi_g: np.ndarray = field(repr=False)
355    _noise: float = field(repr=False)
356
357    @property
358    def ranks(self):
359        return (self.row_loadings.shape[1], self.column_loadings.shape[1])
360
361    @property
362    def orders(self):
363        return (len(self.row_ar), len(self.column_ar))
364
365    @property
366    def loadings(self):
367        return (self.row_loadings, self.column_loadings)
368
369    @property
370    def is_stable(self):
371        """Check stability of every fitted pooled scalar autoregression.
372
373        References
374        ----------
375        Yuan, C., Gao, Z., He, X., Huang, W. and Guo, J. (2023).
376        Two-Way Dynamic Factor Models for High-Dimensional Matrix-Valued
377        Time Series. JRSS B, 85, 1517-1537.
378        https://doi.org/10.1093/jrsssb/qkad077
379        Algorithms 1/2 and supplement Algorithm A1; centering and numerical
380        safeguards are implementation extensions described in the module notes.
381        """
382        return bool(
383            np.all(_ar_radii(self.row_ar) < 1) and np.all(_ar_radii(self.column_ar) < 1)
384        )
385
386    def transform(self, X):
387        """Contemporaneous conditional F/G scores using training parameters.
388
389        These use only each supplied matrix, not a Kalman smoother or data
390        from the future. F/G represent deviations from the training mean.
391
392        References
393        ----------
394        Yuan, C., Gao, Z., He, X., Huang, W. and Guo, J. (2023).
395        Two-Way Dynamic Factor Models for High-Dimensional Matrix-Valued
396        Time Series. JRSS B, 85, 1517-1537.
397        https://doi.org/10.1093/jrsssb/qkad077
398        Algorithms 1/2 and supplement Algorithm A1; centering and numerical
399        safeguards are implementation extensions described in the module notes.
400        """
401        X = as_series(X, min_samples=1)
402        if X.shape[1:] != self.mean.shape:
403            raise ValueError("X spatial dimensions must match the fitted model")
404        Y = X / self.data_scale - self.mean / self.data_scale
405        F, G = _scores(
406            Y,
407            self.column_loadings,
408            self.row_loadings,
409            self._psi_f,
410            self._psi_g,
411            self._noise,
412        )
413        return F * self.data_scale, G * self.data_scale
414
415    def inverse_transform(self, F, G):
416        """Reconstruct the additive signal from F and G scores.
417
418        References
419        ----------
420        Yuan, C., Gao, Z., He, X., Huang, W. and Guo, J. (2023).
421        Two-Way Dynamic Factor Models for High-Dimensional Matrix-Valued
422        Time Series. JRSS B, 85, 1517-1537.
423        https://doi.org/10.1093/jrsssb/qkad077
424        Algorithms 1/2 and supplement Algorithm A1; centering and numerical
425        safeguards are implementation extensions described in the module notes.
426        """
427        F, G = as_series(F, min_samples=1), as_series(G, min_samples=1)
428        n, m = self.mean.shape
429        c, r = self.ranks
430        if F.shape[1:] != (n, r) or G.shape != (len(F), m, c):
431            raise ValueError("F and G score dimensions must match the fitted model")
432        return (
433            self.mean
434            + F @ self.column_loadings.T
435            + self.row_loadings @ G.transpose(0, 2, 1)
436        )
437
438    def reconstruct(self, X):
439        """Reconstruct contemporaneous additive signals with fitted parameters.
440
441        References
442        ----------
443        Yuan, C., Gao, Z., He, X., Huang, W. and Guo, J. (2023).
444        Two-Way Dynamic Factor Models for High-Dimensional Matrix-Valued
445        Time Series. JRSS B, 85, 1517-1537.
446        https://doi.org/10.1093/jrsssb/qkad077
447        Algorithms 1/2 and supplement Algorithm A1; centering and numerical
448        safeguards are implementation extensions described in the module notes.
449        """
450        return self.inverse_transform(*self.transform(X))
451
452    def forecast(self, steps, history=None):
453        """Recursive plug-in common-component forecasts in observation units.
454
455        Uses the last p/q contemporaneous scores, predicting the residual
456        component as zero. Under serially correlated idiosyncratic errors
457        these need not equal full conditional-mean observation forecasts.
458
459        References
460        ----------
461        Yuan, C., Gao, Z., He, X., Huang, W. and Guo, J. (2023).
462        Two-Way Dynamic Factor Models for High-Dimensional Matrix-Valued
463        Time Series. JRSS B, 85, 1517-1537.
464        https://doi.org/10.1093/jrsssb/qkad077
465        Algorithms 1/2 and supplement Algorithm A1; centering and numerical
466        safeguards are implementation extensions described in the module notes.
467        """
468        steps = positive_int(steps, "steps")
469        q, p = self.orders
470        if history is None:
471            F, G = self.F[-p:], self.G[-q:]
472        else:
473            history = as_series(history, min_samples=max(p, q))
474            F, G = self.transform(history)
475        output = []
476        f_history, g_history = list(F[-p:]), list(G[-q:])
477        for _ in range(steps):
478            f = sum(
479                coefficient * f_history[-lag]
480                for lag, coefficient in enumerate(self.column_ar, start=1)
481            )
482            g = sum(
483                coefficient * g_history[-lag]
484                for lag, coefficient in enumerate(self.row_ar, start=1)
485            )
486            prediction = self.inverse_transform(f[None], g[None])[0]
487            if not np.isfinite(prediction).all():
488                raise FloatingPointError(
489                    "two-way dynamic forecast overflow; inspect AR stability"
490                )
491            output.append(prediction)
492            f_history.append(f)
493            g_history.append(g)
494        return np.asarray(output)

Additive factor estimates and pooled diagonal AR dynamics.

Public ranks/orders use (row, column) order: paper (c,r)/(q,p). F has shape (T,n,r), G (T,m,c); signal = mean + F L.T + Lambda G.T. Covariances/innovation variances and scores use physical data units. Objective histories and rank spectra use X/data_scale units. Add -2nm*log(data_scale) to objective history for the physical quasi- likelihood. This objective omits fixed constants and is not the joint temporal likelihood. History tolerances do not certify global optima.

References

Yuan, C., Gao, Z., He, X., Huang, W. and Guo, J. (2023). Two-Way Dynamic Factor Models for High-Dimensional Matrix-Valued Time Series. JRSS B, 85, 1517-1537. https://doi.org/10.1093/jrsssb/qkad077 Algorithms 1/2 and supplement Algorithm A1; centering and numerical safeguards are implementation extensions described in the module notes.

TwoWayDynamicResult( row_loadings: numpy.ndarray, column_loadings: numpy.ndarray, F: numpy.ndarray, G: numpy.ndarray, signal: numpy.ndarray, residuals: numpy.ndarray, mean: numpy.ndarray, row_factor_covariance: numpy.ndarray, column_factor_covariance: numpy.ndarray, noise_variance: float, row_ar: numpy.ndarray, column_ar: numpy.ndarray, row_innovation_variance: numpy.ndarray, column_innovation_variance: numpy.ndarray, objective_history: numpy.ndarray, em_objective_histories: tuple, inner_iterations: numpy.ndarray, inner_converged: numpy.ndarray, rank_history: numpy.ndarray, rank_spectra: tuple | None, rank_converged: bool, rank_cycle: bool, initialization_history: numpy.ndarray, converged: bool, n_iter: int, variance_regularized: bool, data_scale: float, _psi_f: numpy.ndarray, _psi_g: numpy.ndarray, _noise: float)
row_loadings: numpy.ndarray
column_loadings: numpy.ndarray
F: numpy.ndarray
G: numpy.ndarray
signal: numpy.ndarray
residuals: numpy.ndarray
mean: numpy.ndarray
row_factor_covariance: numpy.ndarray
column_factor_covariance: numpy.ndarray
noise_variance: float
row_ar: numpy.ndarray
column_ar: numpy.ndarray
row_innovation_variance: numpy.ndarray
column_innovation_variance: numpy.ndarray
objective_history: numpy.ndarray
em_objective_histories: tuple
inner_iterations: numpy.ndarray
inner_converged: numpy.ndarray
rank_history: numpy.ndarray
rank_spectra: tuple | None
rank_converged: bool
rank_cycle: bool
initialization_history: numpy.ndarray
converged: bool
n_iter: int
variance_regularized: bool
data_scale: float
ranks
357    @property
358    def ranks(self):
359        return (self.row_loadings.shape[1], self.column_loadings.shape[1])
orders
361    @property
362    def orders(self):
363        return (len(self.row_ar), len(self.column_ar))
loadings
365    @property
366    def loadings(self):
367        return (self.row_loadings, self.column_loadings)
is_stable
369    @property
370    def is_stable(self):
371        """Check stability of every fitted pooled scalar autoregression.
372
373        References
374        ----------
375        Yuan, C., Gao, Z., He, X., Huang, W. and Guo, J. (2023).
376        Two-Way Dynamic Factor Models for High-Dimensional Matrix-Valued
377        Time Series. JRSS B, 85, 1517-1537.
378        https://doi.org/10.1093/jrsssb/qkad077
379        Algorithms 1/2 and supplement Algorithm A1; centering and numerical
380        safeguards are implementation extensions described in the module notes.
381        """
382        return bool(
383            np.all(_ar_radii(self.row_ar) < 1) and np.all(_ar_radii(self.column_ar) < 1)
384        )

Check stability of every fitted pooled scalar autoregression.

References

Yuan, C., Gao, Z., He, X., Huang, W. and Guo, J. (2023). Two-Way Dynamic Factor Models for High-Dimensional Matrix-Valued Time Series. JRSS B, 85, 1517-1537. https://doi.org/10.1093/jrsssb/qkad077 Algorithms 1/2 and supplement Algorithm A1; centering and numerical safeguards are implementation extensions described in the module notes.

def transform(self, X):
386    def transform(self, X):
387        """Contemporaneous conditional F/G scores using training parameters.
388
389        These use only each supplied matrix, not a Kalman smoother or data
390        from the future. F/G represent deviations from the training mean.
391
392        References
393        ----------
394        Yuan, C., Gao, Z., He, X., Huang, W. and Guo, J. (2023).
395        Two-Way Dynamic Factor Models for High-Dimensional Matrix-Valued
396        Time Series. JRSS B, 85, 1517-1537.
397        https://doi.org/10.1093/jrsssb/qkad077
398        Algorithms 1/2 and supplement Algorithm A1; centering and numerical
399        safeguards are implementation extensions described in the module notes.
400        """
401        X = as_series(X, min_samples=1)
402        if X.shape[1:] != self.mean.shape:
403            raise ValueError("X spatial dimensions must match the fitted model")
404        Y = X / self.data_scale - self.mean / self.data_scale
405        F, G = _scores(
406            Y,
407            self.column_loadings,
408            self.row_loadings,
409            self._psi_f,
410            self._psi_g,
411            self._noise,
412        )
413        return F * self.data_scale, G * self.data_scale

Contemporaneous conditional F/G scores using training parameters.

These use only each supplied matrix, not a Kalman smoother or data from the future. F/G represent deviations from the training mean.

References

Yuan, C., Gao, Z., He, X., Huang, W. and Guo, J. (2023). Two-Way Dynamic Factor Models for High-Dimensional Matrix-Valued Time Series. JRSS B, 85, 1517-1537. https://doi.org/10.1093/jrsssb/qkad077 Algorithms 1/2 and supplement Algorithm A1; centering and numerical safeguards are implementation extensions described in the module notes.

def inverse_transform(self, F, G):
415    def inverse_transform(self, F, G):
416        """Reconstruct the additive signal from F and G scores.
417
418        References
419        ----------
420        Yuan, C., Gao, Z., He, X., Huang, W. and Guo, J. (2023).
421        Two-Way Dynamic Factor Models for High-Dimensional Matrix-Valued
422        Time Series. JRSS B, 85, 1517-1537.
423        https://doi.org/10.1093/jrsssb/qkad077
424        Algorithms 1/2 and supplement Algorithm A1; centering and numerical
425        safeguards are implementation extensions described in the module notes.
426        """
427        F, G = as_series(F, min_samples=1), as_series(G, min_samples=1)
428        n, m = self.mean.shape
429        c, r = self.ranks
430        if F.shape[1:] != (n, r) or G.shape != (len(F), m, c):
431            raise ValueError("F and G score dimensions must match the fitted model")
432        return (
433            self.mean
434            + F @ self.column_loadings.T
435            + self.row_loadings @ G.transpose(0, 2, 1)
436        )

Reconstruct the additive signal from F and G scores.

References

Yuan, C., Gao, Z., He, X., Huang, W. and Guo, J. (2023). Two-Way Dynamic Factor Models for High-Dimensional Matrix-Valued Time Series. JRSS B, 85, 1517-1537. https://doi.org/10.1093/jrsssb/qkad077 Algorithms 1/2 and supplement Algorithm A1; centering and numerical safeguards are implementation extensions described in the module notes.

def reconstruct(self, X):
438    def reconstruct(self, X):
439        """Reconstruct contemporaneous additive signals with fitted parameters.
440
441        References
442        ----------
443        Yuan, C., Gao, Z., He, X., Huang, W. and Guo, J. (2023).
444        Two-Way Dynamic Factor Models for High-Dimensional Matrix-Valued
445        Time Series. JRSS B, 85, 1517-1537.
446        https://doi.org/10.1093/jrsssb/qkad077
447        Algorithms 1/2 and supplement Algorithm A1; centering and numerical
448        safeguards are implementation extensions described in the module notes.
449        """
450        return self.inverse_transform(*self.transform(X))

Reconstruct contemporaneous additive signals with fitted parameters.

References

Yuan, C., Gao, Z., He, X., Huang, W. and Guo, J. (2023). Two-Way Dynamic Factor Models for High-Dimensional Matrix-Valued Time Series. JRSS B, 85, 1517-1537. https://doi.org/10.1093/jrsssb/qkad077 Algorithms 1/2 and supplement Algorithm A1; centering and numerical safeguards are implementation extensions described in the module notes.

def forecast(self, steps, history=None):
452    def forecast(self, steps, history=None):
453        """Recursive plug-in common-component forecasts in observation units.
454
455        Uses the last p/q contemporaneous scores, predicting the residual
456        component as zero. Under serially correlated idiosyncratic errors
457        these need not equal full conditional-mean observation forecasts.
458
459        References
460        ----------
461        Yuan, C., Gao, Z., He, X., Huang, W. and Guo, J. (2023).
462        Two-Way Dynamic Factor Models for High-Dimensional Matrix-Valued
463        Time Series. JRSS B, 85, 1517-1537.
464        https://doi.org/10.1093/jrsssb/qkad077
465        Algorithms 1/2 and supplement Algorithm A1; centering and numerical
466        safeguards are implementation extensions described in the module notes.
467        """
468        steps = positive_int(steps, "steps")
469        q, p = self.orders
470        if history is None:
471            F, G = self.F[-p:], self.G[-q:]
472        else:
473            history = as_series(history, min_samples=max(p, q))
474            F, G = self.transform(history)
475        output = []
476        f_history, g_history = list(F[-p:]), list(G[-q:])
477        for _ in range(steps):
478            f = sum(
479                coefficient * f_history[-lag]
480                for lag, coefficient in enumerate(self.column_ar, start=1)
481            )
482            g = sum(
483                coefficient * g_history[-lag]
484                for lag, coefficient in enumerate(self.row_ar, start=1)
485            )
486            prediction = self.inverse_transform(f[None], g[None])[0]
487            if not np.isfinite(prediction).all():
488                raise FloatingPointError(
489                    "two-way dynamic forecast overflow; inspect AR stability"
490                )
491            output.append(prediction)
492            f_history.append(f)
493            g_history.append(g)
494        return np.asarray(output)

Recursive plug-in common-component forecasts in observation units.

Uses the last p/q contemporaneous scores, predicting the residual component as zero. Under serially correlated idiosyncratic errors these need not equal full conditional-mean observation forecasts.

References

Yuan, C., Gao, Z., He, X., Huang, W. and Guo, J. (2023). Two-Way Dynamic Factor Models for High-Dimensional Matrix-Valued Time Series. JRSS B, 85, 1517-1537. https://doi.org/10.1093/jrsssb/qkad077 Algorithms 1/2 and supplement Algorithm A1; centering and numerical safeguards are implementation extensions described in the module notes.

def fit_two_way_dynamic( X, ranks=None, *, orders=(1, 1), rank_bounds=None, ratio_ridge=0.01, center=False, max_iter=100, tol=1e-07, em_max_iter=100, loading_max_iter=100, rank_max_iter=20, variance_floor=1e-10, initial=None):
497def fit_two_way_dynamic(
498    X,
499    ranks=None,
500    *,
501    orders=(1, 1),
502    rank_bounds=None,
503    ratio_ridge=0.01,
504    center=False,
505    max_iter=100,
506    tol=1e-7,
507    em_max_iter=100,
508    loading_max_iter=100,
509    rank_max_iter=20,
510    variance_floor=1e-10,
511    initial=None,
512):
513    """Fit Yuan et al.'s additive two-way dynamic factor model.
514
515    X is time-first (T,n,m); ranks=(c,r) and orders=(q,p) follow spatial
516    (row,column) order. Both ranks are positive and strictly below their
517    corresponding dimensions. If ranks=None, Algorithm 2 selects them
518    within rank_bounds (default half each dimension), with denominator
519    addition ratio_ridge*max(n**-.5,m**-.5,T**-.5) in original data units.
520    This selects among positive ranks; it is not a no-factor test.
521
522    Algorithm A1 provides initialization; Algorithm 1 combines heterogeneous
523    quadratic loading updates and full latent-moment covariance EM. The
524    final factor scores are conditional Gaussian linear predictors under
525    the working additive covariance, followed by pooled scalar AR fits.
526    Each AR coefficient is shared across units within its factor column.
527
528    center=False uses the paper's zero-mean model. center=True estimates
529    and removes a training mean (an explicit extension). initial optionally
530    supplies (row_loadings,column_loadings) with L.T@L=dimension*I.
531    variance_floor is a relative lower bound on all covariance eigenvalues
532    and noise variance, as a fraction of mean(Y**2) after centering; this
533    computational safeguard restricts the parameter space and is reported.
534    It may be zero to disable regularization on nondegenerate data.
535
536    max_iter, em_max_iter and loading_max_iter bound outer and inner loops;
537    tol is a relative objective-increment tolerance. Inner loops use tol/10.
538    All convergence/rank diagnostics are retained. Stability is diagnosed,
539    not enforced. This is a local quasi-likelihood solution, not a global
540    optimization certificate or a fit of the full temporal likelihood.
541
542    References
543    ----------
544    Yuan, C., Gao, Z., He, X., Huang, W. and Guo, J. (2023).
545    Two-Way Dynamic Factor Models for High-Dimensional Matrix-Valued
546    Time Series. JRSS B, 85, 1517-1537.
547    https://doi.org/10.1093/jrsssb/qkad077
548    Algorithms 1/2 and supplement Algorithm A1; centering and numerical
549    safeguards are implementation extensions described in the module notes.
550    """
551    X = as_series(X, min_samples=3)
552    if not isinstance(center, (bool, np.bool_)):
553        raise ValueError("center must be boolean")
554    T, n, m = X.shape
555    if min(n, m) < 2:
556        raise ValueError("both spatial dimensions must exceed one")
557    orders = _pair(orders, "orders")
558    if max(orders) >= T - 1:
559        raise ValueError("orders must leave at least two response observations")
560    max_iter = positive_int(max_iter, "max_iter")
561    em_max_iter = positive_int(em_max_iter, "em_max_iter")
562    loading_max_iter = positive_int(loading_max_iter, "loading_max_iter")
563    rank_max_iter = positive_int(rank_max_iter, "rank_max_iter")
564    tol = _positive(tol, "tol")
565    ratio_ridge = _positive(ratio_ridge, "ratio_ridge")
566    variance_floor = finite_scalar(variance_floor, "variance_floor", minimum=0)
567    scale = float(np.max(np.abs(X)))
568    if scale == 0:
569        raise ValueError("zero data have no positive innovation scale")
570    Y = X / scale
571    mean = Y.mean(axis=0) if center else np.zeros((n, m))
572    Y = Y - mean
573    energy = float(np.mean(Y**2))
574    if energy <= np.finfo(float).tiny:
575        raise ValueError("centered data have no identifiable variation")
576    floor = variance_floor * energy
577    if floor == 0:
578        floor = np.finfo(float).tiny
579    bounds = (
580        (max(1, n // 2), max(1, m // 2))
581        if rank_bounds is None
582        else _pair(rank_bounds, "rank_bounds", (n, m))
583    )
584    if ranks is None:
585        # Preserve paper ridge units under purely numerical data scaling.
586        ridge = (ratio_ridge / scale) / scale
587        if not np.isfinite(ridge) or ridge == 0:
588            raise ValueError(
589                "rank-selection ridge is not representable at this data scale; supply explicit ranks"
590            )
591        ranks, rank_history, spectra, rank_converged, rank_cycle = _select_ranks(
592            Y, bounds, ridge, rank_max_iter
593        )
594    else:
595        ranks = _pair(ranks, "ranks", (n, m))
596        rank_history, spectra, rank_converged, rank_cycle = (
597            np.asarray([ranks]),
598            None,
599            True,
600            False,
601        )
602    c, r = ranks
603    if initial is None:
604        L, Lam, initialization_history = _stepwise(Y, ranks, 50, 1e-6)
605    else:
606        try:
607            raw_row, raw_column = initial
608        except (ValueError, TypeError) as exc:
609            raise ValueError("initial must contain row and column loadings") from exc
610        matrices = []
611        for raw, d, k in ((raw_row, n, c), (raw_column, m, r)):
612            raw = np.asarray(raw)
613            if raw.dtype.kind not in "biuf" or np.iscomplexobj(raw):
614                raise ValueError("initial loadings must be real numeric")
615            value = np.array(raw, dtype=float, copy=True)
616            if (
617                value.shape != (d, k)
618                or not np.isfinite(value).all()
619                or not np.allclose(
620                    value.T @ value, d * np.eye(k), rtol=1e-8, atol=1e-10
621                )
622            ):
623                raise ValueError(
624                    "initial loadings must match ranks and dimension-scaled orthogonality"
625                )
626            matrices.append(value)
627        Lam, L = matrices
628        initialization_history = np.empty(0)
629    f0 = Y @ L / m
630    g0 = Y.transpose(0, 2, 1) @ Lam / n
631    psi_f = np.diag(np.maximum(np.mean(f0**2, axis=(0, 1)), floor))
632    psi_g = np.diag(np.maximum(np.mean(g0**2, axis=(0, 1)), floor))
633    noise = max(
634        float(np.mean((Y - f0 @ L.T - Lam @ g0.transpose(0, 2, 1)) ** 2)), floor
635    )
636    L, psi_f = _rotate(L, psi_f)
637    Lam, psi_g = _rotate(Lam, psi_g)
638    history = [_objective(Y, L, Lam, psi_f, psi_g, noise)]
639    em_histories, inner_iterations, inner_converged = [], [], []
640    converged, regularized = False, False
641    for iteration in range(1, max_iter + 1):
642        moments = _loading_moments(Y, Lam, np.diag(psi_f), np.diag(psi_g), noise)
643        L, it_f, done_f = _loading_update(moments, L, loading_max_iter, tol / 10)
644        moments = _loading_moments(
645            Y.transpose(0, 2, 1), L, np.diag(psi_g), np.diag(psi_f), noise
646        )
647        Lam, it_g, done_g = _loading_update(moments, Lam, loading_max_iter, tol / 10)
648        psi_f, psi_g, noise, em_history, done_em, active = _covariance_em(
649            Y, L, Lam, psi_f, psi_g, noise, floor, em_max_iter, tol / 10
650        )
651        regularized |= active
652        L, psi_f = _rotate(L, psi_f)
653        Lam, psi_g = _rotate(Lam, psi_g)
654        new = _objective(Y, L, Lam, psi_f, psi_g, noise)
655        _check_ascent(history[-1], new)
656        em_histories.append(em_history)
657        inner_iterations.append((it_g, it_f, len(em_history) - 1))
658        inner_converged.append((done_g, done_f, done_em))
659        improvement = abs(new - history[-1])
660        history.append(new)
661        if (
662            improvement <= tol * (1 + abs(history[-2]))
663            and done_g
664            and done_f
665            and done_em
666        ):
667            converged = True
668            break
669    F, G = _scores(Y, L, Lam, psi_f, psi_g, noise)
670    column_ar, innovation_f, _ = _pooled_ar(F, orders[1])
671    row_ar, innovation_g, _ = _pooled_ar(G, orders[0])
672    signal = (mean + F @ L.T + Lam @ G.transpose(0, 2, 1)) * scale
673    # Physical variances may overflow even though the scaled fit is valid.
674    physical_f = (psi_f * scale) * scale
675    physical_g = (psi_g * scale) * scale
676    physical_noise = (noise * scale) * scale
677    if not (
678        np.isfinite(physical_f).all()
679        and np.isfinite(physical_g).all()
680        and np.isfinite(physical_noise)
681    ):
682        raise FloatingPointError("physical variances overflow; rescale observations")
683    if (
684        np.any(np.diag(physical_f) <= 0)
685        or np.any(np.diag(physical_g) <= 0)
686        or physical_noise <= 0
687    ):
688        raise FloatingPointError("physical variances underflow; rescale observations")
689    return TwoWayDynamicResult(
690        row_loadings=Lam,
691        column_loadings=L,
692        F=F * scale,
693        G=G * scale,
694        signal=signal,
695        residuals=X - signal,
696        mean=mean * scale,
697        row_factor_covariance=physical_g,
698        column_factor_covariance=physical_f,
699        noise_variance=physical_noise,
700        row_ar=row_ar,
701        column_ar=column_ar,
702        row_innovation_variance=(innovation_g * scale) * scale,
703        column_innovation_variance=(innovation_f * scale) * scale,
704        objective_history=np.asarray(history),
705        em_objective_histories=tuple(em_histories),
706        inner_iterations=np.asarray(inner_iterations),
707        inner_converged=np.asarray(inner_converged),
708        rank_history=rank_history,
709        rank_spectra=spectra,
710        rank_converged=rank_converged,
711        rank_cycle=rank_cycle,
712        initialization_history=initialization_history,
713        converged=converged,
714        n_iter=iteration,
715        variance_regularized=regularized,
716        data_scale=scale,
717        _psi_f=psi_f,
718        _psi_g=psi_g,
719        _noise=noise,
720    )

Fit Yuan et al.'s additive two-way dynamic factor model.

X is time-first (T,n,m); ranks=(c,r) and orders=(q,p) follow spatial (row,column) order. Both ranks are positive and strictly below their corresponding dimensions. If ranks=None, Algorithm 2 selects them within rank_bounds (default half each dimension), with denominator addition ratio_ridgemax(n**-.5,m**-.5,T*-.5) in original data units. This selects among positive ranks; it is not a no-factor test.

Algorithm A1 provides initialization; Algorithm 1 combines heterogeneous quadratic loading updates and full latent-moment covariance EM. The final factor scores are conditional Gaussian linear predictors under the working additive covariance, followed by pooled scalar AR fits. Each AR coefficient is shared across units within its factor column.

center=False uses the paper's zero-mean model. center=True estimates and removes a training mean (an explicit extension). initial optionally supplies (row_loadings,column_loadings) with L.T@L=dimensionI. variance_floor is a relative lower bound on all covariance eigenvalues and noise variance, as a fraction of mean(Y*2) after centering; this computational safeguard restricts the parameter space and is reported. It may be zero to disable regularization on nondegenerate data.

max_iter, em_max_iter and loading_max_iter bound outer and inner loops; tol is a relative objective-increment tolerance. Inner loops use tol/10. All convergence/rank diagnostics are retained. Stability is diagnosed, not enforced. This is a local quasi-likelihood solution, not a global optimization certificate or a fit of the full temporal likelihood.

References

Yuan, C., Gao, Z., He, X., Huang, W. and Guo, J. (2023). Two-Way Dynamic Factor Models for High-Dimensional Matrix-Valued Time Series. JRSS B, 85, 1517-1537. https://doi.org/10.1093/jrsssb/qkad077 Algorithms 1/2 and supplement Algorithm A1; centering and numerical safeguards are implementation extensions described in the module notes.

@dataclass
class MatrixGARCHFilterResult:
367@dataclass
368class MatrixGARCHFilterResult:
369    """Conditional Gaussian likelihood and covariance histories.
370
371    ``row_covariances[t]`` is U[t]; ``column_factors[t]`` is trace-one V[t],
372    so the column covariance is y[t]*V[t]. ``standardized_residuals`` uses
373    Cholesky whitening, which suffices for the Gaussian likelihood but differs
374    from the paper's symmetric-root residual coordinates. No whiteness test
375    or inferential calibration is implied. Histories precede their observation:
376    the covariance at t cannot use X[t].
377
378    References
379    ----------
380    Yu, C., Li, D., Jiang, F. and Zhu, K. (2025; online 2024).
381    Matrix GARCH Model: Inference and Application. JASA, 120, 1747-1762.
382    https://doi.org/10.1080/01621459.2024.2415719
383    Uses the revised accepted manuscript, equations (4)-(9), (12)-(13),
384    Theorem 1 and Assumption 3.3(i), not only arXiv v1. Numerical boxes,
385    optimizer diagnostics and state containers are implementation choices.
386    """
387
388    parameters: MatrixGARCHParameters
389    row_covariances: np.ndarray
390    column_factors: np.ndarray
391    traces: np.ndarray
392    negative_log_likelihoods: np.ndarray
393    standardized_residuals: np.ndarray
394    state: MatrixGARCHState
395
396    @property
397    def log_likelihood(self):
398        return -float(self.negative_log_likelihoods.sum())
399
400    @property
401    def column_covariances(self):
402        return self.column_factors * self.traces[:, None, None]
403
404    def covariance(self, index):
405        """Materialize one conditional covariance in column-major order.
406
407        References
408        ----------
409        Yu, C., Li, D., Jiang, F. and Zhu, K. (2025; online 2024).
410        Matrix GARCH Model: Inference and Application. JASA, 120, 1747-1762.
411        https://doi.org/10.1080/01621459.2024.2415719
412        Uses the revised accepted manuscript, equations (4)-(9), (12)-(13),
413        Theorem 1 and Assumption 3.3(i), not only arXiv v1. Numerical boxes,
414        optimizer diagnostics and state containers are implementation choices.
415        """
416        return _dense_covariance(
417            self.row_covariances[index], self.column_factors[index]
418        )
419
420    def forecast_one(self):
421        """Exact one-step covariance; multistep expectations do not close.
422
423        References
424        ----------
425        Yu, C., Li, D., Jiang, F. and Zhu, K. (2025; online 2024).
426        Matrix GARCH Model: Inference and Application. JASA, 120, 1747-1762.
427        https://doi.org/10.1080/01621459.2024.2415719
428        Uses the revised accepted manuscript, equations (4)-(9), (12)-(13),
429        Theorem 1 and Assumption 3.3(i), not only arXiv v1. Numerical boxes,
430        optimizer diagnostics and state containers are implementation choices.
431        """
432        s = self.state
433        scale = max(
434            float(np.max(np.abs(s.last_observation))),
435            np.sqrt(self.parameters.w),
436            np.sqrt(s.trace),
437            np.finfo(float).tiny,
438        )
439        p = _change_units(self.parameters, scale, to_work=True)
440        row, col, y = _next(
441            p,
442            s.row_shape,
443            s.column_shape,
444            (s.trace / scale) / scale,
445            s.last_observation / scale,
446        )
447        u, v, _, _ = _covariance(row, col, y)
448        with np.errstate(over="ignore", invalid="ignore"):
449            u, y = (u * scale) * scale, (y * scale) * scale
450        _check_original_covariance(u, v, y)
451        return MatrixGARCHForecast(u, v, float(y))

Conditional Gaussian likelihood and covariance histories.

row_covariances[t] is U[t]; column_factors[t] is trace-one V[t], so the column covariance is y[t]*V[t]. standardized_residuals uses Cholesky whitening, which suffices for the Gaussian likelihood but differs from the paper's symmetric-root residual coordinates. No whiteness test or inferential calibration is implied. Histories precede their observation: the covariance at t cannot use X[t].

References

Yu, C., Li, D., Jiang, F. and Zhu, K. (2025; online 2024). Matrix GARCH Model: Inference and Application. JASA, 120, 1747-1762. https://doi.org/10.1080/01621459.2024.2415719 Uses the revised accepted manuscript, equations (4)-(9), (12)-(13), Theorem 1 and Assumption 3.3(i), not only arXiv v1. Numerical boxes, optimizer diagnostics and state containers are implementation choices.

MatrixGARCHFilterResult( parameters: MatrixGARCHParameters, row_covariances: numpy.ndarray, column_factors: numpy.ndarray, traces: numpy.ndarray, negative_log_likelihoods: numpy.ndarray, standardized_residuals: numpy.ndarray, state: MatrixGARCHState)
parameters: MatrixGARCHParameters
row_covariances: numpy.ndarray
column_factors: numpy.ndarray
traces: numpy.ndarray
negative_log_likelihoods: numpy.ndarray
standardized_residuals: numpy.ndarray
log_likelihood
396    @property
397    def log_likelihood(self):
398        return -float(self.negative_log_likelihoods.sum())
column_covariances
400    @property
401    def column_covariances(self):
402        return self.column_factors * self.traces[:, None, None]
def covariance(self, index):
404    def covariance(self, index):
405        """Materialize one conditional covariance in column-major order.
406
407        References
408        ----------
409        Yu, C., Li, D., Jiang, F. and Zhu, K. (2025; online 2024).
410        Matrix GARCH Model: Inference and Application. JASA, 120, 1747-1762.
411        https://doi.org/10.1080/01621459.2024.2415719
412        Uses the revised accepted manuscript, equations (4)-(9), (12)-(13),
413        Theorem 1 and Assumption 3.3(i), not only arXiv v1. Numerical boxes,
414        optimizer diagnostics and state containers are implementation choices.
415        """
416        return _dense_covariance(
417            self.row_covariances[index], self.column_factors[index]
418        )

Materialize one conditional covariance in column-major order.

References

Yu, C., Li, D., Jiang, F. and Zhu, K. (2025; online 2024). Matrix GARCH Model: Inference and Application. JASA, 120, 1747-1762. https://doi.org/10.1080/01621459.2024.2415719 Uses the revised accepted manuscript, equations (4)-(9), (12)-(13), Theorem 1 and Assumption 3.3(i), not only arXiv v1. Numerical boxes, optimizer diagnostics and state containers are implementation choices.

def forecast_one(self):
420    def forecast_one(self):
421        """Exact one-step covariance; multistep expectations do not close.
422
423        References
424        ----------
425        Yu, C., Li, D., Jiang, F. and Zhu, K. (2025; online 2024).
426        Matrix GARCH Model: Inference and Application. JASA, 120, 1747-1762.
427        https://doi.org/10.1080/01621459.2024.2415719
428        Uses the revised accepted manuscript, equations (4)-(9), (12)-(13),
429        Theorem 1 and Assumption 3.3(i), not only arXiv v1. Numerical boxes,
430        optimizer diagnostics and state containers are implementation choices.
431        """
432        s = self.state
433        scale = max(
434            float(np.max(np.abs(s.last_observation))),
435            np.sqrt(self.parameters.w),
436            np.sqrt(s.trace),
437            np.finfo(float).tiny,
438        )
439        p = _change_units(self.parameters, scale, to_work=True)
440        row, col, y = _next(
441            p,
442            s.row_shape,
443            s.column_shape,
444            (s.trace / scale) / scale,
445            s.last_observation / scale,
446        )
447        u, v, _, _ = _covariance(row, col, y)
448        with np.errstate(over="ignore", invalid="ignore"):
449            u, y = (u * scale) * scale, (y * scale) * scale
450        _check_original_covariance(u, v, y)
451        return MatrixGARCHForecast(u, v, float(y))

Exact one-step covariance; multistep expectations do not close.

References

Yu, C., Li, D., Jiang, F. and Zhu, K. (2025; online 2024). Matrix GARCH Model: Inference and Application. JASA, 120, 1747-1762. https://doi.org/10.1080/01621459.2024.2415719 Uses the revised accepted manuscript, equations (4)-(9), (12)-(13), Theorem 1 and Assumption 3.3(i), not only arXiv v1. Numerical boxes, optimizer diagnostics and state containers are implementation choices.

@dataclass
class MatrixGARCHForecast:
330@dataclass
331class MatrixGARCHForecast:
332    """Exact conditional covariance for the next observation, in original units.
333
334    References
335    ----------
336    Yu, C., Li, D., Jiang, F. and Zhu, K. (2025; online 2024).
337    Matrix GARCH Model: Inference and Application. JASA, 120, 1747-1762.
338    https://doi.org/10.1080/01621459.2024.2415719
339    Uses the revised accepted manuscript, equations (4)-(9), (12)-(13),
340    Theorem 1 and Assumption 3.3(i), not only arXiv v1. Numerical boxes,
341    optimizer diagnostics and state containers are implementation choices.
342    """
343
344    row_covariance: np.ndarray
345    column_factor: np.ndarray
346    trace: float
347
348    @property
349    def column_covariance(self):
350        return self.column_factor * self.trace
351
352    def covariance(self):
353        """Materialize Cov(vec_F(X_next)): V_next kron U_next.
354
355        References
356        ----------
357        Yu, C., Li, D., Jiang, F. and Zhu, K. (2025; online 2024).
358        Matrix GARCH Model: Inference and Application. JASA, 120, 1747-1762.
359        https://doi.org/10.1080/01621459.2024.2415719
360        Uses the revised accepted manuscript, equations (4)-(9), (12)-(13),
361        Theorem 1 and Assumption 3.3(i), not only arXiv v1. Numerical boxes,
362        optimizer diagnostics and state containers are implementation choices.
363        """
364        return _dense_covariance(self.row_covariance, self.column_factor)

Exact conditional covariance for the next observation, in original units.

References

Yu, C., Li, D., Jiang, F. and Zhu, K. (2025; online 2024). Matrix GARCH Model: Inference and Application. JASA, 120, 1747-1762. https://doi.org/10.1080/01621459.2024.2415719 Uses the revised accepted manuscript, equations (4)-(9), (12)-(13), Theorem 1 and Assumption 3.3(i), not only arXiv v1. Numerical boxes, optimizer diagnostics and state containers are implementation choices.

MatrixGARCHForecast( row_covariance: numpy.ndarray, column_factor: numpy.ndarray, trace: float)
row_covariance: numpy.ndarray
column_factor: numpy.ndarray
trace: float
column_covariance
348    @property
349    def column_covariance(self):
350        return self.column_factor * self.trace
def covariance(self):
352    def covariance(self):
353        """Materialize Cov(vec_F(X_next)): V_next kron U_next.
354
355        References
356        ----------
357        Yu, C., Li, D., Jiang, F. and Zhu, K. (2025; online 2024).
358        Matrix GARCH Model: Inference and Application. JASA, 120, 1747-1762.
359        https://doi.org/10.1080/01621459.2024.2415719
360        Uses the revised accepted manuscript, equations (4)-(9), (12)-(13),
361        Theorem 1 and Assumption 3.3(i), not only arXiv v1. Numerical boxes,
362        optimizer diagnostics and state containers are implementation choices.
363        """
364        return _dense_covariance(self.row_covariance, self.column_factor)

Materialize Cov(vec_F(X_next)): V_next kron U_next.

References

Yu, C., Li, D., Jiang, F. and Zhu, K. (2025; online 2024). Matrix GARCH Model: Inference and Application. JASA, 120, 1747-1762. https://doi.org/10.1080/01621459.2024.2415719 Uses the revised accepted manuscript, equations (4)-(9), (12)-(13), Theorem 1 and Assumption 3.3(i), not only arXiv v1. Numerical boxes, optimizer diagnostics and state containers are implementation choices.

@dataclass(frozen=True)
class MatrixGARCHParameters:
 48@dataclass(frozen=True)
 49class MatrixGARCHParameters:
 50    """Parameters of the full first-order trace-normalized matrix GARCH model.
 51
 52    A0/B0 are lower triangular, have strictly positive diagonal entries and
 53    first diagonal exactly one. This interior covariance convention ensures
 54    positive definiteness even with the paper's zero-state initialization.
 55    A1/B1 multiply the preceding observation scatter; A2/B2 multiply the
 56    previous *unnormalized* shape state. Dynamic matrices are unrestricted
 57    in sign; each can be negated independently without changing the model.
 58
 59    w > 0, alpha >= 0 and beta >= 0 ensure a positive trace process. Positivity
 60    alone does not imply stationarity. Scalar spatial modes have no identified
 61    shape dynamics: their intercept must be [[1]] and dynamics [[0]].
 62    Arrays are copied and made read-only.
 63
 64    References
 65    ----------
 66    Yu, C., Li, D., Jiang, F. and Zhu, K. (2025; online 2024).
 67    Matrix GARCH Model: Inference and Application. JASA, 120, 1747-1762.
 68    https://doi.org/10.1080/01621459.2024.2415719
 69    Uses the revised accepted manuscript, equations (4)-(9), (12)-(13),
 70    Theorem 1 and Assumption 3.3(i), not only arXiv v1. Numerical boxes,
 71    optimizer diagnostics and state containers are implementation choices.
 72    """
 73
 74    A0: np.ndarray
 75    A1: np.ndarray
 76    A2: np.ndarray
 77    B0: np.ndarray
 78    B1: np.ndarray
 79    B2: np.ndarray
 80    w: float
 81    alpha: float
 82    beta: float
 83
 84    def __post_init__(self):
 85        for prefix in ("A", "B"):
 86            first = _matrix(getattr(self, prefix + "0"), prefix + "0")
 87            if not first.shape[0] or first.shape[0] != first.shape[1]:
 88                raise ValueError(f"{prefix}0 must be a nonempty square matrix")
 89            if (
 90                np.any(np.triu(first, 1) != 0)
 91                or np.any(np.diag(first) <= 0)
 92                or first[0, 0] != 1
 93            ):
 94                raise ValueError(
 95                    f"{prefix}0 must be lower triangular with positive diagonal and [0,0]=1"
 96                )
 97            for suffix in ("0", "1", "2"):
 98                name = prefix + suffix
 99                array = _matrix(getattr(self, name), name, first.shape)
100                if first.shape == (1, 1) and suffix != "0" and np.any(array != 0):
101                    raise ValueError(
102                        "scalar modes must have zero shape dynamics (unidentified otherwise)"
103                    )
104                array.flags.writeable = False
105                object.__setattr__(self, name, array)
106        object.__setattr__(self, "w", _positive(self.w, "w"))
107        object.__setattr__(self, "alpha", finite_scalar(self.alpha, "alpha", minimum=0))
108        object.__setattr__(self, "beta", finite_scalar(self.beta, "beta", minimum=0))
109
110    @property
111    def shape(self):
112        return (len(self.A0), len(self.B0))
113
114    @property
115    def sufficient_stationarity_bound(self):
116        """Theorem 1's Frobenius bound; <1 is sufficient for Gaussian noise.
117
118        This condition is not necessary. Its failure must not be called
119        nonstationarity. It is stronger than separate BEKK spectral checks.
120
121        References
122        ----------
123        Yu, C., Li, D., Jiang, F. and Zhu, K. (2025; online 2024).
124        Matrix GARCH Model: Inference and Application. JASA, 120, 1747-1762.
125        https://doi.org/10.1080/01621459.2024.2415719
126        Uses the revised accepted manuscript, equations (4)-(9), (12)-(13),
127        Theorem 1 and Assumption 3.3(i), not only arXiv v1. Numerical boxes,
128        optimizer diagnostics and state containers are implementation choices.
129        """
130        return float(
131            np.sum(self.A1**2)
132            + np.sum(self.B1**2)
133            + self.alpha
134            + max(np.sum(self.A2**2), np.sum(self.B2**2), self.beta)
135        )
136
137    @property
138    def spectral_bounds(self):
139        """Assumption 3.3(i) row/column radii and alpha+beta, not a proof of stationarity.
140
141        References
142        ----------
143        Yu, C., Li, D., Jiang, F. and Zhu, K. (2025; online 2024).
144        Matrix GARCH Model: Inference and Application. JASA, 120, 1747-1762.
145        https://doi.org/10.1080/01621459.2024.2415719
146        Uses the revised accepted manuscript, equations (4)-(9), (12)-(13),
147        Theorem 1 and Assumption 3.3(i), not only arXiv v1. Numerical boxes,
148        optimizer diagnostics and state containers are implementation choices.
149        """
150        radii = [
151            float(np.max(np.abs(np.linalg.eigvals(np.kron(a, a) + np.kron(b, b)))))
152            for a, b in ((self.A1, self.A2), (self.B1, self.B2))
153        ]
154        return (*radii, self.alpha + self.beta)

Parameters of the full first-order trace-normalized matrix GARCH model.

A0/B0 are lower triangular, have strictly positive diagonal entries and first diagonal exactly one. This interior covariance convention ensures positive definiteness even with the paper's zero-state initialization. A1/B1 multiply the preceding observation scatter; A2/B2 multiply the previous unnormalized shape state. Dynamic matrices are unrestricted in sign; each can be negated independently without changing the model.

w > 0, alpha >= 0 and beta >= 0 ensure a positive trace process. Positivity alone does not imply stationarity. Scalar spatial modes have no identified shape dynamics: their intercept must be [[1]] and dynamics [[0]]. Arrays are copied and made read-only.

References

Yu, C., Li, D., Jiang, F. and Zhu, K. (2025; online 2024). Matrix GARCH Model: Inference and Application. JASA, 120, 1747-1762. https://doi.org/10.1080/01621459.2024.2415719 Uses the revised accepted manuscript, equations (4)-(9), (12)-(13), Theorem 1 and Assumption 3.3(i), not only arXiv v1. Numerical boxes, optimizer diagnostics and state containers are implementation choices.

MatrixGARCHParameters( A0: numpy.ndarray, A1: numpy.ndarray, A2: numpy.ndarray, B0: numpy.ndarray, B1: numpy.ndarray, B2: numpy.ndarray, w: float, alpha: float, beta: float)
A0: numpy.ndarray
A1: numpy.ndarray
A2: numpy.ndarray
B0: numpy.ndarray
B1: numpy.ndarray
B2: numpy.ndarray
w: float
alpha: float
beta: float
shape
110    @property
111    def shape(self):
112        return (len(self.A0), len(self.B0))
sufficient_stationarity_bound
114    @property
115    def sufficient_stationarity_bound(self):
116        """Theorem 1's Frobenius bound; <1 is sufficient for Gaussian noise.
117
118        This condition is not necessary. Its failure must not be called
119        nonstationarity. It is stronger than separate BEKK spectral checks.
120
121        References
122        ----------
123        Yu, C., Li, D., Jiang, F. and Zhu, K. (2025; online 2024).
124        Matrix GARCH Model: Inference and Application. JASA, 120, 1747-1762.
125        https://doi.org/10.1080/01621459.2024.2415719
126        Uses the revised accepted manuscript, equations (4)-(9), (12)-(13),
127        Theorem 1 and Assumption 3.3(i), not only arXiv v1. Numerical boxes,
128        optimizer diagnostics and state containers are implementation choices.
129        """
130        return float(
131            np.sum(self.A1**2)
132            + np.sum(self.B1**2)
133            + self.alpha
134            + max(np.sum(self.A2**2), np.sum(self.B2**2), self.beta)
135        )

Theorem 1's Frobenius bound; <1 is sufficient for Gaussian noise.

This condition is not necessary. Its failure must not be called nonstationarity. It is stronger than separate BEKK spectral checks.

References

Yu, C., Li, D., Jiang, F. and Zhu, K. (2025; online 2024). Matrix GARCH Model: Inference and Application. JASA, 120, 1747-1762. https://doi.org/10.1080/01621459.2024.2415719 Uses the revised accepted manuscript, equations (4)-(9), (12)-(13), Theorem 1 and Assumption 3.3(i), not only arXiv v1. Numerical boxes, optimizer diagnostics and state containers are implementation choices.

spectral_bounds
137    @property
138    def spectral_bounds(self):
139        """Assumption 3.3(i) row/column radii and alpha+beta, not a proof of stationarity.
140
141        References
142        ----------
143        Yu, C., Li, D., Jiang, F. and Zhu, K. (2025; online 2024).
144        Matrix GARCH Model: Inference and Application. JASA, 120, 1747-1762.
145        https://doi.org/10.1080/01621459.2024.2415719
146        Uses the revised accepted manuscript, equations (4)-(9), (12)-(13),
147        Theorem 1 and Assumption 3.3(i), not only arXiv v1. Numerical boxes,
148        optimizer diagnostics and state containers are implementation choices.
149        """
150        radii = [
151            float(np.max(np.abs(np.linalg.eigvals(np.kron(a, a) + np.kron(b, b)))))
152            for a, b in ((self.A1, self.A2), (self.B1, self.B2))
153        ]
154        return (*radii, self.alpha + self.beta)

Assumption 3.3(i) row/column radii and alpha+beta, not a proof of stationarity.

References

Yu, C., Li, D., Jiang, F. and Zhu, K. (2025; online 2024). Matrix GARCH Model: Inference and Application. JASA, 120, 1747-1762. https://doi.org/10.1080/01621459.2024.2415719 Uses the revised accepted manuscript, equations (4)-(9), (12)-(13), Theorem 1 and Assumption 3.3(i), not only arXiv v1. Numerical boxes, optimizer diagnostics and state containers are implementation choices.

@dataclass
class MatrixGARCHResult:
749@dataclass
750class MatrixGARCHResult:
751    """Local conditional QMLE and all starts' optimization diagnostics.
752
753    ``objective`` includes the Gaussian constant in original data units.
754    ``runs`` records optimizer objectives in standardized data units, differing
755    by the same constant ``m*n*log(data_scale)``. Optimizer success does not
756    establish a global minimum, identification, stationarity, or valid inference.
757    Coefficient signs follow the selected start: compare covariance paths,
758    not raw signed coefficients. No covariance floor is applied.
759
760    References
761    ----------
762    Yu, C., Li, D., Jiang, F. and Zhu, K. (2025; online 2024).
763    Matrix GARCH Model: Inference and Application. JASA, 120, 1747-1762.
764    https://doi.org/10.1080/01621459.2024.2415719
765    Uses the revised accepted manuscript, equations (4)-(9), (12)-(13),
766    Theorem 1 and Assumption 3.3(i), not only arXiv v1. Numerical boxes,
767    optimizer diagnostics and state containers are implementation choices.
768    """
769
770    parameters: MatrixGARCHParameters
771    filtered: MatrixGARCHFilterResult
772    converged: bool
773    n_iter: int
774    objective: float
775    runs: tuple
776    selected_start: int
777    dynamics: str
778    constraint: str
779    constraint_margin: float
780    data_scale: float
781
782    @property
783    def log_likelihood(self):
784        return self.filtered.log_likelihood
785
786    @property
787    def active_bounds(self):
788        return self.runs[self.selected_start].active_bounds
789
790    def forecast_one(self):
791        """Return the exact next-observation conditional covariance.
792
793        References
794        ----------
795        Yu, C., Li, D., Jiang, F. and Zhu, K. (2025; online 2024).
796        Matrix GARCH Model: Inference and Application. JASA, 120, 1747-1762.
797        https://doi.org/10.1080/01621459.2024.2415719
798        Uses the revised accepted manuscript, equations (4)-(9), (12)-(13),
799        Theorem 1 and Assumption 3.3(i), not only arXiv v1. Numerical boxes,
800        optimizer diagnostics and state containers are implementation choices.
801        """
802        return self.filtered.forecast_one()
803
804    def filter(self, X, *, continue_history=True):
805        """Filter new observations without refitting; continuation is chronological.
806
807        References
808        ----------
809        Yu, C., Li, D., Jiang, F. and Zhu, K. (2025; online 2024).
810        Matrix GARCH Model: Inference and Application. JASA, 120, 1747-1762.
811        https://doi.org/10.1080/01621459.2024.2415719
812        Uses the revised accepted manuscript, equations (4)-(9), (12)-(13),
813        Theorem 1 and Assumption 3.3(i), not only arXiv v1. Numerical boxes,
814        optimizer diagnostics and state containers are implementation choices.
815        """
816        if not isinstance(continue_history, (bool, np.bool_)):
817            raise ValueError("continue_history must be boolean")
818        return filter_matrix_garch(
819            X,
820            self.parameters,
821            initial_state=self.filtered.state if continue_history else None,
822        )

Local conditional QMLE and all starts' optimization diagnostics.

objective includes the Gaussian constant in original data units. runs records optimizer objectives in standardized data units, differing by the same constant m*n*log(data_scale). Optimizer success does not establish a global minimum, identification, stationarity, or valid inference. Coefficient signs follow the selected start: compare covariance paths, not raw signed coefficients. No covariance floor is applied.

References

Yu, C., Li, D., Jiang, F. and Zhu, K. (2025; online 2024). Matrix GARCH Model: Inference and Application. JASA, 120, 1747-1762. https://doi.org/10.1080/01621459.2024.2415719 Uses the revised accepted manuscript, equations (4)-(9), (12)-(13), Theorem 1 and Assumption 3.3(i), not only arXiv v1. Numerical boxes, optimizer diagnostics and state containers are implementation choices.

MatrixGARCHResult( parameters: MatrixGARCHParameters, filtered: MatrixGARCHFilterResult, converged: bool, n_iter: int, objective: float, runs: tuple, selected_start: int, dynamics: str, constraint: str, constraint_margin: float, data_scale: float)
parameters: MatrixGARCHParameters
converged: bool
n_iter: int
objective: float
runs: tuple
selected_start: int
dynamics: str
constraint: str
constraint_margin: float
data_scale: float
log_likelihood
782    @property
783    def log_likelihood(self):
784        return self.filtered.log_likelihood
active_bounds
786    @property
787    def active_bounds(self):
788        return self.runs[self.selected_start].active_bounds
def forecast_one(self):
790    def forecast_one(self):
791        """Return the exact next-observation conditional covariance.
792
793        References
794        ----------
795        Yu, C., Li, D., Jiang, F. and Zhu, K. (2025; online 2024).
796        Matrix GARCH Model: Inference and Application. JASA, 120, 1747-1762.
797        https://doi.org/10.1080/01621459.2024.2415719
798        Uses the revised accepted manuscript, equations (4)-(9), (12)-(13),
799        Theorem 1 and Assumption 3.3(i), not only arXiv v1. Numerical boxes,
800        optimizer diagnostics and state containers are implementation choices.
801        """
802        return self.filtered.forecast_one()

Return the exact next-observation conditional covariance.

References

Yu, C., Li, D., Jiang, F. and Zhu, K. (2025; online 2024). Matrix GARCH Model: Inference and Application. JASA, 120, 1747-1762. https://doi.org/10.1080/01621459.2024.2415719 Uses the revised accepted manuscript, equations (4)-(9), (12)-(13), Theorem 1 and Assumption 3.3(i), not only arXiv v1. Numerical boxes, optimizer diagnostics and state containers are implementation choices.

def filter(self, X, *, continue_history=True):
804    def filter(self, X, *, continue_history=True):
805        """Filter new observations without refitting; continuation is chronological.
806
807        References
808        ----------
809        Yu, C., Li, D., Jiang, F. and Zhu, K. (2025; online 2024).
810        Matrix GARCH Model: Inference and Application. JASA, 120, 1747-1762.
811        https://doi.org/10.1080/01621459.2024.2415719
812        Uses the revised accepted manuscript, equations (4)-(9), (12)-(13),
813        Theorem 1 and Assumption 3.3(i), not only arXiv v1. Numerical boxes,
814        optimizer diagnostics and state containers are implementation choices.
815        """
816        if not isinstance(continue_history, (bool, np.bool_)):
817            raise ValueError("continue_history must be boolean")
818        return filter_matrix_garch(
819            X,
820            self.parameters,
821            initial_state=self.filtered.state if continue_history else None,
822        )

Filter new observations without refitting; continuation is chronological.

References

Yu, C., Li, D., Jiang, F. and Zhu, K. (2025; online 2024). Matrix GARCH Model: Inference and Application. JASA, 120, 1747-1762. https://doi.org/10.1080/01621459.2024.2415719 Uses the revised accepted manuscript, equations (4)-(9), (12)-(13), Theorem 1 and Assumption 3.3(i), not only arXiv v1. Numerical boxes, optimizer diagnostics and state containers are implementation choices.

@dataclass
class MatrixGARCHSimulation:
520@dataclass
521class MatrixGARCHSimulation:
522    """Gaussian observations and the covariance states actually used to draw them.
523
524    References
525    ----------
526    Yu, C., Li, D., Jiang, F. and Zhu, K. (2025; online 2024).
527    Matrix GARCH Model: Inference and Application. JASA, 120, 1747-1762.
528    https://doi.org/10.1080/01621459.2024.2415719
529    Uses the revised accepted manuscript, equations (4)-(9), (12)-(13),
530    Theorem 1 and Assumption 3.3(i), not only arXiv v1. Numerical boxes,
531    optimizer diagnostics and state containers are implementation choices.
532    """
533
534    observations: np.ndarray
535    row_covariances: np.ndarray
536    column_factors: np.ndarray
537    traces: np.ndarray
538    initial_state: MatrixGARCHState
539    state: MatrixGARCHState

Gaussian observations and the covariance states actually used to draw them.

References

Yu, C., Li, D., Jiang, F. and Zhu, K. (2025; online 2024). Matrix GARCH Model: Inference and Application. JASA, 120, 1747-1762. https://doi.org/10.1080/01621459.2024.2415719 Uses the revised accepted manuscript, equations (4)-(9), (12)-(13), Theorem 1 and Assumption 3.3(i), not only arXiv v1. Numerical boxes, optimizer diagnostics and state containers are implementation choices.

MatrixGARCHSimulation( observations: numpy.ndarray, row_covariances: numpy.ndarray, column_factors: numpy.ndarray, traces: numpy.ndarray, initial_state: MatrixGARCHState, state: MatrixGARCHState)
observations: numpy.ndarray
row_covariances: numpy.ndarray
column_factors: numpy.ndarray
traces: numpy.ndarray
initial_state: MatrixGARCHState
@dataclass
class MatrixGARCHState:
157@dataclass
158class MatrixGARCHState:
159    """Post-observation state at t: S1[t], S2[t], y[t], and X[t].
160
161    ``forecast_one`` uses only this state to calculate covariance at t+1.
162    Shape states are not trace-normalized. Trace and last observation use
163    original data units. A zero state is the paper's conditional initializer.
164
165    References
166    ----------
167    Yu, C., Li, D., Jiang, F. and Zhu, K. (2025; online 2024).
168    Matrix GARCH Model: Inference and Application. JASA, 120, 1747-1762.
169    https://doi.org/10.1080/01621459.2024.2415719
170    Uses the revised accepted manuscript, equations (4)-(9), (12)-(13),
171    Theorem 1 and Assumption 3.3(i), not only arXiv v1. Numerical boxes,
172    optimizer diagnostics and state containers are implementation choices.
173    """
174
175    row_shape: np.ndarray
176    column_shape: np.ndarray
177    trace: float
178    last_observation: np.ndarray

Post-observation state at t: S1[t], S2[t], y[t], and X[t].

forecast_one uses only this state to calculate covariance at t+1. Shape states are not trace-normalized. Trace and last observation use original data units. A zero state is the paper's conditional initializer.

References

Yu, C., Li, D., Jiang, F. and Zhu, K. (2025; online 2024). Matrix GARCH Model: Inference and Application. JASA, 120, 1747-1762. https://doi.org/10.1080/01621459.2024.2415719 Uses the revised accepted manuscript, equations (4)-(9), (12)-(13), Theorem 1 and Assumption 3.3(i), not only arXiv v1. Numerical boxes, optimizer diagnostics and state containers are implementation choices.

MatrixGARCHState( row_shape: numpy.ndarray, column_shape: numpy.ndarray, trace: float, last_observation: numpy.ndarray)
row_shape: numpy.ndarray
column_shape: numpy.ndarray
trace: float
last_observation: numpy.ndarray
def filter_matrix_garch(X, parameters, *, initial_state=None):
454def filter_matrix_garch(X, parameters, *, initial_state=None):
455    """Filter observed zero-mean matrices with fixed matrix GARCH parameters.
456
457    Default is the paper's zero X0, shape states and trace, *before* the first
458    covariance update. Thus first covariance uses A0 A0.T, B0 B0.T and w.
459    Pass a previous result's ``state`` for chronological continuation. This
460    does not refit parameters or estimate a mean. Gaussian constants are
461    included in per-observation likelihoods, in the original data units.
462
463    References
464    ----------
465    Yu, C., Li, D., Jiang, F. and Zhu, K. (2025; online 2024).
466    Matrix GARCH Model: Inference and Application. JASA, 120, 1747-1762.
467    https://doi.org/10.1080/01621459.2024.2415719
468    Uses the revised accepted manuscript, equations (4)-(9), (12)-(13),
469    Theorem 1 and Assumption 3.3(i), not only arXiv v1. Numerical boxes,
470    optimizer diagnostics and state containers are implementation choices.
471    """
472    X = as_series(X, min_samples=1)
473    if (
474        not isinstance(parameters, MatrixGARCHParameters)
475        or parameters.shape != X.shape[1:]
476    ):
477        raise ValueError(
478            "parameters must be MatrixGARCHParameters matching observation shape"
479        )
480    s = _state(initial_state, parameters.shape)
481    scale = max(
482        float(np.max(np.abs(X))),
483        float(np.max(np.abs(s.last_observation))),
484        np.sqrt(parameters.w),
485        np.sqrt(s.trace),
486        np.finfo(float).tiny,
487    )
488    p = _change_units(parameters, scale, to_work=True)
489    row, col, y, past = (
490        s.row_shape,
491        s.column_shape,
492        (s.trace / scale) / scale,
493        s.last_observation / scale,
494    )
495    us, vs, ys, values, residuals = [], [], [], [], []
496    with np.errstate(over="ignore", invalid="ignore", divide="ignore"):
497        for x in X / scale:
498            row, col, y = _next(p, row, col, y, past)
499            u, v, lu, lv = _covariance(row, col, y)
500            nll, white = _likelihood(x, lu, lv)
501            us.append((u * scale) * scale)
502            vs.append(v)
503            ys.append((y * scale) * scale)
504            values.append(nll + X.shape[1] * X.shape[2] * np.log(scale))
505            residuals.append(white)
506            past = x
507    _check_original_covariance(us, vs, ys)
508    terminal = MatrixGARCHState(row, col, float(ys[-1]), X[-1].copy())
509    return MatrixGARCHFilterResult(
510        parameters,
511        np.asarray(us),
512        np.asarray(vs),
513        np.asarray(ys),
514        np.asarray(values),
515        np.asarray(residuals),
516        terminal,
517    )

Filter observed zero-mean matrices with fixed matrix GARCH parameters.

Default is the paper's zero X0, shape states and trace, before the first covariance update. Thus first covariance uses A0 A0.T, B0 B0.T and w. Pass a previous result's state for chronological continuation. This does not refit parameters or estimate a mean. Gaussian constants are included in per-observation likelihoods, in the original data units.

References

Yu, C., Li, D., Jiang, F. and Zhu, K. (2025; online 2024). Matrix GARCH Model: Inference and Application. JASA, 120, 1747-1762. https://doi.org/10.1080/01621459.2024.2415719 Uses the revised accepted manuscript, equations (4)-(9), (12)-(13), Theorem 1 and Assumption 3.3(i), not only arXiv v1. Numerical boxes, optimizer diagnostics and state containers are implementation choices.

def fit_matrix_garch( X, *, dynamics='full', initial=None, n_starts=2, constraint='spectral', constraint_margin=0.0001, max_iter=300, tol=1e-07, parameter_bound=5.0, random_state=0):
 825def fit_matrix_garch(
 826    X,
 827    *,
 828    dynamics="full",
 829    initial=None,
 830    n_starts=2,
 831    constraint="spectral",
 832    constraint_margin=1e-4,
 833    max_iter=300,
 834    tol=1e-7,
 835    parameter_bound=5.0,
 836    random_state=0,
 837):
 838    """Fit the full trace-identified first-order model by conditional Gaussian QMLE.
 839
 840    ``dynamics='full'`` estimates every entry in A1/A2/B1/B2; ``'diagonal'``
 841    restricts these four matrices, retaining full triangular A0/B0. Scalar
 842    spatial modes omit unidentified shape dynamics. Observations must have zero
 843    conditional mean; no mean subtraction or mean-model fitting is implicit.
 844
 845    ``constraint='spectral'`` enforces Assumption 3.3(i)'s two BEKK spectral
 846    bounds and alpha+beta below 1-margin. These do not by themselves establish
 847    stationarity. ``'sufficient'`` instead imposes the stronger Frobenius bound
 848    from accepted-manuscript Theorem 1. ``'none'`` keeps only positivity and
 849    finite optimization boxes. Constraints are evaluated in ORIGINAL data units.
 850    Failing the sufficient bound is not proof of nonstationarity.
 851
 852    SLSQP uses analytic recursive objective gradients and numerical constraint
 853    derivatives. Starts are deterministic given random_state. ``initial`` is a
 854    MatrixGARCHParameters in original units and becomes the first start; the
 855    others perturb it. Otherwise use a sample marginal-covariance initialization
 856    with small nonzero dynamic diagonals. All starts use the paper's zero-state
 857    conditional likelihood; no burn-in observations are removed from fitting.
 858
 859    For conditioning, X is divided by its largest absolute entry. In these work
 860    units, log(w) lies within +/-parameter_bound of log(mean ||X||_F^2), free
 861    log intercept diagonals lie within +/-parameter_bound, other entries within
 862    +/-parameter_bound, and alpha/beta within [0,parameter_bound]. These are
 863    numerical compact-domain choices, not universal paper-prescribed bounds.
 864    Enlarging the boxes and checking multiple starts can change a local fit.
 865    Active box bounds and nonlinear constraints are reported. Convergence uses
 866    the optimizer's success AND feasibility; finite failed starts remain visible.
 867
 868    No standard errors, estimation-adjusted portmanteau test, matrix factor GARCH,
 869    or multistep covariance approximation is implemented. See volatility-notes.md.
 870
 871    References
 872    ----------
 873    Yu, C., Li, D., Jiang, F. and Zhu, K. (2025; online 2024).
 874    Matrix GARCH Model: Inference and Application. JASA, 120, 1747-1762.
 875    https://doi.org/10.1080/01621459.2024.2415719
 876    Uses the revised accepted manuscript, equations (4)-(9), (12)-(13),
 877    Theorem 1 and Assumption 3.3(i), not only arXiv v1. Numerical boxes,
 878    optimizer diagnostics and state containers are implementation choices.
 879    """
 880    X = as_series(X, min_samples=3)
 881    if dynamics not in ("full", "diagonal"):
 882        raise ValueError("dynamics must be 'full' or 'diagonal'")
 883    if constraint not in ("spectral", "sufficient", "none"):
 884        raise ValueError("constraint must be 'spectral', 'sufficient', or 'none'")
 885    n_starts = positive_int(n_starts, "n_starts")
 886    max_iter = positive_int(max_iter, "max_iter")
 887    tol = _positive(tol, "tol")
 888    bound = _positive(parameter_bound, "parameter_bound")
 889    margin = _positive(constraint_margin, "constraint_margin")
 890    if margin >= 1 or bound > 30:
 891        raise ValueError("constraint_margin must be <1 and parameter_bound <=30")
 892    scale = float(np.max(np.abs(X)))
 893    if scale == 0:
 894        raise ValueError("zero observations drive the trace likelihood to its boundary")
 895    work = X / scale
 896    energy = float(np.mean(np.sum(work**2, axis=(1, 2))))
 897    with np.errstate(over="ignore", under="ignore"):
 898        original_energy = (energy * scale) * scale
 899    if not np.isfinite(original_energy) or original_energy == 0:
 900        raise ValueError(
 901            "data units imply a variance outside the finite floating-point range"
 902        )
 903    codec = _Parameterization(X.shape[1:], dynamics)
 904    lower, upper = np.full(len(codec.labels), -bound), np.full(len(codec.labels), bound)
 905    lower[0], upper[0] = np.log(energy) - bound, np.log(energy) + bound
 906    lower[1:3] = 0
 907
 908    def inequalities(x):
 909        if constraint == "none":
 910            return np.empty(0)
 911        size = 3 if constraint == "spectral" else 1
 912        try:
 913            with np.errstate(over="ignore", invalid="ignore", divide="ignore"):
 914                p = _change_units(codec.unpack(x), scale, to_work=False)
 915                bounds = (
 916                    p.spectral_bounds
 917                    if constraint == "spectral"
 918                    else [p.sufficient_stationarity_bound]
 919                )
 920                result = 1 - margin - np.asarray(bounds)
 921            if np.isfinite(result).all():
 922                return result
 923        except (FloatingPointError, ValueError, np.linalg.LinAlgError):
 924            pass
 925        # SLSQP requires finite constraint values, including invalid trials.
 926        return np.full(size, -1e10)
 927
 928    def feasible_start(x):
 929        try:
 930            _change_units(codec.unpack(x), scale, to_work=False)
 931            return bool(np.all(inequalities(x) >= 0))
 932        except (FloatingPointError, ValueError, np.linalg.LinAlgError):
 933            return False
 934
 935    if initial is not None:
 936        if (
 937            not isinstance(initial, MatrixGARCHParameters)
 938            or initial.shape != X.shape[1:]
 939        ):
 940            raise ValueError("initial must be MatrixGARCHParameters matching X")
 941        if dynamics == "diagonal" and any(
 942            np.any(a != np.diag(np.diag(a)))
 943            for a in (initial.A1, initial.A2, initial.B1, initial.B2)
 944        ):
 945            raise ValueError("initial dynamics must be diagonal for diagonal fitting")
 946        start = codec.pack(_change_units(initial, scale, to_work=True))
 947        if (
 948            np.any(start < lower)
 949            or np.any(start > upper)
 950            or np.any(inequalities(start) < 0)
 951        ):
 952            raise ValueError(
 953                "initial lies outside the optimization bounds or chosen constraints"
 954            )
 955    else:
 956        arrays = []
 957        for data in (work, work.transpose(0, 2, 1)):
 958            d = data.shape[1]
 959            cov = np.einsum("tij,tkj->ik", data, data) / len(data)
 960            try:
 961                a0 = np.linalg.cholesky(cov)
 962            except np.linalg.LinAlgError as exc:
 963                raise ValueError(
 964                    "sample marginal covariance is singular; supply a valid initial parameter set"
 965                ) from exc
 966            a0 /= a0[0, 0]
 967            arrays.extend(
 968                (
 969                    a0,
 970                    0.1 * min(1.0, scale) * np.eye(d) if d > 1 else np.zeros((1, 1)),
 971                    0.25 * np.eye(d) if d > 1 else np.zeros((1, 1)),
 972                )
 973            )
 974        p0 = MatrixGARCHParameters(*arrays, 0.3 * energy, 0.1, 0.6)
 975        start = np.clip(codec.pack(p0), lower, upper)
 976        # The conservative start has a feasible scale even in larger modes.
 977        for _ in range(1024):
 978            if feasible_start(start):
 979                break
 980            for j, (name, _, _, _) in enumerate(codec.entries, 3):
 981                if not name.endswith("0"):
 982                    start[j] *= 0.5
 983            start[1:3] *= 0.5
 984        else:
 985            raise ValueError(
 986                "no representable feasible initialization in these data units"
 987            )
 988    rng = np.random.default_rng(random_state)
 989    runs, solutions = [], []
 990    for attempt in range(n_starts):
 991        x0 = start.copy()
 992        start_fallback = False
 993        if attempt:
 994            candidate = np.clip(
 995                start + rng.normal(scale=0.1, size=len(start)), lower, upper
 996            )
 997            # Contract a proposed perturbation toward a known feasible start.
 998            for _ in range(40):
 999                if feasible_start(candidate):
1000                    break
1001                candidate = (candidate + start) / 2
1002            if feasible_start(candidate):
1003                x0 = candidate
1004            else:
1005                start_fallback = True
1006        invalid = [0]
1007        history = []
1008        cached = [None, None, None]
1009
1010        def objective(x):
1011            # SLSQP, its callback, and the terminal audit often request exactly
1012            # the same point. Cache only that point (never approximate matches).
1013            # Own both arrays: optimizers may mutate inputs or returned gradients.
1014            if cached[0] is None or not np.array_equal(x, cached[0]):
1015                try:
1016                    with np.errstate(over="ignore", invalid="ignore", divide="ignore"):
1017                        p = codec.unpack(x)
1018                        _change_units(p, scale, to_work=False)
1019                        value, gradient = _objective_gradient(
1020                            work, p, codec, data_scale=scale
1021                        )
1022                except (FloatingPointError, ValueError, np.linalg.LinAlgError):
1023                    invalid[0] += 1
1024                    value, gradient = 1e30 + float(x @ x), 2 * x
1025                cached[:] = [x.copy(), value, gradient.copy()]
1026            return cached[1], cached[2].copy()
1027
1028        initial_value = objective(x0)[0]
1029        history.append(initial_value)
1030        best = [float(initial_value), x0.copy()]
1031
1032        def callback(x):
1033            value = float(objective(x)[0])
1034            history.append(value)
1035            if (
1036                value < best[0]
1037                and np.all(inequalities(x) >= 0)
1038                and np.all(x >= lower)
1039                and np.all(x <= upper)
1040            ):
1041                best[:] = [value, x.copy()]
1042
1043        constraints = (
1044            [] if constraint == "none" else [{"type": "ineq", "fun": inequalities}]
1045        )
1046        fit = minimize(
1047            objective,
1048            x0,
1049            jac=True,
1050            method="SLSQP",
1051            bounds=list(zip(lower, upper)),
1052            constraints=constraints,
1053            options={"maxiter": max_iter, "ftol": tol},
1054            callback=callback,
1055        )
1056        value, gradient = objective(fit.x)
1057        violations = np.r_[lower - fit.x, fit.x - upper, -inequalities(fit.x), 0.0]
1058        terminal_violation = float(np.max(violations))
1059        retained = (
1060            value >= 1e29
1061            or terminal_violation > max(1e-8, tol)
1062            or (not fit.success and value > best[0])
1063        )
1064        solution = best[1] if retained else fit.x
1065        if retained:
1066            value, gradient = objective(solution)
1067        violation = float(
1068            np.max(
1069                np.r_[lower - solution, solution - upper, -inequalities(solution), 0.0]
1070            )
1071        )
1072        active = [
1073            label
1074            for label, x, lo, hi in zip(codec.labels, solution, lower, upper)
1075            if min(x - lo, hi - x) < 1e-5
1076        ]
1077        for j, v in enumerate(inequalities(solution)):
1078            if v < 1e-5:
1079                active.append(f"{constraint} constraint {j}")
1080        converged = bool(
1081            fit.success
1082            and not retained
1083            and violation <= max(1e-8, tol)
1084            and value < 1e29
1085        )
1086        runs.append(
1087            MatrixGARCHOptimizationRun(
1088                float(value),
1089                converged,
1090                int(fit.status),
1091                str(fit.message),
1092                int(fit.nit),
1093                int(fit.nfev),
1094                float(np.linalg.norm(gradient)),
1095                violation,
1096                tuple(active),
1097                np.asarray(history),
1098                invalid[0],
1099                retained,
1100                terminal_violation,
1101                start_fallback,
1102            )
1103        )
1104        solutions.append(solution)
1105    eligible = [
1106        i
1107        for i, run in enumerate(runs)
1108        if run.objective < 1e29 and run.constraint_violation <= max(1e-8, tol)
1109    ]
1110    if not eligible:
1111        raise RuntimeError(
1112            "no optimization start produced a finite feasible matrix GARCH fit"
1113        )
1114    selected = min(eligible, key=lambda i: runs[i].objective)
1115    parameters = _change_units(codec.unpack(solutions[selected]), scale, to_work=False)
1116    filtered = filter_matrix_garch(X, parameters)
1117    return MatrixGARCHResult(
1118        parameters,
1119        filtered,
1120        runs[selected].converged,
1121        runs[selected].n_iter,
1122        -filtered.log_likelihood / len(X),
1123        tuple(runs),
1124        selected,
1125        dynamics,
1126        constraint,
1127        margin,
1128        scale,
1129    )

Fit the full trace-identified first-order model by conditional Gaussian QMLE.

dynamics='full' estimates every entry in A1/A2/B1/B2; 'diagonal' restricts these four matrices, retaining full triangular A0/B0. Scalar spatial modes omit unidentified shape dynamics. Observations must have zero conditional mean; no mean subtraction or mean-model fitting is implicit.

constraint='spectral' enforces Assumption 3.3(i)'s two BEKK spectral bounds and alpha+beta below 1-margin. These do not by themselves establish stationarity. 'sufficient' instead imposes the stronger Frobenius bound from accepted-manuscript Theorem 1. 'none' keeps only positivity and finite optimization boxes. Constraints are evaluated in ORIGINAL data units. Failing the sufficient bound is not proof of nonstationarity.

SLSQP uses analytic recursive objective gradients and numerical constraint derivatives. Starts are deterministic given random_state. initial is a MatrixGARCHParameters in original units and becomes the first start; the others perturb it. Otherwise use a sample marginal-covariance initialization with small nonzero dynamic diagonals. All starts use the paper's zero-state conditional likelihood; no burn-in observations are removed from fitting.

For conditioning, X is divided by its largest absolute entry. In these work units, log(w) lies within +/-parameter_bound of log(mean ||X||_F^2), free log intercept diagonals lie within +/-parameter_bound, other entries within +/-parameter_bound, and alpha/beta within [0,parameter_bound]. These are numerical compact-domain choices, not universal paper-prescribed bounds. Enlarging the boxes and checking multiple starts can change a local fit. Active box bounds and nonlinear constraints are reported. Convergence uses the optimizer's success AND feasibility; finite failed starts remain visible.

No standard errors, estimation-adjusted portmanteau test, matrix factor GARCH, or multistep covariance approximation is implemented. See volatility-notes.md.

References

Yu, C., Li, D., Jiang, F. and Zhu, K. (2025; online 2024). Matrix GARCH Model: Inference and Application. JASA, 120, 1747-1762. https://doi.org/10.1080/01621459.2024.2415719 Uses the revised accepted manuscript, equations (4)-(9), (12)-(13), Theorem 1 and Assumption 3.3(i), not only arXiv v1. Numerical boxes, optimizer diagnostics and state containers are implementation choices.

def simulate_matrix_garch(n, parameters, *, burnin=300, random_state=None):
542def simulate_matrix_garch(n, parameters, *, burnin=300, random_state=None):
543    """Simulate Gaussian matrix GARCH with a finite burn-in from the zero state.
544
545    Burn-in approximates stationarity and does not prove it. Unstable recursions
546    are rejected if numerical states become invalid. The returned initial_state
547    is the post-burn-in state immediately preceding the retained observations,
548    enabling an exact simulation/filter oracle. Innovations are standard matrix
549    normal, not elementwise or matrix Student-t.
550
551    References
552    ----------
553    Yu, C., Li, D., Jiang, F. and Zhu, K. (2025; online 2024).
554    Matrix GARCH Model: Inference and Application. JASA, 120, 1747-1762.
555    https://doi.org/10.1080/01621459.2024.2415719
556    Uses the revised accepted manuscript, equations (4)-(9), (12)-(13),
557    Theorem 1 and Assumption 3.3(i), not only arXiv v1. Numerical boxes,
558    optimizer diagnostics and state containers are implementation choices.
559    """
560    n = positive_int(n, "n")
561    burnin = positive_int(burnin, "burnin", minimum=0)
562    if not isinstance(parameters, MatrixGARCHParameters):
563        raise ValueError("parameters must be MatrixGARCHParameters")
564    rng = np.random.default_rng(random_state)
565    s = _state(None, parameters.shape)
566    row, col, y, past = s.row_shape, s.column_shape, 0.0, s.last_observation
567    observations, us, vs, ys = [], [], [], []
568    initial = s
569    with np.errstate(over="ignore", invalid="ignore"):
570        for t in range(n + burnin):
571            if t == burnin:
572                initial = MatrixGARCHState(row.copy(), col.copy(), y, past.copy())
573            row, col, y = _next(parameters, row, col, y, past)
574            u, v, lu, lv = _covariance(row, col, y)
575            _check_original_covariance(u, v, y)
576            past = lu @ rng.normal(size=parameters.shape) @ lv.T
577            if not np.isfinite(past).all():
578                raise FloatingPointError(
579                    "simulated observation exceeds the finite numeric range"
580                )
581            if t >= burnin:
582                observations.append(past)
583                us.append(u)
584                vs.append(v)
585                ys.append(y)
586    return MatrixGARCHSimulation(
587        np.asarray(observations),
588        np.asarray(us),
589        np.asarray(vs),
590        np.asarray(ys),
591        initial,
592        MatrixGARCHState(row, col, y, past),
593    )

Simulate Gaussian matrix GARCH with a finite burn-in from the zero state.

Burn-in approximates stationarity and does not prove it. Unstable recursions are rejected if numerical states become invalid. The returned initial_state is the post-burn-in state immediately preceding the retained observations, enabling an exact simulation/filter oracle. Innovations are standard matrix normal, not elementwise or matrix Student-t.

References

Yu, C., Li, D., Jiang, F. and Zhu, K. (2025; online 2024). Matrix GARCH Model: Inference and Application. JASA, 120, 1747-1762. https://doi.org/10.1080/01621459.2024.2415719 Uses the revised accepted manuscript, equations (4)-(9), (12)-(13), Theorem 1 and Assumption 3.3(i), not only arXiv v1. Numerical boxes, optimizer diagnostics and state containers are implementation choices.

@dataclass
class MARInferenceResult:
103@dataclass
104class MARInferenceResult:
105    """Plug-in covariance of normalized A/B estimates, not their sqrt(N) limit.
106
107    ``parameter_covariance`` uses [vec(A), vec(B)] in column-major order;
108    ||A||_F=1 fixes scale. Signs are aligned so A[sign_anchor] is positive;
109    that true entry must be nonzero for coefficient intervals to have a locally
110    fixed identification convention. Operator intervals have no sign ambiguity.
111    ``operator`` and ``operator_standard_errors`` have shape (m*n,m*n).
112    The dense covariance of the operator is available only by explicit call.
113    Intervals are marginal Wald intervals, not simultaneous confidence bands.
114    Covariance and standard-error accessors inherit the theorem references
115    in :func:`mar_inference` and this module.
116    """
117
118    result: MARResult
119    parameter_covariance: np.ndarray
120    operator_standard_errors: np.ndarray
121    condition_number: float
122    n_observations: int
123    sign_anchor: tuple
124    _operator_derivative: np.ndarray
125
126    @property
127    def operator(self):
128        return np.kron(self.result.B, self.result.A)
129
130    @property
131    def left_standard_errors(self):
132        m = len(self.result.A)
133        return np.sqrt(
134            np.maximum(np.diag(self.parameter_covariance)[: m * m], 0)
135        ).reshape(m, m, order="F")
136
137    @property
138    def right_standard_errors(self):
139        m, n = len(self.result.A), len(self.result.B)
140        return np.sqrt(
141            np.maximum(np.diag(self.parameter_covariance)[m * m :], 0)
142        ).reshape(n, n, order="F")
143
144    def operator_covariance(self):
145        """Materialize covariance of column-vectorized operator: (mn)^4 entries."""
146        return (
147            self._operator_derivative
148            @ self.parameter_covariance
149            @ self._operator_derivative.T
150        )
151
152    def confidence_interval(self, *, level=0.95, target="operator"):
153        """Return lower/upper arrays for marginal asymptotic normal intervals.
154
155        At products whose two underlying A/B entries both vanish, the first-order
156        variance degenerates and ordinary Wald coverage is not justified.
157
158        References
159        ----------
160        Chen, R., Xiao, H. and Yang, D. (2021), "Autoregressive Models for
161        Matrix-Valued Time Series", Theorems 2--4,
162        https://doi.org/10.1016/j.jeconom.2020.07.015.
163        These are plug-in marginal Wald intervals from the paper's normal
164        limits; operator intervals apply the first-order delta method.
165        """
166        level = finite_scalar(level, "level")
167        if not 0 < level < 1:
168            raise ValueError("level must lie strictly between zero and one")
169        if target == "operator":
170            estimate, se = self.operator, self.operator_standard_errors
171        elif target == "left":
172            estimate, se = self.result.A, self.left_standard_errors
173        elif target == "right":
174            estimate, se = self.result.B, self.right_standard_errors
175        else:
176            raise ValueError("target must be 'operator', 'left', or 'right'")
177        width = norm.isf((1 - level) / 2) * se
178        return estimate - width, estimate + width

Plug-in covariance of normalized A/B estimates, not their sqrt(N) limit.

parameter_covariance uses [vec(A), vec(B)] in column-major order; ||A||_F=1 fixes scale. Signs are aligned so A[sign_anchor] is positive; that true entry must be nonzero for coefficient intervals to have a locally fixed identification convention. Operator intervals have no sign ambiguity. operator and operator_standard_errors have shape (mn,mn). The dense covariance of the operator is available only by explicit call. Intervals are marginal Wald intervals, not simultaneous confidence bands. Covariance and standard-error accessors inherit the theorem references in mar_inference() and this module.

MARInferenceResult( result: MARResult, parameter_covariance: numpy.ndarray, operator_standard_errors: numpy.ndarray, condition_number: float, n_observations: int, sign_anchor: tuple, _operator_derivative: numpy.ndarray)
result: MARResult
parameter_covariance: numpy.ndarray
operator_standard_errors: numpy.ndarray
condition_number: float
n_observations: int
sign_anchor: tuple
operator
126    @property
127    def operator(self):
128        return np.kron(self.result.B, self.result.A)
left_standard_errors
130    @property
131    def left_standard_errors(self):
132        m = len(self.result.A)
133        return np.sqrt(
134            np.maximum(np.diag(self.parameter_covariance)[: m * m], 0)
135        ).reshape(m, m, order="F")
right_standard_errors
137    @property
138    def right_standard_errors(self):
139        m, n = len(self.result.A), len(self.result.B)
140        return np.sqrt(
141            np.maximum(np.diag(self.parameter_covariance)[m * m :], 0)
142        ).reshape(n, n, order="F")
def operator_covariance(self):
144    def operator_covariance(self):
145        """Materialize covariance of column-vectorized operator: (mn)^4 entries."""
146        return (
147            self._operator_derivative
148            @ self.parameter_covariance
149            @ self._operator_derivative.T
150        )

Materialize covariance of column-vectorized operator: (mn)^4 entries.

def confidence_interval(self, *, level=0.95, target='operator'):
152    def confidence_interval(self, *, level=0.95, target="operator"):
153        """Return lower/upper arrays for marginal asymptotic normal intervals.
154
155        At products whose two underlying A/B entries both vanish, the first-order
156        variance degenerates and ordinary Wald coverage is not justified.
157
158        References
159        ----------
160        Chen, R., Xiao, H. and Yang, D. (2021), "Autoregressive Models for
161        Matrix-Valued Time Series", Theorems 2--4,
162        https://doi.org/10.1016/j.jeconom.2020.07.015.
163        These are plug-in marginal Wald intervals from the paper's normal
164        limits; operator intervals apply the first-order delta method.
165        """
166        level = finite_scalar(level, "level")
167        if not 0 < level < 1:
168            raise ValueError("level must lie strictly between zero and one")
169        if target == "operator":
170            estimate, se = self.operator, self.operator_standard_errors
171        elif target == "left":
172            estimate, se = self.result.A, self.left_standard_errors
173        elif target == "right":
174            estimate, se = self.result.B, self.right_standard_errors
175        else:
176            raise ValueError("target must be 'operator', 'left', or 'right'")
177        width = norm.isf((1 - level) / 2) * se
178        return estimate - width, estimate + width

Return lower/upper arrays for marginal asymptotic normal intervals.

At products whose two underlying A/B entries both vanish, the first-order variance degenerates and ordinary Wald coverage is not justified.

References

Chen, R., Xiao, H. and Yang, D. (2021), "Autoregressive Models for Matrix-Valued Time Series", Theorems 2--4, https://doi.org/10.1016/j.jeconom.2020.07.015. These are plug-in marginal Wald intervals from the paper's normal limits; operator intervals apply the first-order delta method.

@dataclass
class MARSpecificationResult:
341@dataclass
342class MARSpecificationResult:
343    """Wald specification test of one Kronecker VAR transition operator."""
344
345    statistic: float
346    pvalue: float
347    degrees_of_freedom: int
348    projection_left: np.ndarray
349    projection_right: np.ndarray
350    unrestricted_coefficient: np.ndarray
351    condition_number: float
352    n_observations: int

Wald specification test of one Kronecker VAR transition operator.

MARSpecificationResult( statistic: float, pvalue: float, degrees_of_freedom: int, projection_left: numpy.ndarray, projection_right: numpy.ndarray, unrestricted_coefficient: numpy.ndarray, condition_number: float, n_observations: int)
statistic: float
pvalue: float
degrees_of_freedom: int
projection_left: numpy.ndarray
projection_right: numpy.ndarray
unrestricted_coefficient: numpy.ndarray
condition_number: float
n_observations: int
@dataclass
class MatrixDecorrelationResult:
197@dataclass
198class MatrixDecorrelationResult:
199    """Invertible matrix segmentation, with contiguous groups in latent space.
200
201    ``series`` has the same shape as the training data. Its rectangular blocks
202    are indexed by ``row_groups`` and ``column_groups``. Estimated separation
203    is approximate and is not an independence test or an all-lag guarantee.
204    ``row_rotation`` and ``column_rotation`` are the reordered orthogonal
205    eigenvectors of equation (7); the corresponding spectra are reordered to
206    match them. ``row_moment`` and ``column_moment`` remain in the original
207    marginally whitened coordinates. ``*_correlations`` follow the new order.
208
209    The symmetric ``*_root_scaled`` and ``*_whitener_scaled`` matrices refer
210    to data divided by ``data_scale``. This representation avoids squaring the
211    units of the data. Physical-unit transforms and mixing matrices are exposed
212    as properties. ``row_transform @ (X-mean) @ column_transform`` equals
213    ``series``; ``row_mixing @ series @ column_mixing.T + mean`` inverts it.
214    Under positive global scaling by c, latent values scale by 1/c, as required
215    by the two marginal normalizations in equation (8).
216
217    References
218    ----------
219    Han, Y., Chen, R., Zhang, C.-H. and Yao, Q. (2024).
220    Simultaneous Decorrelation of Matrix Time Series. JASA, 119, 957-969.
221    https://doi.org/10.1080/01621459.2022.2151448
222    Open manuscript: https://arxiv.org/abs/2103.09411
223    Equations (5)-(11); the ratio search bound follows the authors'
224    journal supplement, https://doi.org/10.6084/m9.figshare.21641763.v2.
225    The fixed correlation-threshold default is an uncalibrated convenience.
226    """
227
228    series: np.ndarray
229    mean: np.ndarray
230    data_scale: float
231    row_rotation: np.ndarray
232    column_rotation: np.ndarray
233    row_root_scaled: np.ndarray
234    column_root_scaled: np.ndarray
235    row_whitener_scaled: np.ndarray
236    column_whitener_scaled: np.ndarray
237    row_moment: np.ndarray
238    column_moment: np.ndarray
239    row_eigenvalues: np.ndarray
240    column_eigenvalues: np.ndarray
241    row_correlations: np.ndarray
242    column_correlations: np.ndarray
243    row_groups: tuple
244    column_groups: tuple
245    row_n_edges: int
246    column_n_edges: int
247    lags: int
248    correlation_lags: int
249    correlation_threshold: tuple | None
250    grouping: str
251    ratio_delta: float
252    ratio_max_edges: tuple
253    center: bool
254    rank_tol: float
255    _mean_scaled: np.ndarray
256    method: str = "matrix-decorrelation-threshold"
257
258    @property
259    def row_transform(self):
260        """Left transformation B_*.T Sigma_row^-1/2 in original data units."""
261        return (self.row_rotation.T @ self.row_whitener_scaled) / self.data_scale
262
263    @property
264    def column_transform(self):
265        """Right transformation Sigma_column^-1/2 A_* in original units."""
266        return (self.column_whitener_scaled @ self.column_rotation) / self.data_scale
267
268    @property
269    def row_mixing(self):
270        """B = Sigma_row^1/2 B_* in original units."""
271        return (self.row_root_scaled @ self.row_rotation) * self.data_scale
272
273    @property
274    def column_mixing(self):
275        """A = Sigma_column^1/2 A_* in original units."""
276        return (self.column_root_scaled @ self.column_rotation) * self.data_scale
277
278    def transform(self, X):
279        """Transform new data with the fitted mean and both fitted marginals.
280
281        References
282        ----------
283        Han, Y., Chen, R., Zhang, C.-H. and Yao, Q. (2024).
284        Simultaneous Decorrelation of Matrix Time Series. JASA, 119, 957-969.
285        https://doi.org/10.1080/01621459.2022.2151448
286        Open manuscript: https://arxiv.org/abs/2103.09411
287        Equations (5)-(11); the ratio search bound follows the authors'
288        journal supplement, https://doi.org/10.6084/m9.figshare.21641763.v2.
289        The fixed correlation-threshold default is an uncalibrated convenience.
290        """
291        X = as_series(X, min_samples=1)
292        if X.shape[1:] != self.mean.shape:
293            raise ValueError("observation dimensions must match the fitted model")
294        with np.errstate(over="ignore", invalid="ignore", divide="ignore"):
295            work = X / self.data_scale - self._mean_scaled
296            output = (
297                self.row_rotation.T
298                @ self.row_whitener_scaled
299                @ work
300                @ self.column_whitener_scaled
301                @ self.column_rotation
302            ) / self.data_scale
303        if not np.isfinite(output).all():
304            raise ValueError(
305                "transformed values exceed floating-point range; rescale data"
306            )
307        return output
308
309    def inverse_transform(self, series):
310        """Invert complete latent matrices, adding the training mean.
311
312        References
313        ----------
314        Han, Y., Chen, R., Zhang, C.-H. and Yao, Q. (2024).
315        Simultaneous Decorrelation of Matrix Time Series. JASA, 119, 957-969.
316        https://doi.org/10.1080/01621459.2022.2151448
317        Open manuscript: https://arxiv.org/abs/2103.09411
318        Equations (5)-(11); the ratio search bound follows the authors'
319        journal supplement, https://doi.org/10.6084/m9.figshare.21641763.v2.
320        The fixed correlation-threshold default is an uncalibrated convenience.
321        """
322        series = as_series(series, min_samples=1)
323        if series.shape[1:] != self.mean.shape:
324            raise ValueError("latent dimensions must match the fitted model")
325        with np.errstate(over="ignore", invalid="ignore"):
326            normalized = (
327                self.row_root_scaled
328                @ self.row_rotation
329                @ (series * self.data_scale)
330                @ self.column_rotation.T
331                @ self.column_root_scaled
332            )
333            output = (normalized + self._mean_scaled) * self.data_scale
334        if not np.isfinite(output).all():
335            raise ValueError("reconstructed values exceed floating-point range")
336        return output
337
338    def blocks(self, X=None):
339        """Return nested row/column tuples of latent block time series.
340
341        With no argument, return the training blocks. Otherwise transform X
342        with the fitted parameters and then extract its blocks.
343
344        References
345        ----------
346        Han, Y., Chen, R., Zhang, C.-H. and Yao, Q. (2024).
347        Simultaneous Decorrelation of Matrix Time Series. JASA, 119, 957-969.
348        https://doi.org/10.1080/01621459.2022.2151448
349        Open manuscript: https://arxiv.org/abs/2103.09411
350        Equations (5)-(11); the ratio search bound follows the authors'
351        journal supplement, https://doi.org/10.6084/m9.figshare.21641763.v2.
352        The fixed correlation-threshold default is an uncalibrated convenience.
353        """
354        series = self.series if X is None else self.transform(X)
355        return tuple(
356            tuple(series[:, rows, :][:, :, columns] for columns in self.column_groups)
357            for rows in self.row_groups
358        )
359
360    def inverse_blocks(self, blocks):
361        """Reassemble and invert nested block predictions from ``blocks()``.
362
363        References
364        ----------
365        Han, Y., Chen, R., Zhang, C.-H. and Yao, Q. (2024).
366        Simultaneous Decorrelation of Matrix Time Series. JASA, 119, 957-969.
367        https://doi.org/10.1080/01621459.2022.2151448
368        Open manuscript: https://arxiv.org/abs/2103.09411
369        Equations (5)-(11); the ratio search bound follows the authors'
370        journal supplement, https://doi.org/10.6084/m9.figshare.21641763.v2.
371        The fixed correlation-threshold default is an uncalibrated convenience.
372        """
373        try:
374            block_rows = tuple(tuple(row) for row in blocks)
375        except TypeError as exc:
376            raise ValueError("blocks must be nested row/column sequences") from exc
377        if len(block_rows) != len(self.row_groups) or any(
378            len(row) != len(self.column_groups) for row in block_rows
379        ):
380            raise ValueError("block partition must match the fitted groups")
381        assembled_rows = []
382        n_time = None
383        for row, rows in zip(block_rows, self.row_groups):
384            arrays = []
385            for block, columns in zip(row, self.column_groups):
386                array = as_series(block, min_samples=1)
387                if array.shape[1:] != (len(rows), len(columns)):
388                    raise ValueError("block dimensions must match the fitted groups")
389                if n_time is None:
390                    n_time = len(array)
391                if len(array) != n_time:
392                    raise ValueError(
393                        "all blocks must have the same number of observations"
394                    )
395                arrays.append(array)
396            assembled_rows.append(np.concatenate(arrays, axis=2))
397        return self.inverse_transform(np.concatenate(assembled_rows, axis=1))

Invertible matrix segmentation, with contiguous groups in latent space.

series has the same shape as the training data. Its rectangular blocks are indexed by row_groups and column_groups. Estimated separation is approximate and is not an independence test or an all-lag guarantee. row_rotation and column_rotation are the reordered orthogonal eigenvectors of equation (7); the corresponding spectra are reordered to match them. row_moment and column_moment remain in the original marginally whitened coordinates. *_correlations follow the new order.

The symmetric *_root_scaled and *_whitener_scaled matrices refer to data divided by data_scale. This representation avoids squaring the units of the data. Physical-unit transforms and mixing matrices are exposed as properties. row_transform @ (X-mean) @ column_transform equals series; row_mixing @ series @ column_mixing.T + mean inverts it. Under positive global scaling by c, latent values scale by 1/c, as required by the two marginal normalizations in equation (8).

References

Han, Y., Chen, R., Zhang, C.-H. and Yao, Q. (2024). Simultaneous Decorrelation of Matrix Time Series. JASA, 119, 957-969. https://doi.org/10.1080/01621459.2022.2151448 Open manuscript: https://arxiv.org/abs/2103.09411 Equations (5)-(11); the ratio search bound follows the authors' journal supplement, https://doi.org/10.6084/m9.figshare.21641763.v2. The fixed correlation-threshold default is an uncalibrated convenience.

MatrixDecorrelationResult( series: numpy.ndarray, mean: numpy.ndarray, data_scale: float, row_rotation: numpy.ndarray, column_rotation: numpy.ndarray, row_root_scaled: numpy.ndarray, column_root_scaled: numpy.ndarray, row_whitener_scaled: numpy.ndarray, column_whitener_scaled: numpy.ndarray, row_moment: numpy.ndarray, column_moment: numpy.ndarray, row_eigenvalues: numpy.ndarray, column_eigenvalues: numpy.ndarray, row_correlations: numpy.ndarray, column_correlations: numpy.ndarray, row_groups: tuple, column_groups: tuple, row_n_edges: int, column_n_edges: int, lags: int, correlation_lags: int, correlation_threshold: tuple | None, grouping: str, ratio_delta: float, ratio_max_edges: tuple, center: bool, rank_tol: float, _mean_scaled: numpy.ndarray, method: str = 'matrix-decorrelation-threshold')
series: numpy.ndarray
mean: numpy.ndarray
data_scale: float
row_rotation: numpy.ndarray
column_rotation: numpy.ndarray
row_root_scaled: numpy.ndarray
column_root_scaled: numpy.ndarray
row_whitener_scaled: numpy.ndarray
column_whitener_scaled: numpy.ndarray
row_moment: numpy.ndarray
column_moment: numpy.ndarray
row_eigenvalues: numpy.ndarray
column_eigenvalues: numpy.ndarray
row_correlations: numpy.ndarray
column_correlations: numpy.ndarray
row_groups: tuple
column_groups: tuple
row_n_edges: int
column_n_edges: int
lags: int
correlation_lags: int
correlation_threshold: tuple | None
grouping: str
ratio_delta: float
ratio_max_edges: tuple
center: bool
rank_tol: float
method: str = 'matrix-decorrelation-threshold'
row_transform
258    @property
259    def row_transform(self):
260        """Left transformation B_*.T Sigma_row^-1/2 in original data units."""
261        return (self.row_rotation.T @ self.row_whitener_scaled) / self.data_scale

Left transformation B_*.T Sigma_row^-1/2 in original data units.

column_transform
263    @property
264    def column_transform(self):
265        """Right transformation Sigma_column^-1/2 A_* in original units."""
266        return (self.column_whitener_scaled @ self.column_rotation) / self.data_scale

Right transformation Sigma_column^-1/2 A_* in original units.

row_mixing
268    @property
269    def row_mixing(self):
270        """B = Sigma_row^1/2 B_* in original units."""
271        return (self.row_root_scaled @ self.row_rotation) * self.data_scale

B = Sigma_row^1/2 B_* in original units.

column_mixing
273    @property
274    def column_mixing(self):
275        """A = Sigma_column^1/2 A_* in original units."""
276        return (self.column_root_scaled @ self.column_rotation) * self.data_scale

A = Sigma_column^1/2 A_* in original units.

def transform(self, X):
278    def transform(self, X):
279        """Transform new data with the fitted mean and both fitted marginals.
280
281        References
282        ----------
283        Han, Y., Chen, R., Zhang, C.-H. and Yao, Q. (2024).
284        Simultaneous Decorrelation of Matrix Time Series. JASA, 119, 957-969.
285        https://doi.org/10.1080/01621459.2022.2151448
286        Open manuscript: https://arxiv.org/abs/2103.09411
287        Equations (5)-(11); the ratio search bound follows the authors'
288        journal supplement, https://doi.org/10.6084/m9.figshare.21641763.v2.
289        The fixed correlation-threshold default is an uncalibrated convenience.
290        """
291        X = as_series(X, min_samples=1)
292        if X.shape[1:] != self.mean.shape:
293            raise ValueError("observation dimensions must match the fitted model")
294        with np.errstate(over="ignore", invalid="ignore", divide="ignore"):
295            work = X / self.data_scale - self._mean_scaled
296            output = (
297                self.row_rotation.T
298                @ self.row_whitener_scaled
299                @ work
300                @ self.column_whitener_scaled
301                @ self.column_rotation
302            ) / self.data_scale
303        if not np.isfinite(output).all():
304            raise ValueError(
305                "transformed values exceed floating-point range; rescale data"
306            )
307        return output

Transform new data with the fitted mean and both fitted marginals.

References

Han, Y., Chen, R., Zhang, C.-H. and Yao, Q. (2024). Simultaneous Decorrelation of Matrix Time Series. JASA, 119, 957-969. https://doi.org/10.1080/01621459.2022.2151448 Open manuscript: https://arxiv.org/abs/2103.09411 Equations (5)-(11); the ratio search bound follows the authors' journal supplement, https://doi.org/10.6084/m9.figshare.21641763.v2. The fixed correlation-threshold default is an uncalibrated convenience.

def inverse_transform(self, series):
309    def inverse_transform(self, series):
310        """Invert complete latent matrices, adding the training mean.
311
312        References
313        ----------
314        Han, Y., Chen, R., Zhang, C.-H. and Yao, Q. (2024).
315        Simultaneous Decorrelation of Matrix Time Series. JASA, 119, 957-969.
316        https://doi.org/10.1080/01621459.2022.2151448
317        Open manuscript: https://arxiv.org/abs/2103.09411
318        Equations (5)-(11); the ratio search bound follows the authors'
319        journal supplement, https://doi.org/10.6084/m9.figshare.21641763.v2.
320        The fixed correlation-threshold default is an uncalibrated convenience.
321        """
322        series = as_series(series, min_samples=1)
323        if series.shape[1:] != self.mean.shape:
324            raise ValueError("latent dimensions must match the fitted model")
325        with np.errstate(over="ignore", invalid="ignore"):
326            normalized = (
327                self.row_root_scaled
328                @ self.row_rotation
329                @ (series * self.data_scale)
330                @ self.column_rotation.T
331                @ self.column_root_scaled
332            )
333            output = (normalized + self._mean_scaled) * self.data_scale
334        if not np.isfinite(output).all():
335            raise ValueError("reconstructed values exceed floating-point range")
336        return output

Invert complete latent matrices, adding the training mean.

References

Han, Y., Chen, R., Zhang, C.-H. and Yao, Q. (2024). Simultaneous Decorrelation of Matrix Time Series. JASA, 119, 957-969. https://doi.org/10.1080/01621459.2022.2151448 Open manuscript: https://arxiv.org/abs/2103.09411 Equations (5)-(11); the ratio search bound follows the authors' journal supplement, https://doi.org/10.6084/m9.figshare.21641763.v2. The fixed correlation-threshold default is an uncalibrated convenience.

def blocks(self, X=None):
338    def blocks(self, X=None):
339        """Return nested row/column tuples of latent block time series.
340
341        With no argument, return the training blocks. Otherwise transform X
342        with the fitted parameters and then extract its blocks.
343
344        References
345        ----------
346        Han, Y., Chen, R., Zhang, C.-H. and Yao, Q. (2024).
347        Simultaneous Decorrelation of Matrix Time Series. JASA, 119, 957-969.
348        https://doi.org/10.1080/01621459.2022.2151448
349        Open manuscript: https://arxiv.org/abs/2103.09411
350        Equations (5)-(11); the ratio search bound follows the authors'
351        journal supplement, https://doi.org/10.6084/m9.figshare.21641763.v2.
352        The fixed correlation-threshold default is an uncalibrated convenience.
353        """
354        series = self.series if X is None else self.transform(X)
355        return tuple(
356            tuple(series[:, rows, :][:, :, columns] for columns in self.column_groups)
357            for rows in self.row_groups
358        )

Return nested row/column tuples of latent block time series.

With no argument, return the training blocks. Otherwise transform X with the fitted parameters and then extract its blocks.

References

Han, Y., Chen, R., Zhang, C.-H. and Yao, Q. (2024). Simultaneous Decorrelation of Matrix Time Series. JASA, 119, 957-969. https://doi.org/10.1080/01621459.2022.2151448 Open manuscript: https://arxiv.org/abs/2103.09411 Equations (5)-(11); the ratio search bound follows the authors' journal supplement, https://doi.org/10.6084/m9.figshare.21641763.v2. The fixed correlation-threshold default is an uncalibrated convenience.

def inverse_blocks(self, blocks):
360    def inverse_blocks(self, blocks):
361        """Reassemble and invert nested block predictions from ``blocks()``.
362
363        References
364        ----------
365        Han, Y., Chen, R., Zhang, C.-H. and Yao, Q. (2024).
366        Simultaneous Decorrelation of Matrix Time Series. JASA, 119, 957-969.
367        https://doi.org/10.1080/01621459.2022.2151448
368        Open manuscript: https://arxiv.org/abs/2103.09411
369        Equations (5)-(11); the ratio search bound follows the authors'
370        journal supplement, https://doi.org/10.6084/m9.figshare.21641763.v2.
371        The fixed correlation-threshold default is an uncalibrated convenience.
372        """
373        try:
374            block_rows = tuple(tuple(row) for row in blocks)
375        except TypeError as exc:
376            raise ValueError("blocks must be nested row/column sequences") from exc
377        if len(block_rows) != len(self.row_groups) or any(
378            len(row) != len(self.column_groups) for row in block_rows
379        ):
380            raise ValueError("block partition must match the fitted groups")
381        assembled_rows = []
382        n_time = None
383        for row, rows in zip(block_rows, self.row_groups):
384            arrays = []
385            for block, columns in zip(row, self.column_groups):
386                array = as_series(block, min_samples=1)
387                if array.shape[1:] != (len(rows), len(columns)):
388                    raise ValueError("block dimensions must match the fitted groups")
389                if n_time is None:
390                    n_time = len(array)
391                if len(array) != n_time:
392                    raise ValueError(
393                        "all blocks must have the same number of observations"
394                    )
395                arrays.append(array)
396            assembled_rows.append(np.concatenate(arrays, axis=2))
397        return self.inverse_transform(np.concatenate(assembled_rows, axis=1))

Reassemble and invert nested block predictions from blocks().

References

Han, Y., Chen, R., Zhang, C.-H. and Yao, Q. (2024). Simultaneous Decorrelation of Matrix Time Series. JASA, 119, 957-969. https://doi.org/10.1080/01621459.2022.2151448 Open manuscript: https://arxiv.org/abs/2103.09411 Equations (5)-(11); the ratio search bound follows the authors' journal supplement, https://doi.org/10.6084/m9.figshare.21641763.v2. The fixed correlation-threshold default is an uncalibrated convenience.

@dataclass
class SparseMARResult:
199@dataclass
200class SparseMARResult:
201    """Local EMVS mode and conditional, plug-in inclusion probabilities.
202
203    Inclusion probabilities condition on the fitted coefficients and other
204    parameters; they are not integrated posterior inclusion probabilities.
205    A/B are continuously shrunk, generally dense; support masks apply the
206    0.5 slab-probability rule without changing forecasts. Proper priors set
207    the factor scales. The likelihood alone identifies only B kron A and
208    column_covariance kron row_covariance.
209    Model-derived summaries and forecasts inherit the reference and
210    corrected-EMVS scope of :func:`fit_sparse_mar` and this module.
211    """
212
213    A: np.ndarray
214    B: np.ndarray
215    row_covariance: np.ndarray
216    column_covariance: np.ndarray
217    row_inclusion: np.ndarray
218    column_inclusion: np.ndarray
219    inclusion_weights: np.ndarray
220    covariance_scale: float
221    fitted_values: np.ndarray
222    residuals: np.ndarray
223    log_posterior_history: np.ndarray
224    converged: bool
225    n_iter: int
226    spike_variance: float
227    slab_variance: float
228    _history: np.ndarray = field(repr=False)
229
230    @property
231    def row_support(self):
232        return self.row_inclusion > 0.5
233
234    @property
235    def column_support(self):
236        return self.column_inclusion > 0.5
237
238    @property
239    def coefficients(self):
240        """Column-major VAR(1) coefficient with a leading lag axis."""
241        return np.kron(self.B, self.A)[None]
242
243    @property
244    def spectral_radius(self):
245        return float(
246            np.max(np.abs(np.linalg.eigvals(self.A)))
247            * np.max(np.abs(np.linalg.eigvals(self.B)))
248        )
249
250    @property
251    def is_stable(self):
252        """Stationarity diagnostic; stability is not imposed during fitting."""
253        return self.spectral_radius < 1
254
255    @property
256    def log_likelihood(self):
257        return _log_likelihood(
258            self.residuals, self.row_covariance, self.column_covariance
259        )
260
261    def forecast(self, steps, history=None):
262        """Recursive conditional-mean forecasts, without thresholding A/B."""
263        steps = positive_int(steps, "steps")
264        observed = (
265            self._history if history is None else as_series(history, min_samples=1)
266        )
267        if observed.shape[1:] != self.fitted_values.shape[1:]:
268            raise ValueError("history spatial dimensions must match the fitted series")
269        last = observed[-1].copy()
270        output = np.empty((steps,) + last.shape)
271        for t in range(steps):
272            last = self.A @ last @ self.B.T
273            if not np.isfinite(last).all():
274                raise FloatingPointError(
275                    "forecast overflow; check fitted stability and history scale"
276                )
277            output[t] = last
278        return output

Local EMVS mode and conditional, plug-in inclusion probabilities.

Inclusion probabilities condition on the fitted coefficients and other parameters; they are not integrated posterior inclusion probabilities. A/B are continuously shrunk, generally dense; support masks apply the 0.5 slab-probability rule without changing forecasts. Proper priors set the factor scales. The likelihood alone identifies only B kron A and column_covariance kron row_covariance. Model-derived summaries and forecasts inherit the reference and corrected-EMVS scope of fit_sparse_mar() and this module.

SparseMARResult( A: numpy.ndarray, B: numpy.ndarray, row_covariance: numpy.ndarray, column_covariance: numpy.ndarray, row_inclusion: numpy.ndarray, column_inclusion: numpy.ndarray, inclusion_weights: numpy.ndarray, covariance_scale: float, fitted_values: numpy.ndarray, residuals: numpy.ndarray, log_posterior_history: numpy.ndarray, converged: bool, n_iter: int, spike_variance: float, slab_variance: float, _history: numpy.ndarray)
A: numpy.ndarray
B: numpy.ndarray
row_covariance: numpy.ndarray
column_covariance: numpy.ndarray
row_inclusion: numpy.ndarray
column_inclusion: numpy.ndarray
inclusion_weights: numpy.ndarray
covariance_scale: float
fitted_values: numpy.ndarray
residuals: numpy.ndarray
log_posterior_history: numpy.ndarray
converged: bool
n_iter: int
spike_variance: float
slab_variance: float
row_support
230    @property
231    def row_support(self):
232        return self.row_inclusion > 0.5
column_support
234    @property
235    def column_support(self):
236        return self.column_inclusion > 0.5
coefficients
238    @property
239    def coefficients(self):
240        """Column-major VAR(1) coefficient with a leading lag axis."""
241        return np.kron(self.B, self.A)[None]

Column-major VAR(1) coefficient with a leading lag axis.

spectral_radius
243    @property
244    def spectral_radius(self):
245        return float(
246            np.max(np.abs(np.linalg.eigvals(self.A)))
247            * np.max(np.abs(np.linalg.eigvals(self.B)))
248        )
is_stable
250    @property
251    def is_stable(self):
252        """Stationarity diagnostic; stability is not imposed during fitting."""
253        return self.spectral_radius < 1

Stationarity diagnostic; stability is not imposed during fitting.

log_likelihood
255    @property
256    def log_likelihood(self):
257        return _log_likelihood(
258            self.residuals, self.row_covariance, self.column_covariance
259        )
def forecast(self, steps, history=None):
261    def forecast(self, steps, history=None):
262        """Recursive conditional-mean forecasts, without thresholding A/B."""
263        steps = positive_int(steps, "steps")
264        observed = (
265            self._history if history is None else as_series(history, min_samples=1)
266        )
267        if observed.shape[1:] != self.fitted_values.shape[1:]:
268            raise ValueError("history spatial dimensions must match the fitted series")
269        last = observed[-1].copy()
270        output = np.empty((steps,) + last.shape)
271        for t in range(steps):
272            last = self.A @ last @ self.B.T
273            if not np.isfinite(last).all():
274                raise FloatingPointError(
275                    "forecast overflow; check fitted stability and history scale"
276                )
277            output[t] = last
278        return output

Recursive conditional-mean forecasts, without thresholding A/B.

@dataclass
class ThresholdFactorResult:
109@dataclass
110class ThresholdFactorResult:
111    """Two-regime loading spaces and an auditable threshold search.
112
113    ``loadings[i]`` and ``eigenvalues[i]`` contain row and column quantities
114    for regime ``i``. ``factors[i]`` contains only that regime's observations,
115    in time order, allowing different ranks in each regime; ``indices[i]``
116    maps them to the original time axis. ``signal`` and ``residuals`` retain
117    the full input shape. Factors and signal are in original data units.
118
119    ``candidate_scores`` is equation (11) applied to ``X / data_scale``;
120    original-unit scores would be these values times ``data_scale**4``.
121    ``reference_loadings`` are estimated once from the two extreme subsets,
122    and are held fixed throughout the profile. No forecast law is fitted.
123    """
124
125    threshold: float
126    loadings: tuple
127    factors: tuple
128    signal: np.ndarray
129    residuals: np.ndarray
130    regimes: np.ndarray
131    eigenvalues: tuple
132    candidates: np.ndarray
133    candidate_scores: np.ndarray
134    threshold_bounds: tuple | None
135    reference_loadings: tuple | None
136    data_scale: float
137    max_lag: int
138    method: str = "threshold-factor"
139
140    @property
141    def ranks(self):
142        return tuple(tuple(q.shape[1] for q in pair) for pair in self.loadings)
143
144    @property
145    def indices(self):
146        return tuple(np.flatnonzero(self.regimes == i) for i in range(2))
147
148    def classify(self, z):
149        """Assign observed ``z`` values; equality belongs to regime one (upper).
150
151        References
152        ----------
153        Liu and Chen (2022), Identification and Estimation of Threshold
154        Matrix-Variate Factor Models. https://doi.org/10.1111/sjos.12576
155        This applies the fitted threshold; it does not predict future z.
156        """
157        raw = np.asarray(z)
158        if raw.ndim != 1:
159            raise ValueError("z must be a one-dimensional vector")
160        return (_threshold_variable(z, len(raw)) >= self.threshold).astype(int)
161
162    def transform(self, X, z):
163        """Project aligned new observations into regime-specific core arrays.
164
165        Returns ``(lower_regime_cores, upper_regime_cores)`` in each regime's
166        temporal order. The caller supplies contemporaneously available ``z``;
167        this method never estimates a threshold or refits loading spaces.
168
169        References
170        ----------
171        Liu and Chen (2022), Identification and Estimation of Threshold
172        Matrix-Variate Factor Models. https://doi.org/10.1111/sjos.12576
173        """
174        X = as_series(X, min_samples=1)
175        shape = tuple(q.shape[0] for q in self.loadings[0])
176        if X.shape[1:] != shape:
177            raise ValueError("observation dimensions must match fitted loadings")
178        regimes = self.classify(_threshold_variable(z, len(X)))
179        return tuple(
180            _stable_project(X[regimes == i], self.loadings[i]) for i in range(2)
181        )
182
183    def inverse_transform(self, factors, z):
184        """Reconstruct regime-specific cores into the time order specified by z.
185
186        References
187        ----------
188        Liu and Chen (2022), Identification and Estimation of Threshold
189        Matrix-Variate Factor Models. https://doi.org/10.1111/sjos.12576
190        This is fitted model reconstruction, not threshold-variable forecasting.
191        """
192        regimes = self.classify(z)
193        try:
194            factors = tuple(factors)
195        except TypeError as exc:
196            raise ValueError("factors must contain one core array per regime") from exc
197        if len(factors) != 2:
198            raise ValueError("factors must contain one core array per regime")
199        shape = tuple(q.shape[0] for q in self.loadings[0])
200        signal = np.empty((len(regimes), *shape))
201        for i, core in enumerate(factors):
202            core = as_series(core, min_samples=0)
203            if core.shape != (int(np.sum(regimes == i)), *self.ranks[i]):
204                raise ValueError("core shape must match regime count and fitted ranks")
205            signal[regimes == i] = _stable_project(core, self.loadings[i], inverse=True)
206        return signal
207
208    def reconstruct(self, X, z):
209        """Return fitted common components for aligned new observations.
210
211        References
212        ----------
213        Liu and Chen (2022), Identification and Estimation of Threshold
214        Matrix-Variate Factor Models. https://doi.org/10.1111/sjos.12576
215        Contemporaneous projection uses observed X and z, not a forecast law.
216        """
217        return self.inverse_transform(self.transform(X, z), z)

Two-regime loading spaces and an auditable threshold search.

loadings[i] and eigenvalues[i] contain row and column quantities for regime i. factors[i] contains only that regime's observations, in time order, allowing different ranks in each regime; indices[i] maps them to the original time axis. signal and residuals retain the full input shape. Factors and signal are in original data units.

candidate_scores is equation (11) applied to X / data_scale; original-unit scores would be these values times data_scale**4. reference_loadings are estimated once from the two extreme subsets, and are held fixed throughout the profile. No forecast law is fitted.

ThresholdFactorResult( threshold: float, loadings: tuple, factors: tuple, signal: numpy.ndarray, residuals: numpy.ndarray, regimes: numpy.ndarray, eigenvalues: tuple, candidates: numpy.ndarray, candidate_scores: numpy.ndarray, threshold_bounds: tuple | None, reference_loadings: tuple | None, data_scale: float, max_lag: int, method: str = 'threshold-factor')
threshold: float
loadings: tuple
factors: tuple
signal: numpy.ndarray
residuals: numpy.ndarray
regimes: numpy.ndarray
eigenvalues: tuple
candidates: numpy.ndarray
candidate_scores: numpy.ndarray
threshold_bounds: tuple | None
reference_loadings: tuple | None
data_scale: float
max_lag: int
method: str = 'threshold-factor'
ranks
140    @property
141    def ranks(self):
142        return tuple(tuple(q.shape[1] for q in pair) for pair in self.loadings)
indices
144    @property
145    def indices(self):
146        return tuple(np.flatnonzero(self.regimes == i) for i in range(2))
def classify(self, z):
148    def classify(self, z):
149        """Assign observed ``z`` values; equality belongs to regime one (upper).
150
151        References
152        ----------
153        Liu and Chen (2022), Identification and Estimation of Threshold
154        Matrix-Variate Factor Models. https://doi.org/10.1111/sjos.12576
155        This applies the fitted threshold; it does not predict future z.
156        """
157        raw = np.asarray(z)
158        if raw.ndim != 1:
159            raise ValueError("z must be a one-dimensional vector")
160        return (_threshold_variable(z, len(raw)) >= self.threshold).astype(int)

Assign observed z values; equality belongs to regime one (upper).

References

Liu and Chen (2022), Identification and Estimation of Threshold Matrix-Variate Factor Models. https://doi.org/10.1111/sjos.12576 This applies the fitted threshold; it does not predict future z.

def transform(self, X, z):
162    def transform(self, X, z):
163        """Project aligned new observations into regime-specific core arrays.
164
165        Returns ``(lower_regime_cores, upper_regime_cores)`` in each regime's
166        temporal order. The caller supplies contemporaneously available ``z``;
167        this method never estimates a threshold or refits loading spaces.
168
169        References
170        ----------
171        Liu and Chen (2022), Identification and Estimation of Threshold
172        Matrix-Variate Factor Models. https://doi.org/10.1111/sjos.12576
173        """
174        X = as_series(X, min_samples=1)
175        shape = tuple(q.shape[0] for q in self.loadings[0])
176        if X.shape[1:] != shape:
177            raise ValueError("observation dimensions must match fitted loadings")
178        regimes = self.classify(_threshold_variable(z, len(X)))
179        return tuple(
180            _stable_project(X[regimes == i], self.loadings[i]) for i in range(2)
181        )

Project aligned new observations into regime-specific core arrays.

Returns (lower_regime_cores, upper_regime_cores) in each regime's temporal order. The caller supplies contemporaneously available z; this method never estimates a threshold or refits loading spaces.

References

Liu and Chen (2022), Identification and Estimation of Threshold Matrix-Variate Factor Models. https://doi.org/10.1111/sjos.12576

def inverse_transform(self, factors, z):
183    def inverse_transform(self, factors, z):
184        """Reconstruct regime-specific cores into the time order specified by z.
185
186        References
187        ----------
188        Liu and Chen (2022), Identification and Estimation of Threshold
189        Matrix-Variate Factor Models. https://doi.org/10.1111/sjos.12576
190        This is fitted model reconstruction, not threshold-variable forecasting.
191        """
192        regimes = self.classify(z)
193        try:
194            factors = tuple(factors)
195        except TypeError as exc:
196            raise ValueError("factors must contain one core array per regime") from exc
197        if len(factors) != 2:
198            raise ValueError("factors must contain one core array per regime")
199        shape = tuple(q.shape[0] for q in self.loadings[0])
200        signal = np.empty((len(regimes), *shape))
201        for i, core in enumerate(factors):
202            core = as_series(core, min_samples=0)
203            if core.shape != (int(np.sum(regimes == i)), *self.ranks[i]):
204                raise ValueError("core shape must match regime count and fitted ranks")
205            signal[regimes == i] = _stable_project(core, self.loadings[i], inverse=True)
206        return signal

Reconstruct regime-specific cores into the time order specified by z.

References

Liu and Chen (2022), Identification and Estimation of Threshold Matrix-Variate Factor Models. https://doi.org/10.1111/sjos.12576 This is fitted model reconstruction, not threshold-variable forecasting.

def reconstruct(self, X, z):
208    def reconstruct(self, X, z):
209        """Return fitted common components for aligned new observations.
210
211        References
212        ----------
213        Liu and Chen (2022), Identification and Estimation of Threshold
214        Matrix-Variate Factor Models. https://doi.org/10.1111/sjos.12576
215        Contemporaneous projection uses observed X and z, not a forecast law.
216        """
217        return self.inverse_transform(self.transform(X, z), z)

Return fitted common components for aligned new observations.

References

Liu and Chen (2022), Identification and Estimation of Threshold Matrix-Variate Factor Models. https://doi.org/10.1111/sjos.12576 Contemporaneous projection uses observed X and z, not a forecast law.

def fit_matrix_decorrelation( X, *, lags=1, correlation_lags=10, correlation_threshold=0.2, grouping='threshold', ratio_delta=0.0, ratio_max_edges=None, center=True, rank_tol=None):
400def fit_matrix_decorrelation(
401    X,
402    *,
403    lags=1,
404    correlation_lags=10,
405    correlation_threshold=0.2,
406    grouping="threshold",
407    ratio_delta=0.0,
408    ratio_max_edges=None,
409    center=True,
410    rank_tol=None,
411):
412    """Fit Han et al.'s bilinear transform and group dependent coordinates.
413
414    Parameters
415    ----------
416    X : array_like, shape (time, rows, columns)
417        Finite real, weakly stationary observations. Both empirical marginal
418        covariance matrices must be numerically positive definite.
419    lags : int, default 1
420        Include all signed lags from -lags through +lags, including zero, in
421        the moment matrices. Must be positive and smaller than sample size.
422    correlation_lags : int, default 10
423        Maximum signed lag for grouping, also positive and below sample size.
424    correlation_threshold : float or pair, default 0.2
425        Connect two rows/columns when their equation (10) maximum absolute
426        cross-correlation exceeds this value. A pair specifies separate row
427        and column thresholds. The default is a convenience, not a calibrated
428        significance level or consistent automatic tuning rule. Inspect the
429        returned correlation matrices and assess sensitivity to the threshold.
430        All nonnegative finite thresholds are allowed: equation (10) can
431        exceed one in small samples because its denominators use all times.
432        Used only with ``grouping='threshold'``.
433    grouping : {"threshold", "ratio"}, default "threshold"
434        ``ratio`` selects the first maximizer of consecutive descending
435        correlation ratios (11), then connects that many strongest pairs.
436        The default search ends at floor(number_of_pairs/2), as in the authors'
437        journal supplement. It always selects at least one pair for each mode
438        of dimension at least three, so cannot select all singleton groups.
439        A scalar mode needs no selection; dimension two requires threshold
440        grouping because it has no adjacent pair of ordered correlations.
441    ratio_delta : float, default 0
442        Nonnegative smoothing constant added to both correlations in each
443        ratio. Zero matches the reference script; positive values implement
444        the ridge in (11). At zero, 0/0 uses its continuous-ridge limit one,
445        and positive/0 is infinite. Thus even all-zero scores select one edge.
446        Equal edge strengths are ordered lexicographically by coordinate pair.
447    ratio_max_edges : int, pair, or None
448        Optional upper bound on the ratio search, independently for rows and
449        columns. Bounds must be below the number of pairs. None uses the
450        reference half-pair bound. A singleton mode requires a None bound.
451    center : bool, default True
452        Subtract and retain the training mean. False requires zero-mean data.
453    rank_tol : float, optional
454        Relative singular-value cutoff for each marginal data matricization.
455        Defaults to sqrt(machine epsilon). No ridge or pseudoinverse is used.
456        Smaller values permit less stable marginal whitening.
457
458    Notes
459    -----
460    Each side's eigenanalysis uses *all* cross-row (respectively cross-column)
461    covariance matrices, not only the summed matrix autocovariance. No PCA
462    truncation is performed. Grouping also uses separate one-sided transforms
463    (9), not correlations of the fully transformed matrix. Connected graph
464    components define the row and column groups, contiguous after reordering.
465    Zero-variance scalar coordinates contribute zero to grouping correlations.
466
467    Repeated population moment eigenvalues across distinct blocks prevent
468    their identification (paper Remark 3). Finite samples, weak dependence, or
469    an unsuitable threshold can likewise merge or split true blocks. The
470    optional VAR prewhitening, recursive irregular segmentation and alternative
471    spectral functions are not implemented. The authors' reference script
472    uses nonnegative moment lags and trimmed variance estimates; here both
473    grouping modes use the signed-lag and full-variance equations (7) and (10).
474    Covariance computation streams one opposite-mode index at a time, using
475    O(max(rows*columns**2, columns*rows**2)) temporary storage in addition to
476    the data, with no (rows*columns)-square eigenproblem.
477
478    References
479    ----------
480    Han, Y., Chen, R., Zhang, C.-H. and Yao, Q. (2024).
481    Simultaneous Decorrelation of Matrix Time Series. JASA, 119, 957-969.
482    https://doi.org/10.1080/01621459.2022.2151448
483    Open manuscript: https://arxiv.org/abs/2103.09411
484    Equations (5)-(11); the ratio search bound follows the authors'
485    journal supplement, https://doi.org/10.6084/m9.figshare.21641763.v2.
486    The fixed correlation-threshold default is an uncalibrated convenience.
487    """
488    X = as_series(X)
489    lags = positive_int(lags, "lags")
490    correlation_lags = positive_int(correlation_lags, "correlation_lags")
491    if max(lags, correlation_lags) >= len(X):
492        raise ValueError("lags and correlation_lags must be smaller than sample size")
493    thresholds = _thresholds(correlation_threshold)
494    if not isinstance(grouping, str) or grouping not in ("threshold", "ratio"):
495        raise ValueError("grouping must be 'threshold' or 'ratio'")
496    ratio_delta = finite_scalar(ratio_delta, "ratio_delta", minimum=0)
497    ratio_bounds = _ratio_bounds(ratio_max_edges)
498    if not isinstance(center, (bool, np.bool_)):
499        raise ValueError("center must be boolean")
500    rank_tol = (
501        np.sqrt(np.finfo(float).eps)
502        if rank_tol is None
503        else finite_scalar(rank_tol, "rank_tol", minimum=0)
504    )
505    if not 0 < rank_tol < 1:
506        raise ValueError("rank_tol must lie strictly between zero and one")
507    data_scale = float(np.max(np.abs(X)))
508    if data_scale == 0:
509        raise ValueError("marginal covariances must have full numerical rank")
510    normalized = X / data_scale
511    mean_scaled = normalized.mean(axis=0) if center else np.zeros(X.shape[1:])
512    work = normalized - mean_scaled
513    roots, whiteners, moments, rotations, spectra, correlations, groups = (
514        [] for _ in range(7)
515    )
516    edge_counts, search_bounds = [], []
517    for oriented, name, threshold, bound in zip(
518        (work.transpose(0, 2, 1), work), ("row", "column"), thresholds, ratio_bounds
519    ):
520        root, whitener = _marginal_roots(oriented, rank_tol, name)
521        partial = oriented @ whitener
522        moment = _lag_moment(partial, lags)
523        values, vectors = np.linalg.eigh(moment)
524        values = np.maximum(values[::-1], 0)
525        vectors = _canonical_signs(vectors[:, ::-1])
526        correlation = _maximum_correlations(partial @ vectors, correlation_lags)
527        if grouping == "threshold":
528            original_groups = _connected_groups(correlation, threshold)
529            edge_count = int(np.count_nonzero(np.triu(correlation > threshold, k=1)))
530            search_bound = None
531        else:
532            original_groups, edge_count, search_bound = _ratio_groups(
533                correlation, ratio_delta, bound
534            )
535        edge_counts.append(edge_count)
536        search_bounds.append(search_bound)
537        permutation = np.array([index for group in original_groups for index in group])
538        roots.append(root)
539        whiteners.append(whitener)
540        moments.append(moment)
541        rotations.append(vectors[:, permutation])
542        spectra.append(values[permutation])
543        correlations.append(correlation[np.ix_(permutation, permutation)])
544        groups.append(_contiguous_groups(original_groups))
545    result = MatrixDecorrelationResult(
546        series=np.empty((0, *X.shape[1:])),
547        mean=mean_scaled * data_scale,
548        data_scale=data_scale,
549        row_rotation=rotations[0],
550        column_rotation=rotations[1],
551        row_root_scaled=roots[0],
552        column_root_scaled=roots[1],
553        row_whitener_scaled=whiteners[0],
554        column_whitener_scaled=whiteners[1],
555        row_moment=moments[0],
556        column_moment=moments[1],
557        row_eigenvalues=spectra[0],
558        column_eigenvalues=spectra[1],
559        row_correlations=correlations[0],
560        column_correlations=correlations[1],
561        row_groups=groups[0],
562        column_groups=groups[1],
563        row_n_edges=edge_counts[0],
564        column_n_edges=edge_counts[1],
565        lags=lags,
566        correlation_lags=correlation_lags,
567        correlation_threshold=thresholds if grouping == "threshold" else None,
568        grouping=grouping,
569        ratio_delta=ratio_delta,
570        ratio_max_edges=tuple(search_bounds),
571        center=bool(center),
572        rank_tol=rank_tol,
573        _mean_scaled=mean_scaled,
574        method=f"matrix-decorrelation-{grouping}",
575    )
576    result.series = result.transform(X)
577    return result

Fit Han et al.'s bilinear transform and group dependent coordinates.

Parameters
  • X (array_like, shape (time, rows, columns)): Finite real, weakly stationary observations. Both empirical marginal covariance matrices must be numerically positive definite.
  • lags (int, default 1): Include all signed lags from -lags through +lags, including zero, in the moment matrices. Must be positive and smaller than sample size.
  • correlation_lags (int, default 10): Maximum signed lag for grouping, also positive and below sample size.
  • correlation_threshold (float or pair, default 0.2): Connect two rows/columns when their equation (10) maximum absolute cross-correlation exceeds this value. A pair specifies separate row and column thresholds. The default is a convenience, not a calibrated significance level or consistent automatic tuning rule. Inspect the returned correlation matrices and assess sensitivity to the threshold. All nonnegative finite thresholds are allowed: equation (10) can exceed one in small samples because its denominators use all times. Used only with grouping='threshold'.
  • grouping ({"threshold", "ratio"}, default "threshold"): ratio selects the first maximizer of consecutive descending correlation ratios (11), then connects that many strongest pairs. The default search ends at floor(number_of_pairs/2), as in the authors' journal supplement. It always selects at least one pair for each mode of dimension at least three, so cannot select all singleton groups. A scalar mode needs no selection; dimension two requires threshold grouping because it has no adjacent pair of ordered correlations.
  • ratio_delta (float, default 0): Nonnegative smoothing constant added to both correlations in each ratio. Zero matches the reference script; positive values implement the ridge in (11). At zero, 0/0 uses its continuous-ridge limit one, and positive/0 is infinite. Thus even all-zero scores select one edge. Equal edge strengths are ordered lexicographically by coordinate pair.
  • ratio_max_edges (int, pair, or None): Optional upper bound on the ratio search, independently for rows and columns. Bounds must be below the number of pairs. None uses the reference half-pair bound. A singleton mode requires a None bound.
  • center (bool, default True): Subtract and retain the training mean. False requires zero-mean data.
  • rank_tol (float, optional): Relative singular-value cutoff for each marginal data matricization. Defaults to sqrt(machine epsilon). No ridge or pseudoinverse is used. Smaller values permit less stable marginal whitening.
Notes

Each side's eigenanalysis uses all cross-row (respectively cross-column) covariance matrices, not only the summed matrix autocovariance. No PCA truncation is performed. Grouping also uses separate one-sided transforms (9), not correlations of the fully transformed matrix. Connected graph components define the row and column groups, contiguous after reordering. Zero-variance scalar coordinates contribute zero to grouping correlations.

Repeated population moment eigenvalues across distinct blocks prevent their identification (paper Remark 3). Finite samples, weak dependence, or an unsuitable threshold can likewise merge or split true blocks. The optional VAR prewhitening, recursive irregular segmentation and alternative spectral functions are not implemented. The authors' reference script uses nonnegative moment lags and trimmed variance estimates; here both grouping modes use the signed-lag and full-variance equations (7) and (10). Covariance computation streams one opposite-mode index at a time, using O(max(rows*columns2, columns*rows2)) temporary storage in addition to the data, with no (rows*columns)-square eigenproblem.

References

Han, Y., Chen, R., Zhang, C.-H. and Yao, Q. (2024). Simultaneous Decorrelation of Matrix Time Series. JASA, 119, 957-969. https://doi.org/10.1080/01621459.2022.2151448 Open manuscript: https://arxiv.org/abs/2103.09411 Equations (5)-(11); the ratio search bound follows the authors' journal supplement, https://doi.org/10.6084/m9.figshare.21641763.v2. The fixed correlation-threshold default is an uncalibrated convenience.

def fit_sparse_mar( X, *, spike_variance=0.01, slab_variance=4.0, inclusion_prior=(1.0, 1.0), covariance_df=None, covariance_scale=None, scale_prior=(1.0, 1.0), initial=None, max_iter=500, tol=1e-07):
281def fit_sparse_mar(
282    X,
283    *,
284    spike_variance=0.01,
285    slab_variance=4.0,
286    inclusion_prior=(1.0, 1.0),
287    covariance_df=None,
288    covariance_scale=None,
289    scale_prior=(1.0, 1.0),
290    initial=None,
291    max_iter=500,
292    tol=1e-7,
293):
294    """Fit zero-mean MAR(1) using continuous spike-and-slab ECM/EMVS.
295
296    ``X`` is finite and time-first, with shape ``(T,m,n)``. Coefficient
297    priors are independent mixtures N(0, spike_variance)/N(0, slab_variance)
298    in both modes; these arguments are variances, not standard deviations.
299    Each mode has an estimated slab weight with Beta(*inclusion_prior).
300    Both Beta parameters must be >= 1 (finite conditional modes).
301
302    Covariance priors are Sigma_k|xi ~ IW(xi*Omega_k, nu_k), with default
303    Omega=(I_m/m,I_n/n), nu=(m+2,n+2); ``covariance_scale`` supplies the two
304    SPD Omega matrices, ``covariance_df`` supplies their degrees of freedom.
305    The shared xi has Gamma(shape, RATE), specified by ``scale_prior``.
306    Priors operate in the supplied data units: preprocessing changes the
307    statistical problem unless the covariance priors are also transformed.
308
309    ``initial=(A,B)`` optionally supplies the initial coefficients; otherwise
310    an unpenalized ALS fit initializes ECM. The scale of this starting pair
311    is balanced once for initialization only. Subsequent iterations retain
312    prior-selected scales, and exact conditional updates monotonically
313    increase the observed log posterior up to numerical error. ``tol`` is
314    the relative posterior-increment stopping tolerance. Local modes and
315    sensitivity to initial values/prior settings should be assessed by the
316    caller. There is no stationarity constraint or fitted intercept.
317
318    This is the MAR(1) EMVS branch of Celani et al. (2024), with mathematical
319    corrections described in docs/sparse-notes.md. MCMC posterior draws,
320    credible intervals, and the paper's MAR*(P) branch are not implemented.
321
322    References
323    ----------
324    Celani, A., Pagnottoni, P. and Jones, G. (2024), "Bayesian Variable
325    Selection for Matrix Autoregressive Models", Sections 3--4 and Appendix C,
326    https://doi.org/10.1007/s11222-024-10402-y.
327    Exact conditional scale maximizations replace the paper's arbitrary
328    post-update rescaling, which would change the prior. The default uniform
329    Beta inclusion prior also differs from its simulation-specific settings.
330    """
331    X = as_series(X, min_samples=3)
332    if np.array_equal(X, np.broadcast_to(X[0], X.shape)):
333        raise ValueError(
334            "temporally constant data cannot identify a nondegenerate innovation covariance"
335        )
336    max_iter = positive_int(max_iter, "max_iter")
337    tol = _positive(tol, "tol")
338    spike = _positive(spike_variance, "spike_variance")
339    slab = _positive(slab_variance, "slab_variance")
340    if spike >= slab:
341        raise ValueError("spike_variance must be less than slab_variance")
342    beta = _pair(inclusion_prior, "inclusion_prior", minimum=1)
343    gamma = _pair(scale_prior, "scale_prior")
344    if min(gamma) <= 0:
345        raise ValueError("scale_prior shape and rate must be strictly positive")
346    m, n = X.shape[1:]
347    dfs = (
348        (m + 2.0, n + 2.0)
349        if covariance_df is None
350        else _pair(covariance_df, "covariance_df")
351    )
352    if any(nu <= d - 1 for nu, d in zip(dfs, (m, n))):
353        raise ValueError("covariance_df must exceed dimension minus one")
354    if gamma[0] - 1 + 0.5 * (m * dfs[0] + n * dfs[1]) <= 0:
355        raise ValueError(
356            "scale prior and covariance_df must give a positive interior xi mode"
357        )
358    if covariance_scale is None:
359        omegas = (np.eye(m) / m, np.eye(n) / n)
360    else:
361        covariance_scale = _matrix_pair(covariance_scale, "covariance_scale")
362        omegas = tuple(
363            _matrix(cov, d, "covariance_scale", positive_definite=True)
364            for cov, d in zip(covariance_scale, (m, n))
365        )
366    if initial is None:
367        start = fit_mar(X, method="als", max_iter=50)
368        A, B = start.A.copy(), start.B.copy()
369        norm_a, norm_b = np.linalg.norm(A), np.linalg.norm(B)
370        if norm_a > 0 and norm_b > 0:
371            balance = np.sqrt(norm_b / norm_a)
372            A, B = A * balance, B / balance
373    else:
374        initial = _matrix_pair(initial, "initial")
375        A, B = (_matrix(v, d, "initial") for v, d in zip(initial, (m, n)))
376    covs = (np.eye(m), np.eye(n))
377    xi = gamma[0] / gamma[1]
378    theta = np.full(2, beta[0] / (beta[0] + beta[1]))
379    Y, Z = X[1:], X[:-1]
380
381    def objective():
382        return _log_posterior(
383            Y, Z, A, B, covs, theta, xi, spike, slab, beta, omegas, dfs, gamma
384        )
385
386    objectives = [objective()]
387    if not np.isfinite(objectives[-1]):
388        raise FloatingPointError(
389            "initial sparse MAR posterior is not finite; check data and prior scales"
390        )
391    converged = False
392    for iteration in range(1, max_iter + 1):
393        p_a = _mixture(A, theta[0], spike, slab)[0]
394        p_b = _mixture(B, theta[1], spike, slab)[0]
395        precision_a = (1 - p_a) / spike + p_a / slab
396        precision_b = (1 - p_b) / spike + p_b / slab
397        A = _coefficient_update(Y, Z, B, *covs, precision_a)
398        B = _coefficient_update(
399            Y.transpose(0, 2, 1),
400            Z.transpose(0, 2, 1),
401            A,
402            covs[1],
403            covs[0],
404            precision_b,
405        )
406        A, B = _balance_coefficients(A, B, precision_a, precision_b)
407        theta = np.array(
408            [
409                (prob.sum() + beta[0] - 1) / (prob.size + beta[0] + beta[1] - 2)
410                for prob in (p_a, p_b)
411            ]
412        )
413        residuals = Y - A @ Z @ B.T
414        row = _covariance_mode(residuals, covs[1], omegas[0], xi, dfs[0])
415        col = _covariance_mode(residuals.transpose(0, 2, 1), row, omegas[1], xi, dfs[1])
416        covs = _balance_covariances((row, col), omegas, dfs, xi)
417        xi = _scale_mode(covs, omegas, dfs, *gamma)
418        value = objective()
419        if not np.isfinite(value):
420            raise FloatingPointError(
421                "sparse MAR posterior became nonfinite; check data and prior scales"
422            )
423        improvement = value - objectives[-1]
424        if improvement < -1e-9 * (1 + abs(objectives[-1])):
425            raise FloatingPointError(
426                "sparse MAR ECM posterior decreased beyond numerical tolerance"
427            )
428        objectives.append(value)
429        if abs(improvement) <= tol * (1 + abs(objectives[-2])):
430            converged = True
431            break
432    # The joint sign change preserves both centered coefficient priors.
433    if A.flat[np.argmax(np.abs(A))] < 0:
434        A, B = -A, -B
435    fitted = A @ Z @ B.T
436    return SparseMARResult(
437        A=A,
438        B=B,
439        row_covariance=covs[0],
440        column_covariance=covs[1],
441        row_inclusion=_mixture(A, theta[0], spike, slab)[0],
442        column_inclusion=_mixture(B, theta[1], spike, slab)[0],
443        inclusion_weights=theta,
444        covariance_scale=xi,
445        fitted_values=fitted,
446        residuals=Y - fitted,
447        log_posterior_history=np.asarray(objectives),
448        converged=converged,
449        n_iter=iteration,
450        spike_variance=spike,
451        slab_variance=slab,
452        _history=X[-1:].copy(),
453    )

Fit zero-mean MAR(1) using continuous spike-and-slab ECM/EMVS.

X is finite and time-first, with shape (T,m,n). Coefficient priors are independent mixtures N(0, spike_variance)/N(0, slab_variance) in both modes; these arguments are variances, not standard deviations. Each mode has an estimated slab weight with Beta(*inclusion_prior). Both Beta parameters must be >= 1 (finite conditional modes).

Covariance priors are Sigma_k|xi ~ IW(xi*Omega_k, nu_k), with default Omega=(I_m/m,I_n/n), nu=(m+2,n+2); covariance_scale supplies the two SPD Omega matrices, covariance_df supplies their degrees of freedom. The shared xi has Gamma(shape, RATE), specified by scale_prior. Priors operate in the supplied data units: preprocessing changes the statistical problem unless the covariance priors are also transformed.

initial=(A,B) optionally supplies the initial coefficients; otherwise an unpenalized ALS fit initializes ECM. The scale of this starting pair is balanced once for initialization only. Subsequent iterations retain prior-selected scales, and exact conditional updates monotonically increase the observed log posterior up to numerical error. tol is the relative posterior-increment stopping tolerance. Local modes and sensitivity to initial values/prior settings should be assessed by the caller. There is no stationarity constraint or fitted intercept.

This is the MAR(1) EMVS branch of Celani et al. (2024), with mathematical corrections described in docs/sparse-notes.md. MCMC posterior draws, credible intervals, and the paper's MAR*(P) branch are not implemented.

References

Celani, A., Pagnottoni, P. and Jones, G. (2024), "Bayesian Variable Selection for Matrix Autoregressive Models", Sections 3--4 and Appendix C, https://doi.org/10.1007/s11222-024-10402-y. Exact conditional scale maximizations replace the paper's arbitrary post-update rescaling, which would change the prior. The default uniform Beta inclusion prior also differs from its simulation-specific settings.

def fit_threshold_factors( X, z, ranks=None, *, threshold=None, max_lag=1, trim=(0.1, 0.9), threshold_bounds=None, candidates=None):
220def fit_threshold_factors(
221    X,
222    z,
223    ranks=None,
224    *,
225    threshold=None,
226    max_lag=1,
227    trim=(0.1, 0.9),
228    threshold_bounds=None,
229    candidates=None,
230):
231    """Fit Liu and Chen's two-regime matrix-variate factor model (2022).
232
233    Parameters
234    ----------
235    X : array_like, shape (T, p, q)
236        Finite observations. The published estimator uses uncentered cross
237        moments and assumes serially uncorrelated idiosyncratic noise, and
238        sufficiently informative nonzero lagged factor cross moments.
239    z : array_like, shape (T,)
240        Observed threshold variable aligned with ``X[t]``. Regime zero means
241        ``z[t] < threshold``; equality belongs to regime one. For a lagged
242        threshold variable, align and truncate both arrays before calling.
243        In forecasting applications z must be available at the forecast
244        origin; the function cannot validate the provenance of supplied z.
245    ranks : pair of (row_rank, column_rank), optional
246        One rank pair per regime, e.g. ``((1, 2), (2, 1))``. Missing entries
247        use the equation (12) eigenvalue ratio, searching to half the smaller
248        of T and the mode dimension. In threshold search these ranks are
249        selected from extreme subsets and fixed for the final fit.
250    threshold : float, optional
251        Known threshold. Otherwise estimate it by a trimmed profile search.
252    max_lag : int, default 1
253        Include all temporal lags from one through this value, less than T.
254    trim : pair of float, default (0.1, 0.9)
255        Quantiles defining the two extreme subsets and open search interval.
256        The true threshold must lie inside this interval; this is an assumption
257        of the method, not something the data can guarantee.
258    threshold_bounds : pair of float, optional
259        Explicit extreme cutoffs instead of quantiles. Uses ``z < lower`` and
260        ``z >= upper``. Both must have lagged origins represented in the data.
261    candidates : vector of float, optional
262        Search these thresholds inside the open bounds. By default search
263        every unique observed z inside the bounds, as in the paper. Exact
264        score ties select the smallest candidate. A supplied grid is a
265        deliberate approximation to the paper's complete observed-value search.
266
267    Notes
268    -----
269    Implements equations (9)-(12) and Section 2.3 of the author manuscript
270    https://par.nsf.gov/servlets/purl/10351724 corresponding to Liu and Chen,
271    *Identification and estimation of threshold matrix-variate factor models*,
272    Scandinavian Journal of Statistics 49, 1383-1417 (2022),
273    https://doi.org/10.1111/sjos.12576.
274
275    References
276    ----------
277    Liu and Chen (2022), Identification and Estimation of Threshold
278    Matrix-Variate Factor Models. https://doi.org/10.1111/sjos.12576
279
280    For every source regime, temporal lag and pair of matrix columns, form
281    ``Omega = sum_t X[t,:,u] X[t+h,:,v].T I(z[t] in regime) / T``.
282    The row moment sums ``Omega @ Omega.T``; the column moment repeats this
283    calculation on transposes. Unlike the earlier 2019 preprint, the *future*
284    threshold value does not partition these moments. For unknown threshold,
285    estimate complementary loading spaces from the two extreme subsets, then
286    minimize the sum of the four spectral norms ``||B.T M(r) B||_2``.
287
288    This API fits one threshold and two regimes, including unequal ranks. The
289    paper's multi-threshold and threshold-variable-selection extensions are
290    not implemented. No confidence interval or statistical threshold-existence
291    test is implied by the profile. Weak dynamics or nested regime spaces can
292    make the threshold poorly identified. Storage per moment is O(p*q*max(p,q)),
293    with an exhaustive search costing one moment evaluation per candidate.
294    """
295    X = as_series(X, min_samples=3)
296    z = _threshold_variable(z, len(X))
297    max_lag = positive_int(max_lag, "max_lag")
298    if max_lag >= len(X):
299        raise ValueError("max_lag must be smaller than the observation count")
300    ranks = _regime_ranks(ranks, X.shape[1:])
301    scale = max(float(np.max(np.abs(X))), np.finfo(float).tiny)
302    work = X / scale
303    reference = None
304    bounds = None
305    grid = np.empty(0)
306    scores = np.empty(0)
307    if threshold is not None:
308        threshold = finite_scalar(threshold, "threshold")
309        if threshold_bounds is not None or candidates is not None:
310            raise ValueError("search options cannot be combined with known threshold")
311    else:
312        if threshold_bounds is None:
313            trim = _threshold_variable(trim, 2)
314            if not 0 < trim[0] < trim[1] < 1:
315                raise ValueError("trim must satisfy 0 < lower < upper < 1")
316            # Quantiles on a scaled variable avoid overflow in interpolation.
317            z_scale = max(float(np.max(np.abs(z))), np.finfo(float).tiny)
318            bounds = tuple(np.quantile(z / z_scale, trim) * z_scale)
319        else:
320            bounds = tuple(_threshold_variable(threshold_bounds, 2))
321        if not bounds[0] < bounds[1]:
322            raise ValueError("threshold bounds must be strictly increasing")
323        extreme_masks = (z < bounds[0], z >= bounds[1])
324        if any(not np.any(mask[:-1]) for mask in extreme_masks):
325            raise ValueError("each extreme regime needs a lagged origin observation")
326        moments = _moments(work, extreme_masks, max_lag)
327        reference, _, complements = _spaces(moments, ranks, len(X))
328        ranks = tuple(tuple(q.shape[1] for q in pair) for pair in reference)
329        if any(all(b.shape[1] == 0 for b in pair) for pair in complements):
330            raise ValueError(
331                "threshold search needs a loading complement in each regime"
332            )
333        if candidates is None:
334            grid = np.unique(z[(z > bounds[0]) & (z < bounds[1])])
335        else:
336            raw_grid = np.asarray(candidates)
337            if raw_grid.ndim != 1:
338                raise ValueError("candidates must be a finite nonempty vector")
339            grid = np.unique(_threshold_variable(raw_grid, len(raw_grid)))
340            if np.any((grid <= bounds[0]) | (grid >= bounds[1])):
341                raise ValueError("candidates must lie strictly inside threshold bounds")
342        if not len(grid):
343            raise ValueError("no candidate thresholds inside the trimmed interval")
344        scores = np.array(
345            [
346                _score(_moments(work, (z < r, z >= r), max_lag), complements)
347                for r in grid
348            ]
349        )
350        threshold = float(grid[np.argmin(scores)])
351    regimes = (z >= threshold).astype(int)
352    masks = tuple(regimes == i for i in range(2))
353    if any(not np.any(mask[:-1]) for mask in masks):
354        raise ValueError("each regime needs a lagged origin observation")
355    moments = _moments(work, masks, max_lag)
356    loadings, spectra, _ = _spaces(moments, ranks, len(X))
357    factors = tuple(
358        _stable_project(X[mask], pair) for mask, pair in zip(masks, loadings)
359    )
360    signal = np.empty_like(X)
361    for i in range(2):
362        signal[masks[i]] = _stable_project(factors[i], loadings[i], inverse=True)
363    with np.errstate(over="ignore", invalid="ignore"):
364        residuals = X - signal
365    if not np.isfinite(residuals).all():
366        raise ValueError("residuals exceed the finite numeric range")
367    return ThresholdFactorResult(
368        threshold,
369        loadings,
370        factors,
371        signal,
372        residuals,
373        regimes,
374        spectra,
375        grid,
376        scores,
377        bounds,
378        reference,
379        scale,
380        max_lag,
381    )

Fit Liu and Chen's two-regime matrix-variate factor model (2022).

Parameters
  • X (array_like, shape (T, p, q)): Finite observations. The published estimator uses uncentered cross moments and assumes serially uncorrelated idiosyncratic noise, and sufficiently informative nonzero lagged factor cross moments.
  • z (array_like, shape (T,)): Observed threshold variable aligned with X[t]. Regime zero means z[t] < threshold; equality belongs to regime one. For a lagged threshold variable, align and truncate both arrays before calling. In forecasting applications z must be available at the forecast origin; the function cannot validate the provenance of supplied z.
  • ranks (pair of (row_rank, column_rank), optional): One rank pair per regime, e.g. ((1, 2), (2, 1)). Missing entries use the equation (12) eigenvalue ratio, searching to half the smaller of T and the mode dimension. In threshold search these ranks are selected from extreme subsets and fixed for the final fit.
  • threshold (float, optional): Known threshold. Otherwise estimate it by a trimmed profile search.
  • max_lag (int, default 1): Include all temporal lags from one through this value, less than T.
  • trim (pair of float, default (0.1, 0.9)): Quantiles defining the two extreme subsets and open search interval. The true threshold must lie inside this interval; this is an assumption of the method, not something the data can guarantee.
  • threshold_bounds (pair of float, optional): Explicit extreme cutoffs instead of quantiles. Uses z < lower and z >= upper. Both must have lagged origins represented in the data.
  • candidates (vector of float, optional): Search these thresholds inside the open bounds. By default search every unique observed z inside the bounds, as in the paper. Exact score ties select the smallest candidate. A supplied grid is a deliberate approximation to the paper's complete observed-value search.
Notes

Implements equations (9)-(12) and Section 2.3 of the author manuscript https://par.nsf.gov/servlets/purl/10351724 corresponding to Liu and Chen, Identification and estimation of threshold matrix-variate factor models, Scandinavian Journal of Statistics 49, 1383-1417 (2022), https://doi.org/10.1111/sjos.12576.

References

Liu and Chen (2022), Identification and Estimation of Threshold Matrix-Variate Factor Models. https://doi.org/10.1111/sjos.12576

For every source regime, temporal lag and pair of matrix columns, form Omega = sum_t X[t,:,u] X[t+h,:,v].T I(z[t] in regime) / T. The row moment sums Omega @ Omega.T; the column moment repeats this calculation on transposes. Unlike the earlier 2019 preprint, the future threshold value does not partition these moments. For unknown threshold, estimate complementary loading spaces from the two extreme subsets, then minimize the sum of the four spectral norms ||B.T M(r) B||_2.

This API fits one threshold and two regimes, including unequal ranks. The paper's multi-threshold and threshold-variable-selection extensions are not implemented. No confidence interval or statistical threshold-existence test is implied by the profile. Weak dynamics or nested regime spaces can make the threshold poorly identified. Storage per moment is O(pqmax(p,q)), with an exhaustive search costing one moment evaluation per candidate.

def mar_inference( X, result=None, *, method='als', sign_anchor=(0, 0), condition_limit=10000000000.0, max_dimension=32):
181def mar_inference(
182    X,
183    result=None,
184    *,
185    method="als",
186    sign_anchor=(0, 0),
187    condition_limit=1e10,
188    max_dimension=32,
189):
190    """Plug-in MAR(1) standard errors for projection, ALS or separable MLE.
191
192    Implements Theorems 2--4 of Chen, Xiao and Yang (2021), with sample means
193    replacing expectations and N=T-1 effective observations. Public parameter
194    ordering consistently uses vec(B), applying the permutation needed by the
195    paper's vec(B.T) ALS/MLE formula. A common data scale is removed before
196    computing moments; coefficient standard errors are invariant to data units.
197
198    If ``result`` is omitted, fit the named method at a tight optimization
199    tolerance. A supplied result must match X and be converged, stable, without
200    intercept, ridge or reduced-rank constraints. MLE covariance flooring must
201    be inactive. Innovations must be iid with finite second moments and, for
202    the ALS/MLE theorems, absolutely continuous (the paper's Condition R).
203    This is not a HAC covariance or
204    post-selection inference. Correct separability is required for MLE inference.
205
206    ``max_dimension`` bounds rows*columns before dense allocations, protecting
207    against accidental (mn)^4 memory use in projection inference. Raise it
208    deliberately for larger problems. Numerically ill-conditioned sample
209    problems are rejected; sample guards cannot establish population identification.
210    An asymptotic interval is not a finite-sample coverage guarantee.
211
212    ``sign_anchor`` is a fixed (row,column) index of A whose true value must be
213    nonzero. A/B are jointly sign-flipped in a copied result to make this entry
214    positive. This avoids the discontinuity of the estimator's largest-entry
215    sign rule at tied opposite-signed entries. The caller's result is unchanged.
216
217    References
218    ----------
219    Chen, R., Xiao, H. and Yang, D. (2021), "Autoregressive Models for
220    Matrix-Valued Time Series", Theorems 2--4,
221    https://doi.org/10.1016/j.jeconom.2020.07.015.
222    This is sample-moment plug-in inference with an explicit fixed sign
223    chart, not a finite-sample, HAC or post-selection extension.
224    """
225    X = as_series(X, min_samples=3)
226    m, n = X.shape[1:]
227    try:
228        anchor = tuple(
229            positive_int(v, "sign_anchor index", minimum=0) for v in sign_anchor
230        )
231    except TypeError as exc:
232        raise ValueError("sign_anchor must be a pair of matrix indices") from exc
233    if len(anchor) != 2 or max(anchor) >= m:
234        raise ValueError("sign_anchor must index the left coefficient matrix")
235    maximum = positive_int(max_dimension, "max_dimension")
236    limit = finite_scalar(condition_limit, "condition_limit")
237    if limit <= 1:
238        raise ValueError("condition_limit must exceed one")
239    if m * n > maximum:
240        raise ValueError(
241            "matrix vector dimension exceeds max_dimension for dense inference"
242        )
243    if result is None:
244        result = fit_mar(X, method=method, max_iter=1000, tol=1e-11)
245    if not isinstance(result, MARResult) or result.order != 1:
246        raise ValueError("result must be a MAR(1) fit")
247    if (
248        result.method not in ("als", "projection", "mle")
249        or result.ridge
250        or result.ranks is not None
251    ):
252        raise ValueError("inference requires an unconstrained unpenalized MAR fit")
253    if (
254        result.A.shape != (m, m)
255        or result.B.shape != (n, n)
256        or result.residuals.shape != X[1:].shape
257    ):
258        raise ValueError("result dimensions do not match X")
259    if np.any(result.intercept != 0):
260        raise ValueError("the published inference here requires a zero-intercept fit")
261    if not result.converged or not result.is_stable:
262        raise ValueError("inference requires a converged stationary fit")
263    if result.covariance_regularized:
264        raise ValueError(
265            "the published MLE inference requires inactive covariance flooring"
266        )
267    if abs(result.A[anchor]) <= np.finfo(float).eps * m:
268        raise ValueError(
269            "sign anchor is numerically zero; choose a nonzero population coefficient"
270        )
271    if result.A[anchor] < 0:
272        result = replace(result, left=-result.left, right=-result.right)
273    A, B = result.A, result.B
274    _condition(A, limit, "left coefficient")
275    _condition(B, limit, "right coefficient")
276    if not np.isclose(np.linalg.norm(A), 1.0, rtol=1e-10):
277        raise ValueError("left coefficient must have unit Frobenius norm")
278    scale = float(np.max(np.abs(X))) or 1.0
279    work = X / scale
280    predicted = A @ work[:-1] @ B.T
281    residuals = work[1:] - predicted
282    if not np.allclose(result.residuals / scale, residuals, atol=1e-10, rtol=1e-8):
283        raise ValueError("result was not fitted to the supplied observations")
284    N, d, q = len(X) - 1, m * n, m * m + n * n
285    alpha, beta = A.ravel(order="F"), B.ravel(order="F")
286    if result.method == "projection":
287        _, xi = _var_moments(work, limit)
288        indices = _rearrangement_indices(m, n)
289        xi = xi[np.ix_(indices, indices)]
290        beta_norm = np.linalg.norm(beta)
291        v0 = np.concatenate(
292            (
293                np.kron(
294                    (beta / beta_norm)[None], np.eye(m * m) - np.outer(alpha, alpha)
295                )
296                / beta_norm,
297                np.kron(np.eye(n * n), alpha[None]),
298            ),
299            axis=0,
300        )
301        covariance = v0 @ xi @ v0.T / N
302        condition = _condition(work[:-1].reshape(N, d), limit, "VAR design")
303    else:
304        flat = residuals.transpose(0, 2, 1).reshape(N, d)
305        if result.method == "mle":
306            sigma = np.kron(
307                result.column_covariance / scale, result.row_covariance / scale
308            )
309        else:
310            sigma = flat.T @ flat / N
311        sigma = _positive_definite(sigma, limit, "innovation covariance")
312        gram, meat = np.zeros((q, q)), np.zeros((q, q))
313        for start in range(0, N, 128):
314            J = _jacobian_batch(work[start : min(start + 128, N)], A, B)
315            if result.method == "mle":
316                weighted = np.linalg.solve(sigma, J.transpose(1, 0, 2).reshape(d, -1))
317                weighted = weighted.reshape(d, len(J), q).transpose(1, 0, 2)
318                gram += np.einsum("tdi,tdj->ij", J, weighted, optimize=True) / N
319            else:
320                gram += np.einsum("tdi,tdj->ij", J, J, optimize=True) / N
321                meat += np.einsum("tdi,de,tej->ij", J, sigma, J, optimize=True) / N
322        if result.method == "mle":
323            meat = gram.copy()
324        gamma = np.r_[alpha, np.zeros(n * n)]
325        H = _positive_definite(
326            gram + np.outer(gamma, gamma), limit, "constrained information"
327        )
328        condition = _condition(H, limit, "constrained information")
329        covariance = np.linalg.solve(H, np.linalg.solve(H, meat).T).T / N
330    covariance = (covariance + covariance.T) / 2
331    derivative = _operator_jacobian(A, B)
332    variances = np.einsum(
333        "ij,jk,ik->i", derivative, covariance, derivative, optimize=True
334    )
335    errors = np.sqrt(np.maximum(variances, 0)).reshape(d, d, order="F")
336    return MARInferenceResult(
337        result, covariance, errors, condition, N, anchor, derivative
338    )

Plug-in MAR(1) standard errors for projection, ALS or separable MLE.

Implements Theorems 2--4 of Chen, Xiao and Yang (2021), with sample means replacing expectations and N=T-1 effective observations. Public parameter ordering consistently uses vec(B), applying the permutation needed by the paper's vec(B.T) ALS/MLE formula. A common data scale is removed before computing moments; coefficient standard errors are invariant to data units.

If result is omitted, fit the named method at a tight optimization tolerance. A supplied result must match X and be converged, stable, without intercept, ridge or reduced-rank constraints. MLE covariance flooring must be inactive. Innovations must be iid with finite second moments and, for the ALS/MLE theorems, absolutely continuous (the paper's Condition R). This is not a HAC covariance or post-selection inference. Correct separability is required for MLE inference.

max_dimension bounds rows*columns before dense allocations, protecting against accidental (mn)^4 memory use in projection inference. Raise it deliberately for larger problems. Numerically ill-conditioned sample problems are rejected; sample guards cannot establish population identification. An asymptotic interval is not a finite-sample coverage guarantee.

sign_anchor is a fixed (row,column) index of A whose true value must be nonzero. A/B are jointly sign-flipped in a copied result to make this entry positive. This avoids the discontinuity of the estimator's largest-entry sign rule at tied opposite-signed entries. The caller's result is unchanged.

References

Chen, R., Xiao, H. and Yang, D. (2021), "Autoregressive Models for Matrix-Valued Time Series", Theorems 2--4, https://doi.org/10.1016/j.jeconom.2020.07.015. This is sample-moment plug-in inference with an explicit fixed sign chart, not a finite-sample, HAC or post-selection extension.

def mar_specification_test(X, *, condition_limit=10000000000.0, max_dimension=16):
355def mar_specification_test(X, *, condition_limit=1e10, max_dimension=16):
356    """Test VAR(1) coefficient Phi = B ⊗ A using the paper's Section 4.2.
357
358    Requires a stationary zero-mean process with iid nonsingular innovations.
359    Under the null, both Kronecker factors must be nonsingular. The statistic
360    has asymptotic chi-square df=(m²-1)(n²-1). A small p-value rejects that
361    coefficient structure; a large p-value does not establish it.
362
363    Uses N=T-1 and sample second moments. Instead of a numerically singular
364    projected covariance and a pseudoinverse, an orthonormal basis of its
365    known normal space gives an equivalent positive-definite solve. Dense
366    covariance storage scales as (m*n)^4. Both modes must exceed one.
367
368    References
369    ----------
370    Chen, R., Xiao, H. and Yang, D. (2021), "Autoregressive Models for
371    Matrix-Valued Time Series", Section 4.2,
372    https://doi.org/10.1016/j.jeconom.2020.07.015.
373    The normal-space solve is an algebraic implementation of the paper's
374    statistic, not a differently calibrated test.
375    """
376    X = as_series(X, min_samples=3)
377    m, n = X.shape[1:]
378    limit = finite_scalar(condition_limit, "condition_limit")
379    if limit <= 1:
380        raise ValueError("condition_limit must exceed one")
381    if min(m, n) <= 1:
382        raise ValueError("both matrix dimensions must exceed one for a nontrivial test")
383    if m * n > positive_int(max_dimension, "max_dimension"):
384        raise ValueError("matrix vector dimension exceeds max_dimension for dense test")
385    scale = float(np.max(np.abs(X))) or 1.0
386    coefficient, xi = _var_moments(X / scale, limit)
387    if np.max(abs(np.linalg.eigvals(coefficient))) >= 1:
388        raise ValueError("unrestricted VAR fit is not stationary")
389    A, B = nearest_kronecker_product(coefficient, (m, n))
390    _condition(A, limit, "projected left coefficient")
391    _condition(B, limit, "projected right coefficient")
392    alpha, beta = A.ravel(order="F"), B.ravel(order="F").copy()
393    beta /= np.linalg.norm(beta)
394    qa = np.linalg.svd(alpha[None], full_matrices=True)[2][1:].T
395    qb = np.linalg.svd(beta[None], full_matrices=True)[2][1:].T
396    normal = np.kron(qb, qa)
397    indices = _rearrangement_indices(m, n)
398    xi = xi[np.ix_(indices, indices)]
399    projected = _positive_definite(
400        normal.T @ xi @ normal, limit, "normal-space covariance"
401    )
402    deviation = _rearrange_phi(coefficient - np.kron(B, A), m, n).ravel(order="F")
403    residual = normal.T @ deviation
404    statistic = float((len(X) - 1) * residual @ np.linalg.solve(projected, residual))
405    df = (m * m - 1) * (n * n - 1)
406    return MARSpecificationResult(
407        statistic,
408        float(chi2.sf(statistic, df)),
409        df,
410        A,
411        B,
412        coefficient,
413        _condition(projected, limit, "normal-space covariance"),
414        len(X) - 1,
415    )

Test VAR(1) coefficient Phi = B ⊗ A using the paper's Section 4.2.

Requires a stationary zero-mean process with iid nonsingular innovations. Under the null, both Kronecker factors must be nonsingular. The statistic has asymptotic chi-square df=(m²-1)(n²-1). A small p-value rejects that coefficient structure; a large p-value does not establish it.

Uses N=T-1 and sample second moments. Instead of a numerically singular projected covariance and a pseudoinverse, an orthonormal basis of its known normal space gives an equivalent positive-definite solve. Dense covariance storage scales as (m*n)^4. Both modes must exceed one.

References

Chen, R., Xiao, H. and Yang, D. (2021), "Autoregressive Models for Matrix-Valued Time Series", Section 4.2, https://doi.org/10.1016/j.jeconom.2020.07.015. The normal-space solve is an algebraic implementation of the paper's statistic, not a differently calibrated test.

@dataclass
class HuberFactorResult(mavats.FactorResult):
20@dataclass
21class HuberFactorResult(FactorResult):
22    """Robust loading estimate and fixed-threshold optimization diagnostics.
23
24    ``threshold`` is in original matrix Frobenius-norm units. ``weights`` are
25    final observation weights. ``objective_history`` includes initialization
26    and accepted sweeps, reporting mean Huber loss after dividing observations
27    and threshold by ``data_scale``. Multiply by ``data_scale**2`` for original
28    loss units when representable. The Huber convention is quadratic loss below
29    threshold and ``2 * threshold * norm - threshold**2`` above it.
30
31    ``safeguard_steps`` counts simultaneous updates replaced with sequential
32    weighted projection. ``converged`` concerns this local optimization only.
33    Factor scores use ordinary projection and are not individually robust.
34    """
35
36    threshold: float = 0.0
37    weights: np.ndarray | None = None
38    objective_history: np.ndarray | None = None
39    safeguard_steps: int = 0
40    stopping_reason: str = "max_iter"

Robust loading estimate and fixed-threshold optimization diagnostics.

threshold is in original matrix Frobenius-norm units. weights are final observation weights. objective_history includes initialization and accepted sweeps, reporting mean Huber loss after dividing observations and threshold by data_scale. Multiply by data_scale**2 for original loss units when representable. The Huber convention is quadratic loss below threshold and 2 * threshold * norm - threshold**2 above it.

safeguard_steps counts simultaneous updates replaced with sequential weighted projection. converged concerns this local optimization only. Factor scores use ordinary projection and are not individually robust.

HuberFactorResult( factors: numpy.ndarray, signal: numpy.ndarray, loadings: tuple, residuals: numpy.ndarray, mean: numpy.ndarray, eigenvalues: tuple, method: str, data_scale: float = 1.0, n_iter: int = 0, converged: bool = True, threshold: float = 0.0, weights: numpy.ndarray | None = None, objective_history: numpy.ndarray | None = None, safeguard_steps: int = 0, stopping_reason: str = 'max_iter')
threshold: float = 0.0
weights: numpy.ndarray | None = None
objective_history: numpy.ndarray | None = None
safeguard_steps: int = 0
stopping_reason: str = 'max_iter'
def fit_huber_factor( X, ranks, *, threshold=None, max_iter=200, tol=1e-08, center=False, initial=None):
101def fit_huber_factor(
102    X, ranks, *, threshold=None, max_iter=200, tol=1e-8, center=False, initial=None
103):
104    """Estimate factors by Huber loss on each matrix's residual Frobenius norm.
105
106    Minimizes the mean matrixwise Huber objective of He et al., equation (4.1).
107    ``ranks`` must contain two explicit positive ranks. ``threshold=None`` fixes
108    the threshold at the median initial residual norm, following the paper's
109    simulation rule; a zero median requires an explicit positive threshold.
110    An explicit threshold is in original Frobenius-norm units, not per-entry
111    standard deviations. Scaling data requires scaling the threshold equally.
112
113    Initialization is alpha=0 PCA unless ``initial=(row, column)`` supplies
114    full-rank loading bases. Each sweep recomputes matrix weights and proposes
115    the simultaneous weighted projection of Algorithm 2. If actual loss rises,
116    sequential weighted updates replace that proposal to ensure descent.
117    This safeguard is an implementation extension. Convergence requires both
118    relative loss change <= ``tol`` and projector change <= ``sqrt(tol)``.
119
120    Returns ``HuberFactorResult`` with orthonormal loadings. This is not the
121    entrywise iterative Huber regression estimator or Algorithm 3 rank selection.
122    With ``center=True``, a fixed ordinary sample mean is removed; that location
123    estimate is not robust. Prefer a justified known offset for contaminated data.
124
125    References
126    ----------
127    He, Kong, Yu, Zhang and Zhao (2024; online 2023), Matrix Factor Analysis:
128    From Least Squares to Iterative Projection.
129    https://doi.org/10.1080/07350015.2023.2191676
130    Algorithm source: https://arxiv.org/html/2112.04186v3, Sections 4.1 and 5.1.
131    """
132    X, work, mean, scale = _prepare(X, center)
133    ranks = ranks_tuple(ranks, X.shape[1:])
134    if ranks is None:
135        raise ValueError("ranks must contain two explicit positive integers")
136    max_iter = positive_int(max_iter, "max_iter")
137    tol = finite_scalar(tol, "tol", minimum=0)
138    if tol <= 0:
139        raise ValueError("tol must be positive")
140    loadings = _initial_spaces(initial, work, ranks)
141    norms = _residual_norms(work, loadings)
142    # Any orthogonal projection residual is bounded by the observation norm.
143    # Capping the normalized threshold at this bound preserves the quadratic
144    # regime while preventing overflow when explicit threshold / scale is huge.
145    maximum_norm = float(np.sqrt(np.einsum("tij,tij->t", work, work)).max())
146    threshold_cap = max(maximum_norm * (1 + 1e-12), np.finfo(float).tiny)
147    if threshold is None:
148        normalized_threshold = float(np.median(norms))
149        if normalized_threshold <= np.finfo(float).eps * max(maximum_norm, 1.0):
150            raise ValueError(
151                "initial median residual is numerically zero; set a positive threshold"
152            )
153        threshold = normalized_threshold * scale
154        if not np.isfinite(threshold):
155            raise ValueError(
156                "automatic threshold exceeds floating-point range; rescale data"
157            )
158    else:
159        threshold = finite_scalar(threshold, "threshold", minimum=0)
160        if threshold <= 0:
161            raise ValueError("threshold must be positive")
162        normalized_threshold = min(threshold / scale, threshold_cap)
163        if normalized_threshold == 0:
164            raise ValueError("threshold is too small relative to the data scale")
165    weights, objective = _weights_and_loss(norms, normalized_threshold)
166    history = [objective]
167    safeguards = 0
168    converged = False
169    reason = "max_iter"
170    iteration = 0
171    for iteration in range(1, max_iter + 1):
172        row_moment = _weighted_moment(work, loadings[1], weights)
173        column_moment = _weighted_moment(work.transpose(0, 2, 1), loadings[0], weights)
174        row = _eigenspace(row_moment, ranks[0])[0]
175        column = _eigenspace(column_moment, ranks[1])[0]
176        candidate = [row, column]
177        next_norms = _residual_norms(work, candidate)
178        next_weights, next_objective = _weights_and_loss(
179            next_norms, normalized_threshold
180        )
181        if next_objective > objective:
182            safeguards += 1
183            # Frozen IRLS weights define a quadratic majorizer. Updating the
184            # column space using the new row space decreases that majorizer.
185            column_moment = _weighted_moment(work.transpose(0, 2, 1), row, weights)
186            candidate[1] = _eigenspace(column_moment, ranks[1])[0]
187            next_norms = _residual_norms(work, candidate)
188            next_weights, next_objective = _weights_and_loss(
189                next_norms, normalized_threshold
190            )
191        distance = _space_distance(loadings, candidate)
192        if next_objective > objective:
193            # Never publish an increasing history by clipping its values.
194            roundoff = 64 * np.finfo(float).eps * max(objective, np.finfo(float).tiny)
195            converged = next_objective - objective <= roundoff and distance <= np.sqrt(
196                tol
197            )
198            reason = "numerical_tolerance" if converged else "descent_stalled"
199            iteration -= 1
200            break
201        improvement = objective - next_objective
202        relative = improvement / max(objective, np.finfo(float).tiny)
203        loadings, weights, objective = candidate, next_weights, next_objective
204        history.append(objective)
205        if relative <= tol and distance <= np.sqrt(tol):
206            converged = True
207            reason = "converged"
208            break
209    # Spectra describe final robust weighted projected moments in normalized units.
210    moments = (
211        _weighted_moment(work, loadings[1], weights),
212        _weighted_moment(work.transpose(0, 2, 1), loadings[0], weights),
213    )
214    spectra = [_eigenspace(moment, rank)[1] for moment, rank in zip(moments, ranks)]
215    result = _result(
216        X, loadings, mean, spectra, "matrix-huber", scale, iteration, converged
217    )
218    return HuberFactorResult(
219        **result.__dict__,
220        threshold=threshold,
221        weights=weights,
222        objective_history=np.asarray(history),
223        safeguard_steps=safeguards,
224        stopping_reason=reason,
225    )

Estimate factors by Huber loss on each matrix's residual Frobenius norm.

Minimizes the mean matrixwise Huber objective of He et al., equation (4.1). ranks must contain two explicit positive ranks. threshold=None fixes the threshold at the median initial residual norm, following the paper's simulation rule; a zero median requires an explicit positive threshold. An explicit threshold is in original Frobenius-norm units, not per-entry standard deviations. Scaling data requires scaling the threshold equally.

Initialization is alpha=0 PCA unless initial=(row, column) supplies full-rank loading bases. Each sweep recomputes matrix weights and proposes the simultaneous weighted projection of Algorithm 2. If actual loss rises, sequential weighted updates replace that proposal to ensure descent. This safeguard is an implementation extension. Convergence requires both relative loss change <= tol and projector change <= sqrt(tol).

Returns HuberFactorResult with orthonormal loadings. This is not the entrywise iterative Huber regression estimator or Algorithm 3 rank selection. With center=True, a fixed ordinary sample mean is removed; that location estimate is not robust. Prefer a justified known offset for contaminated data.

References

He, Kong, Yu, Zhang and Zhao (2024; online 2023), Matrix Factor Analysis: From Least Squares to Iterative Projection. https://doi.org/10.1080/07350015.2023.2191676 Algorithm source: https://arxiv.org/html/2112.04186v3, Sections 4.1 and 5.1.

class CPIdentificationError(builtins.ValueError):
12class CPIdentificationError(ValueError):
13    """The selected lag moments do not identify a stable, real CP decomposition."""

The selected lag moments do not identify a stable, real CP decomposition.

@dataclass
class CPResult:
121@dataclass
122class CPResult:
123    """A fitted CP model ``Y[t] = sum_r scores[t,r] A[:,r] B[:,r].T``.
124
125    Loading columns have unit length but generally are not orthogonal. They
126    are identifiable only up to paired permutations and signs. Columns are
127    ordered by ascending generalized eigenvalue and their largest entries are
128    made positive. Scores absorb signs and original data scale. ``signal``
129    includes the unrestricted training mean when centering was requested.
130    """
131
132    scores: np.ndarray
133    loadings: tuple
134    signal: np.ndarray
135    residuals: np.ndarray
136    mean: np.ndarray
137    eigenvalues: np.ndarray
138    condition_number: float
139    xi: np.ndarray
140    eta: np.ndarray
141    method: str = "cp-refined"
142
143    @property
144    def rank(self):
145        return self.scores.shape[1]
146
147    @property
148    def factors(self):
149        return self.scores
150
151    @property
152    def A(self):
153        return self.loadings[0]
154
155    @property
156    def B(self):
157        return self.loadings[1]
158
159    def transform(self, X):
160        """Estimate scalar factors by joint least squares in the CP basis.
161
162        References
163        ----------
164        Chang, He, Yang and Yao (2023), Modelling Matrix Time Series via a
165        Tensor CP-Decomposition. https://arxiv.org/abs/2112.15423
166        Uses fixed fitted loadings; this does not estimate a forecast law.
167        """
168        X = as_series(X, min_samples=1)
169        if X.shape[1:] != self.mean.shape:
170            raise ValueError("observation dimensions must match the fitted CP model")
171        centered = X - self.mean
172        scale = max(float(np.max(np.abs(centered))), np.finfo(float).tiny)
173        scores = np.linalg.lstsq(
174            _design(self.loadings), (centered / scale).reshape(len(X), -1).T, rcond=None
175        )[0].T
176        return scores * scale
177
178    def inverse_transform(self, scores):
179        """Reconstruct matrices from scalar factors, adding the training mean.
180
181        References
182        ----------
183        Chang, He, Yang and Yao (2023), Modelling Matrix Time Series via a
184        Tensor CP-Decomposition. https://arxiv.org/abs/2112.15423
185        Restoring the optional training mean is an implementation convention.
186        """
187        scores = as_series(scores, min_samples=1, ndim=2)
188        if scores.shape[1] != self.rank:
189            raise ValueError("score dimensions must match the fitted CP rank")
190        return (
191            np.einsum("tr,ir,jr->tij", scores, self.A, self.B, optimize=True)
192            + self.mean
193        )

A fitted CP model Y[t] = sum_r scores[t,r] A[:,r] B[:,r].T.

Loading columns have unit length but generally are not orthogonal. They are identifiable only up to paired permutations and signs. Columns are ordered by ascending generalized eigenvalue and their largest entries are made positive. Scores absorb signs and original data scale. signal includes the unrestricted training mean when centering was requested.

CPResult( scores: numpy.ndarray, loadings: tuple, signal: numpy.ndarray, residuals: numpy.ndarray, mean: numpy.ndarray, eigenvalues: numpy.ndarray, condition_number: float, xi: numpy.ndarray, eta: numpy.ndarray, method: str = 'cp-refined')
scores: numpy.ndarray
loadings: tuple
signal: numpy.ndarray
residuals: numpy.ndarray
mean: numpy.ndarray
eigenvalues: numpy.ndarray
condition_number: float
xi: numpy.ndarray
eta: numpy.ndarray
method: str = 'cp-refined'
rank
143    @property
144    def rank(self):
145        return self.scores.shape[1]
factors
147    @property
148    def factors(self):
149        return self.scores
A
151    @property
152    def A(self):
153        return self.loadings[0]
B
155    @property
156    def B(self):
157        return self.loadings[1]
def transform(self, X):
159    def transform(self, X):
160        """Estimate scalar factors by joint least squares in the CP basis.
161
162        References
163        ----------
164        Chang, He, Yang and Yao (2023), Modelling Matrix Time Series via a
165        Tensor CP-Decomposition. https://arxiv.org/abs/2112.15423
166        Uses fixed fitted loadings; this does not estimate a forecast law.
167        """
168        X = as_series(X, min_samples=1)
169        if X.shape[1:] != self.mean.shape:
170            raise ValueError("observation dimensions must match the fitted CP model")
171        centered = X - self.mean
172        scale = max(float(np.max(np.abs(centered))), np.finfo(float).tiny)
173        scores = np.linalg.lstsq(
174            _design(self.loadings), (centered / scale).reshape(len(X), -1).T, rcond=None
175        )[0].T
176        return scores * scale

Estimate scalar factors by joint least squares in the CP basis.

References

Chang, He, Yang and Yao (2023), Modelling Matrix Time Series via a Tensor CP-Decomposition. https://arxiv.org/abs/2112.15423 Uses fixed fitted loadings; this does not estimate a forecast law.

def inverse_transform(self, scores):
178    def inverse_transform(self, scores):
179        """Reconstruct matrices from scalar factors, adding the training mean.
180
181        References
182        ----------
183        Chang, He, Yang and Yao (2023), Modelling Matrix Time Series via a
184        Tensor CP-Decomposition. https://arxiv.org/abs/2112.15423
185        Restoring the optional training mean is an implementation convention.
186        """
187        scores = as_series(scores, min_samples=1, ndim=2)
188        if scores.shape[1] != self.rank:
189            raise ValueError("score dimensions must match the fitted CP rank")
190        return (
191            np.einsum("tr,ir,jr->tij", scores, self.A, self.B, optimize=True)
192            + self.mean
193        )

Reconstruct matrices from scalar factors, adding the training mean.

References

Chang, He, Yang and Yao (2023), Modelling Matrix Time Series via a Tensor CP-Decomposition. https://arxiv.org/abs/2112.15423 Restoring the optional training mean is an implementation convention.

def fit_cp_factor( X, rank, *, lags=3, xi=None, eta=None, center=False, proxy_variance=0.99, condition_limit=10000000000.0, eigenvalue_tol=1e-08):
196def fit_cp_factor(
197    X,
198    rank,
199    *,
200    lags=3,
201    xi=None,
202    eta=None,
203    center=False,
204    proxy_variance=0.99,
205    condition_limit=1e10,
206    eigenvalue_tol=1e-8,
207):
208    """Fit Chang, He, Yang and Yao's refined matrix CP factor estimator.
209
210    Parameters
211    ----------
212    X : array_like, shape (T, p, q)
213        Finite real matrix time series with at least four observations.
214    rank : int
215        Number of CP components, at most min(p, q). Row and column loadings
216        must each have this rank; overcomplete CP models are not supported.
217    lags : int or sequence of int, default 3
218        Lags used to estimate the initial row and column spaces. An integer K
219        includes lags 1,...,K. The refinement always uses lags one and two.
220    xi, eta : array_like, shape (T,), optional
221        Scalar proxies for the initial and projected stages. Each must have
222        informative lag covariance with every component. The model assumes
223        these are contemporaneous linear combinations of observations (of the
224        projected observations for eta); arbitrary external proxies require
225        corresponding noise exogeneity. By default, each proxy averages PCA
226        scores accounting for at least ``proxy_variance`` of total variance,
227        following the paper's simulation procedure. Proxies are centered and
228        scaled internally, and returned in that convention.
229    center : bool, default False
230        Remove and restore an unrestricted temporal mean when computing scores
231        and signal. Moment estimation always subtracts full-sample means.
232    proxy_variance : float, default 0.99
233        Cumulative variance fraction in (0, 1] used for data-derived proxies.
234    condition_limit : float, default 1e10
235        Reject projected lag moments/loadings with larger condition numbers.
236    eigenvalue_tol : float, default 1e-8
237        Minimum separation relative to max(1, magnitudes) of adjacent sorted
238        generalized eigenvalues. Complex eigenvalues are rejected explicitly.
239
240    Returns
241    -------
242    CPResult
243        Unit-norm nonorthogonal loadings, scalar factors, signal and diagnostics.
244
245    Notes
246    -----
247    Implements equations (29)--(34) with delta1=delta2=0, the unthresholded
248    finite-dimensional estimator. It does not implement the paper's thresholded
249    high-dimensional covariance estimator or automatic rank selection.
250    Identification requires full-rank projected moments at lags one and two,
251    distinct real generalized eigenvalues and appropriately white errors. A
252    noisy finite sample may violate these requirements and raises
253    ``CPIdentificationError`` instead of returning complex or unstable loadings.
254
255    References
256    ----------
257    Chang, He, Yang and Yao (2023), Modelling Matrix Time Series via a Tensor
258    CP-Decomposition. https://arxiv.org/abs/2112.15423
259    """
260    X = as_series(X, min_samples=4)
261    rank = positive_int(rank, "rank")
262    if rank > min(X.shape[1:]):
263        raise ValueError("rank cannot exceed either matrix dimension")
264    lags = _lags(lags, len(X))
265    variance = finite_scalar(proxy_variance, "proxy_variance", minimum=0)
266    if not 0 < variance <= 1:
267        raise ValueError("proxy_variance must be in (0, 1]")
268    condition_limit = finite_scalar(condition_limit, "condition_limit", minimum=1)
269    if condition_limit <= 1:
270        raise ValueError("condition_limit must exceed one")
271    eigenvalue_tol = finite_scalar(eigenvalue_tol, "eigenvalue_tol", minimum=0)
272    if eigenvalue_tol == 0:
273        raise ValueError("eigenvalue_tol must be positive")
274    X, normalized, mean, _ = _prepare(X, center)
275    centered = normalized - normalized.mean(axis=0)
276    xi = _scalar_proxy(
277        _pca_proxy(centered, variance) if xi is None else xi, len(X), "xi"
278    )
279    moments = [_cross_moment(centered, xi, lag) for lag in lags]
280    row = sum(moment @ moment.T for moment in moments)
281    col = sum(moment.T @ moment for moment in moments)
282    P, row_values = _eigenspace(row, rank)
283    Q, col_values = _eigenspace(col, rank)
284    for values in (row_values, col_values):
285        if values[0] == 0 or values[rank - 1] <= values[0] / condition_limit**2:
286            raise CPIdentificationError(
287                "initial lag moments do not identify the requested row and column ranks"
288            )
289    Z = P.T @ centered @ Q
290    eta = _scalar_proxy(_pca_proxy(Z, variance) if eta is None else eta, len(X), "eta")
291    H1, H2 = (_cross_moment(Z, eta, lag) for lag in (1, 2))
292    U, V, eigenvalues, condition = _refined_loadings(
293        H1, H2, condition_limit, eigenvalue_tol
294    )
295    loadings = (_canonical_signs(P @ U), _canonical_signs(Q @ V))
296    _condition(_design(loadings), condition_limit, "CP least-squares design")
297    result = CPResult(
298        np.empty((len(X), rank)),
299        loadings,
300        np.empty_like(X),
301        np.empty_like(X),
302        mean,
303        eigenvalues,
304        condition,
305        xi,
306        eta,
307    )
308    result.scores = result.transform(X)
309    result.signal = result.inverse_transform(result.scores)
310    result.residuals = X - result.signal
311    return result

Fit Chang, He, Yang and Yao's refined matrix CP factor estimator.

Parameters
  • X (array_like, shape (T, p, q)): Finite real matrix time series with at least four observations.
  • rank (int): Number of CP components, at most min(p, q). Row and column loadings must each have this rank; overcomplete CP models are not supported.
  • lags (int or sequence of int, default 3): Lags used to estimate the initial row and column spaces. An integer K includes lags 1,...,K. The refinement always uses lags one and two.
  • xi, eta (array_like, shape (T,), optional): Scalar proxies for the initial and projected stages. Each must have informative lag covariance with every component. The model assumes these are contemporaneous linear combinations of observations (of the projected observations for eta); arbitrary external proxies require corresponding noise exogeneity. By default, each proxy averages PCA scores accounting for at least proxy_variance of total variance, following the paper's simulation procedure. Proxies are centered and scaled internally, and returned in that convention.
  • center (bool, default False): Remove and restore an unrestricted temporal mean when computing scores and signal. Moment estimation always subtracts full-sample means.
  • proxy_variance (float, default 0.99): Cumulative variance fraction in (0, 1] used for data-derived proxies.
  • condition_limit (float, default 1e10): Reject projected lag moments/loadings with larger condition numbers.
  • eigenvalue_tol (float, default 1e-8): Minimum separation relative to max(1, magnitudes) of adjacent sorted generalized eigenvalues. Complex eigenvalues are rejected explicitly.
Returns
  • CPResult: Unit-norm nonorthogonal loadings, scalar factors, signal and diagnostics.
Notes

Implements equations (29)--(34) with delta1=delta2=0, the unthresholded finite-dimensional estimator. It does not implement the paper's thresholded high-dimensional covariance estimator or automatic rank selection. Identification requires full-rank projected moments at lags one and two, distinct real generalized eigenvalues and appropriately white errors. A noisy finite sample may violate these requirements and raises CPIdentificationError instead of returning complex or unstable loadings.

References

Chang, He, Yang and Yao (2023), Modelling Matrix Time Series via a Tensor CP-Decomposition. https://arxiv.org/abs/2112.15423

@dataclass
class FactorResult:
 98@dataclass
 99class FactorResult:
100    """Fitted matrix or Tucker tensor factor decomposition.
101
102    ``loadings[k].T @ loadings[k]`` is identity. ``signal`` includes ``mean``
103    when centering was requested; ``factors`` describe deviations from that
104    mean. ``eigenvalues`` are descending spectra of moments computed after
105    dividing data by ``data_scale`` (and after any alpha mean adjustment;
106    alpha-PCA additionally divides its moment by ``max(1, 1 + alpha)``).
107    This scaling avoids overflow and does not change the estimated subspaces.
108    ``converged`` describes the projection iteration, not statistical accuracy.
109    """
110
111    factors: np.ndarray
112    signal: np.ndarray
113    loadings: tuple
114    residuals: np.ndarray
115    mean: np.ndarray
116    eigenvalues: tuple
117    method: str
118    data_scale: float = 1.0
119    n_iter: int = 0
120    converged: bool = True
121
122    @property
123    def ranks(self):
124        return tuple(loading.shape[1] for loading in self.loadings)
125
126    def transform(self, X):
127        """Project new observations using the fitted loadings and training mean.
128
129        References
130        ----------
131        Chen, Yang and Zhang (2022), Factor Models for High-Dimensional Tensor
132        Time Series. https://doi.org/10.1080/01621459.2021.1912757
133        This is fixed-loading Tucker projection, not a new forecasting fit.
134        """
135        X = as_series(X, min_samples=1, ndim=len(self.loadings) + 1)
136        if X.shape[1:] != self.mean.shape:
137            raise ValueError("observation dimensions must match the fitted model")
138        return _project(X - self.mean, self.loadings)
139
140    def inverse_transform(self, factors):
141        """Reconstruct observations from core factors, adding the training mean.
142
143        References
144        ----------
145        Chen, Yang and Zhang (2022), Factor Models for High-Dimensional Tensor
146        Time Series. https://doi.org/10.1080/01621459.2021.1912757
147        Restoring the optional training mean is an implementation convention.
148        """
149        factors = as_series(factors, min_samples=1, ndim=len(self.loadings) + 1)
150        if factors.shape[1:] != self.ranks:
151            raise ValueError("factor dimensions must match fitted ranks")
152        return _project(factors, self.loadings, inverse=True) + self.mean

Fitted matrix or Tucker tensor factor decomposition.

loadings[k].T @ loadings[k] is identity. signal includes mean when centering was requested; factors describe deviations from that mean. eigenvalues are descending spectra of moments computed after dividing data by data_scale (and after any alpha mean adjustment; alpha-PCA additionally divides its moment by max(1, 1 + alpha)). This scaling avoids overflow and does not change the estimated subspaces. converged describes the projection iteration, not statistical accuracy.

FactorResult( factors: numpy.ndarray, signal: numpy.ndarray, loadings: tuple, residuals: numpy.ndarray, mean: numpy.ndarray, eigenvalues: tuple, method: str, data_scale: float = 1.0, n_iter: int = 0, converged: bool = True)
factors: numpy.ndarray
signal: numpy.ndarray
loadings: tuple
residuals: numpy.ndarray
mean: numpy.ndarray
eigenvalues: tuple
method: str
data_scale: float = 1.0
n_iter: int = 0
converged: bool = True
ranks
122    @property
123    def ranks(self):
124        return tuple(loading.shape[1] for loading in self.loadings)
def transform(self, X):
126    def transform(self, X):
127        """Project new observations using the fitted loadings and training mean.
128
129        References
130        ----------
131        Chen, Yang and Zhang (2022), Factor Models for High-Dimensional Tensor
132        Time Series. https://doi.org/10.1080/01621459.2021.1912757
133        This is fixed-loading Tucker projection, not a new forecasting fit.
134        """
135        X = as_series(X, min_samples=1, ndim=len(self.loadings) + 1)
136        if X.shape[1:] != self.mean.shape:
137            raise ValueError("observation dimensions must match the fitted model")
138        return _project(X - self.mean, self.loadings)

Project new observations using the fitted loadings and training mean.

References

Chen, Yang and Zhang (2022), Factor Models for High-Dimensional Tensor Time Series. https://doi.org/10.1080/01621459.2021.1912757 This is fixed-loading Tucker projection, not a new forecasting fit.

def inverse_transform(self, factors):
140    def inverse_transform(self, factors):
141        """Reconstruct observations from core factors, adding the training mean.
142
143        References
144        ----------
145        Chen, Yang and Zhang (2022), Factor Models for High-Dimensional Tensor
146        Time Series. https://doi.org/10.1080/01621459.2021.1912757
147        Restoring the optional training mean is an implementation convention.
148        """
149        factors = as_series(factors, min_samples=1, ndim=len(self.loadings) + 1)
150        if factors.shape[1:] != self.ranks:
151            raise ValueError("factor dimensions must match fitted ranks")
152        return _project(factors, self.loadings, inverse=True) + self.mean

Reconstruct observations from core factors, adding the training mean.

References

Chen, Yang and Zhang (2022), Factor Models for High-Dimensional Tensor Time Series. https://doi.org/10.1080/01621459.2021.1912757 Restoring the optional training mean is an implementation convention.

@dataclass
class MARResult:
189@dataclass
190class MARResult:
191    """Fitted ``X[t] = intercept + sum(A[l] X[t-l-1] B[l].T) + E[t]``.
192
193    ``left`` and ``right`` retain a leading lag axis, including at order one.
194    Fitted values and residuals correspond to times ``order:``. Stability is
195    diagnosed, not enforced. Iterative estimators find a stationary local
196    solution; ``converged`` does not certify global optimality.
197
198    Objective histories refer to observations divided by ``data_scale``.
199    For ALS/projection multiply by ``data_scale**2`` for the physical SSE
200    plus penalty, when representable. For MLE add
201    ``residuals.size * log(data_scale)`` for the physical negative log
202    likelihood. MLE covariance factors are in physical units with the scale
203    shared between factors: ``||row_covariance||_F = data_scale``. Their
204    Kronecker product is the physical innovation covariance; this product
205    need not itself fit floating-point range at extreme data scales.
206
207    Model references and extension boundaries are those of :func:`fit_mar`
208    and this module, including for the forecast and covariance accessors.
209    """
210
211    left: np.ndarray
212    right: np.ndarray
213    intercept: np.ndarray
214    fitted_values: np.ndarray
215    residuals: np.ndarray
216    method: str
217    converged: bool
218    n_iter: int
219    objective_history: np.ndarray
220    row_covariance: np.ndarray | None = None
221    column_covariance: np.ndarray | None = None
222    covariance_regularized: bool = False
223    ridge: float = 0.0
224    ranks: tuple | None = None
225    data_scale: float = 1.0
226    _history: np.ndarray = field(default_factory=lambda: np.empty(0), repr=False)
227
228    @property
229    def order(self):
230        return len(self.left)
231
232    @property
233    def A(self):
234        """Left coefficient for MAR(1); use ``left`` for higher orders."""
235        if self.order != 1:
236            raise ValueError("A is only defined for order one; use left")
237        return self.left[0]
238
239    @property
240    def B(self):
241        """Right coefficient for MAR(1); use ``right`` for higher orders."""
242        if self.order != 1:
243            raise ValueError("B is only defined for order one; use right")
244        return self.right[0]
245
246    @property
247    def coefficients(self):
248        """Dense column-major VAR coefficients; allocates p*(m*n)**2 entries."""
249        return np.stack([np.kron(B, A) for A, B in zip(self.left, self.right)])
250
251    @property
252    def spectral_radius(self):
253        """Companion spectral radius (dense companion for order > 1)."""
254        if self.order == 1:
255            return float(
256                np.max(np.abs(np.linalg.eigvals(self.A)))
257                * np.max(np.abs(np.linalg.eigvals(self.B)))
258            )
259        phi = self.coefficients
260        d = phi.shape[1]
261        companion = np.zeros((self.order * d, self.order * d))
262        companion[:d] = np.concatenate(phi, axis=1)
263        companion[d:, :-d] = np.eye((self.order - 1) * d)
264        return float(np.max(np.abs(np.linalg.eigvals(companion))))
265
266    @property
267    def is_stable(self):
268        return self.spectral_radius < 1.0
269
270    @property
271    def log_likelihood(self):
272        """Conditional Gaussian log likelihood, available for method='mle'."""
273        if self.row_covariance is None:
274            return None
275        return -_negative_loglike(
276            self.residuals, self.row_covariance, self.column_covariance
277        )
278
279    def residual_covariance(self, *, ddof=0):
280        """Centered covariance of column-major residuals (dense mn by mn)."""
281        if (
282            isinstance(ddof, (bool, np.bool_))
283            or not isinstance(ddof, (int, np.integer))
284            or ddof < 0
285        ):
286            raise ValueError("ddof must be a nonnegative integer")
287        if len(self.residuals) <= ddof:
288            raise ValueError("ddof must be less than the number of residuals")
289        flat = self.residuals.transpose(0, 2, 1).reshape(len(self.residuals), -1)
290        flat = flat - flat.mean(axis=0)
291        return flat.T @ flat / (len(flat) - ddof)
292
293    def forecast(self, steps, history=None):
294        """Recursively forecast future matrices using the most recent p values.
295
296        A supplied history must have shape ``(T,m,n)`` in chronological
297        order and contain at least ``order`` observations. The result has
298        shape ``(steps,m,n)``. These are conditional mean forecasts.
299        """
300        steps = positive_int(steps, "steps")
301        observed = (
302            self._history
303            if history is None
304            else as_series(history, min_samples=self.order)
305        )
306        if observed.shape[1:] != self.intercept.shape:
307            raise ValueError("history matrix dimensions do not match fitted model")
308        values = [x.copy() for x in observed[-self.order :]]
309        forecasts = []
310        for _ in range(steps):
311            scale = (
312                max(
313                    np.max(np.abs(self.intercept)),
314                    max(np.max(np.abs(x)) for x in values[-self.order :]),
315                )
316                or 1.0
317            )
318            with np.errstate(over="ignore", invalid="ignore"):
319                prediction = self.intercept / scale
320                for lag, (A, B) in enumerate(zip(self.left, self.right), 1):
321                    prediction += A @ (values[-lag] / scale) @ B.T
322                prediction *= scale
323            if not np.isfinite(prediction).all():
324                raise FloatingPointError("MAR forecast exceeds floating-point range")
325            forecasts.append(prediction)
326            values.append(prediction)
327        return np.stack(forecasts)

Fitted X[t] = intercept + sum(A[l] X[t-l-1] B[l].T) + E[t].

left and right retain a leading lag axis, including at order one. Fitted values and residuals correspond to times order:. Stability is diagnosed, not enforced. Iterative estimators find a stationary local solution; converged does not certify global optimality.

Objective histories refer to observations divided by data_scale. For ALS/projection multiply by data_scale**2 for the physical SSE plus penalty, when representable. For MLE add residuals.size * log(data_scale) for the physical negative log likelihood. MLE covariance factors are in physical units with the scale shared between factors: ||row_covariance||_F = data_scale. Their Kronecker product is the physical innovation covariance; this product need not itself fit floating-point range at extreme data scales.

Model references and extension boundaries are those of fit_mar() and this module, including for the forecast and covariance accessors.

MARResult( left: numpy.ndarray, right: numpy.ndarray, intercept: numpy.ndarray, fitted_values: numpy.ndarray, residuals: numpy.ndarray, method: str, converged: bool, n_iter: int, objective_history: numpy.ndarray, row_covariance: numpy.ndarray | None = None, column_covariance: numpy.ndarray | None = None, covariance_regularized: bool = False, ridge: float = 0.0, ranks: tuple | None = None, data_scale: float = 1.0, _history: numpy.ndarray = <factory>)
left: numpy.ndarray
right: numpy.ndarray
intercept: numpy.ndarray
fitted_values: numpy.ndarray
residuals: numpy.ndarray
method: str
converged: bool
n_iter: int
objective_history: numpy.ndarray
row_covariance: numpy.ndarray | None = None
column_covariance: numpy.ndarray | None = None
covariance_regularized: bool = False
ridge: float = 0.0
ranks: tuple | None = None
data_scale: float = 1.0
order
228    @property
229    def order(self):
230        return len(self.left)
A
232    @property
233    def A(self):
234        """Left coefficient for MAR(1); use ``left`` for higher orders."""
235        if self.order != 1:
236            raise ValueError("A is only defined for order one; use left")
237        return self.left[0]

Left coefficient for MAR(1); use left for higher orders.

B
239    @property
240    def B(self):
241        """Right coefficient for MAR(1); use ``right`` for higher orders."""
242        if self.order != 1:
243            raise ValueError("B is only defined for order one; use right")
244        return self.right[0]

Right coefficient for MAR(1); use right for higher orders.

coefficients
246    @property
247    def coefficients(self):
248        """Dense column-major VAR coefficients; allocates p*(m*n)**2 entries."""
249        return np.stack([np.kron(B, A) for A, B in zip(self.left, self.right)])

Dense column-major VAR coefficients; allocates p*(m*n)**2 entries.

spectral_radius
251    @property
252    def spectral_radius(self):
253        """Companion spectral radius (dense companion for order > 1)."""
254        if self.order == 1:
255            return float(
256                np.max(np.abs(np.linalg.eigvals(self.A)))
257                * np.max(np.abs(np.linalg.eigvals(self.B)))
258            )
259        phi = self.coefficients
260        d = phi.shape[1]
261        companion = np.zeros((self.order * d, self.order * d))
262        companion[:d] = np.concatenate(phi, axis=1)
263        companion[d:, :-d] = np.eye((self.order - 1) * d)
264        return float(np.max(np.abs(np.linalg.eigvals(companion))))

Companion spectral radius (dense companion for order > 1).

is_stable
266    @property
267    def is_stable(self):
268        return self.spectral_radius < 1.0
log_likelihood
270    @property
271    def log_likelihood(self):
272        """Conditional Gaussian log likelihood, available for method='mle'."""
273        if self.row_covariance is None:
274            return None
275        return -_negative_loglike(
276            self.residuals, self.row_covariance, self.column_covariance
277        )

Conditional Gaussian log likelihood, available for method='mle'.

def residual_covariance(self, *, ddof=0):
279    def residual_covariance(self, *, ddof=0):
280        """Centered covariance of column-major residuals (dense mn by mn)."""
281        if (
282            isinstance(ddof, (bool, np.bool_))
283            or not isinstance(ddof, (int, np.integer))
284            or ddof < 0
285        ):
286            raise ValueError("ddof must be a nonnegative integer")
287        if len(self.residuals) <= ddof:
288            raise ValueError("ddof must be less than the number of residuals")
289        flat = self.residuals.transpose(0, 2, 1).reshape(len(self.residuals), -1)
290        flat = flat - flat.mean(axis=0)
291        return flat.T @ flat / (len(flat) - ddof)

Centered covariance of column-major residuals (dense mn by mn).

def forecast(self, steps, history=None):
293    def forecast(self, steps, history=None):
294        """Recursively forecast future matrices using the most recent p values.
295
296        A supplied history must have shape ``(T,m,n)`` in chronological
297        order and contain at least ``order`` observations. The result has
298        shape ``(steps,m,n)``. These are conditional mean forecasts.
299        """
300        steps = positive_int(steps, "steps")
301        observed = (
302            self._history
303            if history is None
304            else as_series(history, min_samples=self.order)
305        )
306        if observed.shape[1:] != self.intercept.shape:
307            raise ValueError("history matrix dimensions do not match fitted model")
308        values = [x.copy() for x in observed[-self.order :]]
309        forecasts = []
310        for _ in range(steps):
311            scale = (
312                max(
313                    np.max(np.abs(self.intercept)),
314                    max(np.max(np.abs(x)) for x in values[-self.order :]),
315                )
316                or 1.0
317            )
318            with np.errstate(over="ignore", invalid="ignore"):
319                prediction = self.intercept / scale
320                for lag, (A, B) in enumerate(zip(self.left, self.right), 1):
321                    prediction += A @ (values[-lag] / scale) @ B.T
322                prediction *= scale
323            if not np.isfinite(prediction).all():
324                raise FloatingPointError("MAR forecast exceeds floating-point range")
325            forecasts.append(prediction)
326            values.append(prediction)
327        return np.stack(forecasts)

Recursively forecast future matrices using the most recent p values.

A supplied history must have shape (T,m,n) in chronological order and contain at least order observations. The result has shape (steps,m,n). These are conditional mean forecasts.

@dataclass
class MARRankSelection:
627@dataclass
628class MARRankSelection:
629    """Joint RR.LS EBIC grid, indexed by ``[row_rank-1, column_rank-1]``."""
630
631    ranks: tuple
632    result: MARResult
633    scores: np.ndarray
634    converged: np.ndarray

Joint RR.LS EBIC grid, indexed by [row_rank-1, column_rank-1].

MARRankSelection( ranks: tuple, result: MARResult, scores: numpy.ndarray, converged: numpy.ndarray)
ranks: tuple
result: MARResult
scores: numpy.ndarray
converged: numpy.ndarray
@dataclass
class VARResult:
23@dataclass
24class VARResult:
25    """Vector autoregression fitted to column-vectorized matrices.
26
27    ``coefficients[lag]`` multiplies a column vector. Dense storage and least
28    squares scale with the product of the spatial dimensions.
29    """
30
31    coefficients: np.ndarray
32    intercept: np.ndarray
33    fitted_values: np.ndarray
34    residuals: np.ndarray
35    history: np.ndarray
36    rank: int
37    singular_values: np.ndarray
38    design_scale: float = 1.0
39
40    @property
41    def order(self):
42        return len(self.coefficients)
43
44    def forecast(self, steps, history=None):
45        """Recursively forecast without using future observations."""
46        steps = positive_int(steps, "steps")
47        history = (
48            self.history
49            if history is None
50            else as_series(history, min_samples=self.order)
51        )
52        if history.shape[1:] != self.history.shape[1:]:
53            raise ValueError("history spatial dimensions differ from the fitted series")
54        m, n = history.shape[1:]
55        state = [x.ravel(order="F").copy() for x in history[-self.order :]]
56        result = []
57        for _ in range(steps):
58            scale = (
59                max(
60                    np.max(np.abs(self.intercept)),
61                    max(np.max(np.abs(x)) for x in state[-self.order :]),
62                )
63                or 1.0
64            )
65            with np.errstate(over="ignore", invalid="ignore"):
66                value = self.intercept / scale
67                for lag, coefficient in enumerate(self.coefficients, 1):
68                    value += coefficient @ (state[-lag] / scale)
69                value *= scale
70            if not np.isfinite(value).all():
71                raise FloatingPointError("VAR forecast exceeds floating-point range")
72            state.append(value)
73            result.append(value.reshape(m, n, order="F"))
74        return np.asarray(result)
75
76    @property
77    def spectral_radius(self):
78        p, d, _ = self.coefficients.shape
79        companion = np.zeros((p * d, p * d))
80        companion[:d] = np.concatenate(self.coefficients, axis=1)
81        companion[d:, :-d] = np.eye((p - 1) * d)
82        return float(max(abs(np.linalg.eigvals(companion))))

Vector autoregression fitted to column-vectorized matrices.

coefficients[lag] multiplies a column vector. Dense storage and least squares scale with the product of the spatial dimensions.

VARResult( coefficients: numpy.ndarray, intercept: numpy.ndarray, fitted_values: numpy.ndarray, residuals: numpy.ndarray, history: numpy.ndarray, rank: int, singular_values: numpy.ndarray, design_scale: float = 1.0)
coefficients: numpy.ndarray
intercept: numpy.ndarray
fitted_values: numpy.ndarray
residuals: numpy.ndarray
history: numpy.ndarray
rank: int
singular_values: numpy.ndarray
design_scale: float = 1.0
order
40    @property
41    def order(self):
42        return len(self.coefficients)
def forecast(self, steps, history=None):
44    def forecast(self, steps, history=None):
45        """Recursively forecast without using future observations."""
46        steps = positive_int(steps, "steps")
47        history = (
48            self.history
49            if history is None
50            else as_series(history, min_samples=self.order)
51        )
52        if history.shape[1:] != self.history.shape[1:]:
53            raise ValueError("history spatial dimensions differ from the fitted series")
54        m, n = history.shape[1:]
55        state = [x.ravel(order="F").copy() for x in history[-self.order :]]
56        result = []
57        for _ in range(steps):
58            scale = (
59                max(
60                    np.max(np.abs(self.intercept)),
61                    max(np.max(np.abs(x)) for x in state[-self.order :]),
62                )
63                or 1.0
64            )
65            with np.errstate(over="ignore", invalid="ignore"):
66                value = self.intercept / scale
67                for lag, coefficient in enumerate(self.coefficients, 1):
68                    value += coefficient @ (state[-lag] / scale)
69                value *= scale
70            if not np.isfinite(value).all():
71                raise FloatingPointError("VAR forecast exceeds floating-point range")
72            state.append(value)
73            result.append(value.reshape(m, n, order="F"))
74        return np.asarray(result)

Recursively forecast without using future observations.

spectral_radius
76    @property
77    def spectral_radius(self):
78        p, d, _ = self.coefficients.shape
79        companion = np.zeros((p * d, p * d))
80        companion[:d] = np.concatenate(self.coefficients, axis=1)
81        companion[d:, :-d] = np.eye((p - 1) * d)
82        return float(max(abs(np.linalg.eigvals(companion))))
def eigenvalue_ratio(eigenvalues, max_rank=None):
40def eigenvalue_ratio(eigenvalues, max_rank=None):
41    """Estimate rank from the largest adjacent gap in descending eigenvalues.
42
43    Accepts eigenvalues in either order. Searches up to half the dimension by
44    default, as in Wang, Liu and Chen (2019). A relative numerical floor prevents
45    dividing by roundoff at an exact rank deficiency. A zero spectrum returns
46    rank one; this heuristic does not test for absence of factors. Specify ranks
47    explicitly for small dimensions or weak signals.
48
49    References
50    ----------
51    Wang, Liu and Chen (2019), Factor Models for Matrix-Valued
52    High-Dimensional Time Series, Section 3.
53    https://doi.org/10.1016/j.jeconom.2018.09.013
54    The numerical floor and zero-spectrum fallback are implementation
55    conventions, not a calibrated test of the absence of factors.
56    """
57    values = np.asarray(eigenvalues, dtype=float)
58    if values.ndim != 1 or values.size == 0 or not np.isfinite(values).all():
59        raise ValueError("eigenvalues must be a nonempty finite vector")
60    values = np.sort(np.maximum(values, 0))[::-1]
61    if values.size == 1:
62        return 1
63    upper = (
64        max(1, values.size // 2)
65        if max_rank is None
66        else positive_int(max_rank, "max_rank")
67    )
68    if upper >= values.size:
69        raise ValueError("max_rank must be smaller than the spectrum length")
70    if values[0] == 0:
71        return 1
72    values = values / values[0]
73    floor = np.finfo(float).eps * values.size
74    ratios = (values[1 : upper + 1] + floor) / (values[:upper] + floor)
75    return int(np.argmin(ratios)) + 1

Estimate rank from the largest adjacent gap in descending eigenvalues.

Accepts eigenvalues in either order. Searches up to half the dimension by default, as in Wang, Liu and Chen (2019). A relative numerical floor prevents dividing by roundoff at an exact rank deficiency. A zero spectrum returns rank one; this heuristic does not test for absence of factors. Specify ranks explicitly for small dimensions or weak signals.

References

Wang, Liu and Chen (2019), Factor Models for Matrix-Valued High-Dimensional Time Series, Section 3. https://doi.org/10.1016/j.jeconom.2018.09.013 The numerical floor and zero-spectrum fallback are implementation conventions, not a calibrated test of the absence of factors.

def fit_alpha_pca(X, ranks=None, *, alpha=0.0, center=False):
198def fit_alpha_pca(X, ranks=None, *, alpha=0.0, center=False):
199    """Fit alpha-PCA of Chen and Fan (2023; published online 2021).
200
201    The moment combines temporal covariance and ``(1 + alpha)`` times the
202    outer product of the mean. ``alpha=-1`` uses covariance only; ``alpha=0``
203    uses uncentered second moments. ``center=True`` first removes a fixed mean
204    and restores it on reconstruction, making alpha immaterial.
205
206    Parameters
207    ----------
208    X : array_like, shape (T, p, q)
209        Finite, real observations.
210    ranks : pair of int or None, optional
211        Loading ranks. A missing rank uses an adjacent eigenvalue ratio.
212    alpha : float, default 0
213        First-moment weight adjustment, finite and at least -1.
214    center : bool, default False
215        Estimate and subtract an unrestricted temporal mean.
216
217    References
218    ----------
219    Chen and Fan (2023; online 2021), Statistical Inference for
220    High-Dimensional Matrix-Variate Factor Models.
221    https://doi.org/10.1080/01621459.2021.1970569
222    """
223    alpha = finite_scalar(alpha, "alpha", minimum=-1)
224    X, work, mean, scale = _prepare(X, center)
225    ranks = _factor_ranks(ranks, X.shape[1:])
226    # A convex rescaling avoids overflow even for alpha near float64's maximum.
227    weight = np.sqrt(float(alpha) + 1)
228    divisor = max(1.0, weight)
229    work_mean = work.mean(axis=0)
230    adjusted = (work - work_mean) / divisor + (weight / divisor) * work_mean
231    pairs = [
232        _eigenspace(moment, rank)
233        for moment, rank in zip(_matrix_moments(adjusted), ranks)
234    ]
235    return _result(
236        X,
237        [pair[0] for pair in pairs],
238        mean,
239        [pair[1] for pair in pairs],
240        "alpha-pca",
241        scale,
242    )

Fit alpha-PCA of Chen and Fan (2023; published online 2021).

The moment combines temporal covariance and (1 + alpha) times the outer product of the mean. alpha=-1 uses covariance only; alpha=0 uses uncentered second moments. center=True first removes a fixed mean and restores it on reconstruction, making alpha immaterial.

Parameters
  • X (array_like, shape (T, p, q)): Finite, real observations.
  • ranks (pair of int or None, optional): Loading ranks. A missing rank uses an adjacent eigenvalue ratio.
  • alpha (float, default 0): First-moment weight adjustment, finite and at least -1.
  • center (bool, default False): Estimate and subtract an unrestricted temporal mean.
References

Chen and Fan (2023; online 2021), Statistical Inference for High-Dimensional Matrix-Variate Factor Models. https://doi.org/10.1080/01621459.2021.1970569

def fit_constrained_factor( X, ranks=None, *, row_constraints=None, column_constraints=None, lags=1, center=False):
 57def fit_constrained_factor(
 58    X,
 59    ranks=None,
 60    *,
 61    row_constraints=None,
 62    column_constraints=None,
 63    lags=1,
 64    center=False,
 65):
 66    """Fit a single-term constrained lagged matrix factor model.
 67
 68    ``row_constraints`` and ``column_constraints`` are full-column-rank bases
 69    for the permitted loading spaces, with shapes ``(m, q_row)`` and
 70    ``(n, q_col)``. ``None`` leaves that side unrestricted. Ranks cannot exceed
 71    the corresponding constraint dimensions. Constraint columns are
 72    orthonormalized; their units and invertible reparameterizations do not
 73    change the estimated spaces.
 74
 75    Implements Chen, Tsay and Chen (2020), Sections 3.1--3.2: project into the
 76    constraint spaces, estimate lagged factors, then lift the loadings. The
 77    Partial and multi-term models have separate fitting functions. White
 78    measurement noise and temporally informative factors are required.
 79
 80    Returns a ``FactorResult`` with orthonormal loadings in the original
 81    coordinates. ``center=True`` adds an unrestricted training mean; constraints
 82    then apply to deviations from that mean. No entrywise standardization is
 83    performed. ``lags`` and automatic ranks follow ``fit_lagged_factor``.
 84
 85    References
 86    ----------
 87    Chen, Tsay and Chen (2020), Constrained Factor Models for High-Dimensional
 88    Matrix-Variate Time Series, Sections 3.1--3.2.
 89    https://doi.org/10.1080/01621459.2019.1584899
 90    """
 91    X, work, mean, scale = _prepare(X, center)
 92    row = _constraint_basis(row_constraints, X.shape[1], "row_constraints")
 93    column = _constraint_basis(column_constraints, X.shape[2], "column_constraints")
 94    ranks = _factor_ranks(ranks, (row.shape[1], column.shape[1]))
 95    lags = _lags(lags, len(X))
 96    reduced = row.T @ work @ column
 97    moments = (
 98        _lagged_moment(reduced, lags, "topup"),
 99        _lagged_moment(reduced.transpose(0, 2, 1), lags, "topup"),
100    )
101    pairs = [_eigenspace(moment, rank) for moment, rank in zip(moments, ranks)]
102    loadings = [
103        _canonical_signs(basis @ pair[0]) for basis, pair in zip((row, column), pairs)
104    ]
105    return _result(
106        X, loadings, mean, [pair[1] for pair in pairs], "constrained-lagged", scale
107    )

Fit a single-term constrained lagged matrix factor model.

row_constraints and column_constraints are full-column-rank bases for the permitted loading spaces, with shapes (m, q_row) and (n, q_col). None leaves that side unrestricted. Ranks cannot exceed the corresponding constraint dimensions. Constraint columns are orthonormalized; their units and invertible reparameterizations do not change the estimated spaces.

Implements Chen, Tsay and Chen (2020), Sections 3.1--3.2: project into the constraint spaces, estimate lagged factors, then lift the loadings. The Partial and multi-term models have separate fitting functions. White measurement noise and temporally informative factors are required.

Returns a FactorResult with orthonormal loadings in the original coordinates. center=True adds an unrestricted training mean; constraints then apply to deviations from that mean. No entrywise standardization is performed. lags and automatic ranks follow fit_lagged_factor.

References

Chen, Tsay and Chen (2020), Constrained Factor Models for High-Dimensional Matrix-Variate Time Series, Sections 3.1--3.2. https://doi.org/10.1080/01621459.2019.1584899

def fit_lagged_factor(X, ranks=None, *, lags=1, center=False):
365def fit_lagged_factor(X, ranks=None, *, lags=1, center=False):
366    """Fit the lagged cross-covariance matrix factor model of Wang et al. (2019).
367
368    Uses all pairs of columns (and rows), summing squared lagged cross-moments.
369    This is matrix TOPUP, not the sum-before-squaring TIPUP approximation. An
370    integer ``lags=h`` uses 1 through h; a sequence selects individual lags.
371    White measurement noise and serially informative factors are required for
372    identification. With ``center=False`` the model assumes zero mean.
373
374    References
375    ----------
376    Wang, Liu and Chen (2019), Factor Models for Matrix-Valued
377    High-Dimensional Time Series, Section 3.
378    https://doi.org/10.1016/j.jeconom.2018.09.013
379    """
380    X, work, mean, scale = _prepare(X, center)
381    ranks = _factor_ranks(ranks, X.shape[1:])
382    lags = _lags(lags, len(X))
383    moments = [
384        _lagged_moment(work, lags, "topup"),
385        _lagged_moment(work.transpose(0, 2, 1), lags, "topup"),
386    ]
387    pairs = [_eigenspace(moment, rank) for moment, rank in zip(moments, ranks)]
388    return _result(
389        X,
390        [pair[0] for pair in pairs],
391        mean,
392        [pair[1] for pair in pairs],
393        "lagged-factor",
394        scale,
395    )

Fit the lagged cross-covariance matrix factor model of Wang et al. (2019).

Uses all pairs of columns (and rows), summing squared lagged cross-moments. This is matrix TOPUP, not the sum-before-squaring TIPUP approximation. An integer lags=h uses 1 through h; a sequence selects individual lags. White measurement noise and serially informative factors are required for identification. With center=False the model assumes zero mean.

References

Wang, Liu and Chen (2019), Factor Models for Matrix-Valued High-Dimensional Time Series, Section 3. https://doi.org/10.1016/j.jeconom.2018.09.013

def fit_mar( X, *, method='als', order=1, fit_intercept=False, max_iter=200, tol=1e-08, ridge=0.0, init='auto', initial=None, random_state=None, covariance_floor=1e-08, initial_covariance=None, ranks=None):
330def fit_mar(
331    X,
332    *,
333    method="als",
334    order=1,
335    fit_intercept=False,
336    max_iter=200,
337    tol=1e-8,
338    ridge=0.0,
339    init="auto",
340    initial=None,
341    random_state=None,
342    covariance_floor=1e-8,
343    initial_covariance=None,
344    ranks=None,
345):
346    """Fit matrix autoregression with one bilinear term per lag.
347
348    Parameters
349    ----------
350    X : array_like, shape (T, m, n)
351        Finite real observations, with time on the first axis.
352    method : {'als', 'projection', 'mle'}
353        ALS minimizes squared prediction error. Projection fits a dense
354        unrestricted VAR, then projects each lag onto a Kronecker product.
355        MLE uses alternating weighted regressions and row/column covariance
356        updates for Gaussian innovations with separable covariance.
357    order : int, default 1
358        Number of lags, with a distinct pair of factors for each lag.
359    fit_intercept : bool, default False
360        Profile a free matrix intercept by separately centering the response
361        and each lag predictor over the regression sample.
362    max_iter : int, default 200
363        Maximum complete coordinate sweeps for iterative methods.
364    tol : float, default 1e-8
365        Relative objective-change tolerance. Check ``result.converged``.
366    ridge : float, default 0
367        ALS objective adds ``ridge * sum(||A_l||_F**2 * ||B_l||_F**2)``.
368        This equals a ridge penalty on the structured VAR coefficients and
369        is invariant to factor rescaling. Unsupported for MLE/projection.
370    init : {'auto', 'projection', 'identity', 'random'}, default 'auto'
371        Auto uses projection when m*n <= 256, identity otherwise. Identity
372        and random avoid allocating a dense unrestricted VAR. Initialization
373        affects local solutions; compare starts on difficult problems.
374    initial : pair of arrays, optional
375        Left/right factors with a leading lag axis (2-D accepted for order
376        one), copied before fitting. Overrides ``init``.
377    random_state : int or numpy.random.Generator, optional
378        Random initializer seed; never changes NumPy's global RNG.
379    covariance_floor : float, default 1e-8
380        MLE covariance eigenvalues are floored relative to their largest
381        eigenvalue. If flooring occurs, ``covariance_regularized`` is True:
382        that result is a stabilized likelihood estimate, not an unregularized
383        MLE. Set zero for strict positive-definite covariance updates.
384    initial_covariance : (row_covariance, column_covariance), optional
385        Positive-definite starting covariances for MLE.
386    ranks : (row_rank, column_rank), optional
387        Upper bounds on the ranks of A and B for every lag. Supported by
388        ALS; each block uses exact reduced-rank regression, including the
389        augmented-design ridge objective when requested. For order one and
390        ridge zero this is RR.LS, Section 3.1 of Xiao, Han, Chen and Liu,
391        "Reduced Rank Autoregressive Models for Matrix Time Series",
392        https://yuefenghan.github.io/papers/Reduced_Rank_MAR.pdf.
393        The paper's distinct RR.CC estimator is not implemented here.
394
395    Returns
396    -------
397    MARResult
398        Coefficients, residuals, recursive forecasting and fit diagnostics.
399        Objective histories use data divided by ``result.data_scale``:
400        ALS/projection records scaled residual sum of squares plus the
401        equivalently scaled ridge penalty; MLE records negative conditional
402        Gaussian log likelihood of the scaled data. ``log_likelihood`` is
403        in original data units. See MARResult for conversion formulas.
404
405    Notes
406    -----
407    Regressions use SVD least squares on the design matrix, including an
408    augmented design for ridge, avoiding squared conditioning from normal
409    equations. ALS works with matrix-sized designs. Projection explicitly
410    allocates a dense VAR coefficient of size p*(m*n)**2, so is unsuitable
411    for large matrices. No estimator constrains stationarity or handles
412    missing observations. Finite separable MLEs need not exist for small or
413    degenerate samples; singular updates raise when flooring is disabled.
414
415    References
416    ----------
417    Chen, R., Xiao, H. and Yang, D. (2021), "Autoregressive Models for
418    Matrix-Valued Time Series", Section 3,
419    https://doi.org/10.1016/j.jeconom.2020.07.015.
420    Xiao, H., Han, Y., Chen, R. and Liu, C., "Reduced Rank Autoregressive
421    Models for Matrix Time Series", Section 3.1 (RR.LS),
422    https://yuefenghan.github.io/papers/Reduced_Rank_MAR.pdf.
423    Multiple lags, intercept profiling, ridge penalties and covariance
424    flooring are package extensions, not the papers' unregularized MAR(1)
425    estimators or their asymptotic guarantees.
426
427    Examples
428    --------
429    >>> rng = np.random.default_rng(7)
430    >>> data = rng.normal(size=(100, 2, 3))
431    >>> result = fit_mar(data, fit_intercept=True)
432    >>> result.forecast(5).shape
433    (5, 2, 3)
434    """
435    order = positive_int(order, "order")
436    X = as_series(X, min_samples=order + 1)
437    max_iter = positive_int(max_iter, "max_iter")
438    tol, ridge = _nonnegative(tol, "tol"), _nonnegative(ridge, "ridge")
439    covariance_floor = _nonnegative(covariance_floor, "covariance_floor")
440    if covariance_floor >= 1:
441        raise ValueError("covariance_floor must be less than one")
442    if not isinstance(fit_intercept, (bool, np.bool_)):
443        raise ValueError("fit_intercept must be boolean")
444    if method not in ("als", "projection", "mle"):
445        raise ValueError("method must be 'als', 'projection', or 'mle'")
446    if ridge and method != "als":
447        raise ValueError("ridge is supported only for method='als'")
448    if init not in ("auto", "projection", "identity", "random"):
449        raise ValueError("init must be 'auto', 'projection', 'identity', or 'random'")
450    if initial_covariance is not None and method != "mle":
451        raise ValueError("initial_covariance is supported only for method='mle'")
452    T, m, n = X.shape
453    original = X
454    original_ridge = ridge
455    data_scale = float(max(np.max(np.abs(X)), np.sqrt(ridge))) or 1.0
456    X = X / data_scale
457    ridge = (np.sqrt(ridge) / data_scale) ** 2
458    ranks = ranks_tuple(ranks, (m, n))
459    if ranks is not None and method != "als":
460        raise ValueError("rank constraints are supported only for method='als'")
461    row_rank, col_rank = ranks if ranks is not None else (None, None)
462    raw_lags = [X[order - lag : T - lag] for lag in range(1, order + 1)]
463    response_mean = X[order:].mean(axis=0) if fit_intercept else np.zeros((m, n))
464    lag_means = [
465        z.mean(axis=0) if fit_intercept else np.zeros((m, n)) for z in raw_lags
466    ]
467    Y = X[order:] - response_mean
468    lags = [z - mean for z, mean in zip(raw_lags, lag_means)]
469    use_projection = method == "projection" or (
470        initial is None and (init == "projection" or (init == "auto" and m * n <= 256))
471    )
472    if use_projection:
473        design = np.concatenate(
474            [z.transpose(0, 2, 1).reshape(T - order, -1) for z in lags], axis=1
475        )
476        Phi = _lstsq(design, Y.transpose(0, 2, 1).reshape(T - order, -1)).T
477        pairs = [
478            nearest_kronecker_product(Phi[:, j * m * n : (j + 1) * m * n], (m, n))
479            for j in range(order)
480        ]
481        left, right = (np.stack(part) for part in zip(*pairs))
482    elif initial is not None:
483        if len(initial) != 2:
484            raise ValueError("initial must contain left and right coefficients")
485        arrays = []
486        for raw, dim in zip(initial, (m, n)):
487            if np.iscomplexobj(raw):
488                raise ValueError("initial coefficients must be real-valued")
489            value = np.array(raw, dtype=float, copy=True)
490            if order == 1 and value.ndim == 2:
491                value = value[None]
492            if value.shape != (order, dim, dim) or not np.isfinite(value).all():
493                raise ValueError(
494                    "initial coefficient dimensions do not match order and X"
495                )
496            arrays.append(value)
497        left, right = arrays
498    elif init == "random":
499        rng = random_generator(random_state)
500        left, right = rng.normal(size=(order, m, m)), rng.normal(size=(order, n, n))
501    else:
502        left = np.repeat(np.eye(m)[None], order, axis=0)
503        right = np.repeat(np.eye(n)[None], order, axis=0) / order
504    for j in range(order):
505        if ranks is not None:
506            for coefficients, rank in ((left, row_rank), (right, col_rank)):
507                u, s, vh = np.linalg.svd(coefficients[j], full_matrices=False)
508                coefficients[j] = (u[:, :rank] * s[:rank]) @ vh[:rank]
509        left[j], right[j] = _normalize(left[j], right[j])
510    row_cov, col_cov = np.eye(m), np.eye(n)
511    if initial_covariance is not None:
512        if len(initial_covariance) != 2:
513            raise ValueError(
514                "initial_covariance must contain row and column covariances"
515            )
516        covs = []
517        for raw, dim in zip(initial_covariance, (m, n)):
518            if np.iscomplexobj(raw):
519                raise ValueError("initial covariances must be real-valued")
520            cov = np.array(raw, dtype=float, copy=True)
521            if (
522                cov.shape != (dim, dim)
523                or not np.isfinite(cov).all()
524                or not np.allclose(cov, cov.T)
525            ):
526                raise ValueError(
527                    "initial covariance must be finite, symmetric and dimensionally compatible"
528                )
529            cov = cov / data_scale
530            cov = cov / 2 + cov.T / 2
531            _whitener(cov)
532            covs.append(cov)
533        row_cov, col_cov = covs
534    residuals = Y - _prediction(lags, left, right)
535
536    def objective():
537        if method == "mle":
538            return _negative_loglike(residuals, row_cov, col_cov)
539        penalty = ridge * sum(
540            np.sum(A * A) * np.sum(B * B) for A, B in zip(left, right)
541        )
542        return float(np.sum(residuals**2) + penalty)
543
544    objectives = [objective()]
545    if not np.isfinite(objectives[0]):
546        raise FloatingPointError(
547            "initial MAR objective is not finite; check starting parameters"
548        )
549    converged, n_iter, regularized = method == "projection", 0, False
550    if method != "projection":
551        for n_iter in range(1, max_iter + 1):
552            for j, Z in enumerate(lags):
553                target = residuals + left[j] @ Z @ right[j].T
554                left[j] = _left_update(
555                    target,
556                    Z,
557                    right[j],
558                    ridge * np.sum(right[j] ** 2),
559                    _whitener(col_cov) if method == "mle" else None,
560                    row_rank,
561                )
562                # A zero block can be represented by nonzero A and zero B,
563                # allowing the subsequent B update to leave that boundary.
564                left[j], right[j] = _normalize(left[j], right[j])
565                right[j] = _right_update(
566                    target,
567                    Z,
568                    left[j],
569                    ridge * np.sum(left[j] ** 2),
570                    _whitener(row_cov) if method == "mle" else None,
571                    col_rank,
572                )
573                left[j], right[j] = _normalize(left[j], right[j])
574                residuals = target - left[j] @ Z @ right[j].T
575            if method == "mle":
576                row_cov, col_cov, active = _covariance_update(
577                    residuals, row_cov, col_cov, covariance_floor
578                )
579                regularized |= active
580            current = objective()
581            if not np.isfinite(current):
582                raise FloatingPointError(
583                    "MAR objective is not finite; rescale the observations"
584                )
585            objectives.append(current)
586            if abs(objectives[-2] - current) <= tol * max(
587                abs(current), abs(objectives[-2]), np.finfo(float).tiny
588            ):
589                converged = True
590                break
591    intercept = response_mean.copy()
592    for A, mean, B in zip(left, lag_means, right):
593        intercept -= A @ mean @ B.T
594    with np.errstate(over="ignore", invalid="ignore"):
595        fitted = (intercept + _prediction(raw_lags, left, right)) * data_scale
596        intercept = intercept * data_scale
597        residuals = original[order:] - fitted
598        row_cov = row_cov * data_scale
599        col_cov = col_cov * data_scale
600    if not all(
601        np.isfinite(value).all()
602        for value in (fitted, intercept, residuals, row_cov, col_cov)
603    ):
604        raise FloatingPointError(
605            "MAR fitted values or covariance factors exceed floating-point range"
606        )
607    return MARResult(
608        left=left,
609        right=right,
610        intercept=intercept,
611        fitted_values=fitted,
612        residuals=residuals,
613        method=method,
614        converged=converged,
615        n_iter=n_iter,
616        objective_history=np.asarray(objectives),
617        row_covariance=row_cov if method == "mle" else None,
618        column_covariance=col_cov if method == "mle" else None,
619        covariance_regularized=regularized,
620        ridge=original_ridge,
621        ranks=ranks,
622        data_scale=data_scale,
623        _history=original[-order:].copy(),
624    )

Fit matrix autoregression with one bilinear term per lag.

Parameters
  • X (array_like, shape (T, m, n)): Finite real observations, with time on the first axis.
  • method ({'als', 'projection', 'mle'}): ALS minimizes squared prediction error. Projection fits a dense unrestricted VAR, then projects each lag onto a Kronecker product. MLE uses alternating weighted regressions and row/column covariance updates for Gaussian innovations with separable covariance.
  • order (int, default 1): Number of lags, with a distinct pair of factors for each lag.
  • fit_intercept (bool, default False): Profile a free matrix intercept by separately centering the response and each lag predictor over the regression sample.
  • max_iter (int, default 200): Maximum complete coordinate sweeps for iterative methods.
  • tol (float, default 1e-8): Relative objective-change tolerance. Check result.converged.
  • ridge (float, default 0): ALS objective adds ridge * sum(||A_l||_F**2 * ||B_l||_F**2). This equals a ridge penalty on the structured VAR coefficients and is invariant to factor rescaling. Unsupported for MLE/projection.
  • init ({'auto', 'projection', 'identity', 'random'}, default 'auto'): Auto uses projection when m*n <= 256, identity otherwise. Identity and random avoid allocating a dense unrestricted VAR. Initialization affects local solutions; compare starts on difficult problems.
  • initial (pair of arrays, optional): Left/right factors with a leading lag axis (2-D accepted for order one), copied before fitting. Overrides init.
  • random_state (int or numpy.random.Generator, optional): Random initializer seed; never changes NumPy's global RNG.
  • covariance_floor (float, default 1e-8): MLE covariance eigenvalues are floored relative to their largest eigenvalue. If flooring occurs, covariance_regularized is True: that result is a stabilized likelihood estimate, not an unregularized MLE. Set zero for strict positive-definite covariance updates.
  • initial_covariance ((row_covariance, column_covariance), optional): Positive-definite starting covariances for MLE.
  • ranks ((row_rank, column_rank), optional): Upper bounds on the ranks of A and B for every lag. Supported by ALS; each block uses exact reduced-rank regression, including the augmented-design ridge objective when requested. For order one and ridge zero this is RR.LS, Section 3.1 of Xiao, Han, Chen and Liu, "Reduced Rank Autoregressive Models for Matrix Time Series", https://yuefenghan.github.io/papers/Reduced_Rank_MAR.pdf. The paper's distinct RR.CC estimator is not implemented here.
Returns
  • MARResult: Coefficients, residuals, recursive forecasting and fit diagnostics. Objective histories use data divided by result.data_scale: ALS/projection records scaled residual sum of squares plus the equivalently scaled ridge penalty; MLE records negative conditional Gaussian log likelihood of the scaled data. log_likelihood is in original data units. See MARResult for conversion formulas.
Notes

Regressions use SVD least squares on the design matrix, including an augmented design for ridge, avoiding squared conditioning from normal equations. ALS works with matrix-sized designs. Projection explicitly allocates a dense VAR coefficient of size p*(m*n)**2, so is unsuitable for large matrices. No estimator constrains stationarity or handles missing observations. Finite separable MLEs need not exist for small or degenerate samples; singular updates raise when flooring is disabled.

References

Chen, R., Xiao, H. and Yang, D. (2021), "Autoregressive Models for Matrix-Valued Time Series", Section 3, https://doi.org/10.1016/j.jeconom.2020.07.015. Xiao, H., Han, Y., Chen, R. and Liu, C., "Reduced Rank Autoregressive Models for Matrix Time Series", Section 3.1 (RR.LS), https://yuefenghan.github.io/papers/Reduced_Rank_MAR.pdf. Multiple lags, intercept profiling, ridge penalties and covariance flooring are package extensions, not the papers' unregularized MAR(1) estimators or their asymptotic guarantees.

Examples
>>> rng = np.random.default_rng(7)
>>> data = rng.normal(size=(100, 2, 3))
>>> result = fit_mar(data, fit_intercept=True)
>>> result.forecast(5).shape
(5, 2, 3)
def fit_matrix_kendall(X, ranks=None, *, max_pairs=None, batch_size=256, random_state=None):
 90def fit_matrix_kendall(
 91    X, ranks=None, *, max_pairs=None, batch_size=256, random_state=None
 92):
 93    """Fit the matrix robust two-step (MRTS) factor estimator.
 94
 95    Leading eigenvectors of row/column matrix Kendall's tau give robust
 96    loading spaces, then least-squares projection gives factor scores. Modern
 97    MaVaTS uses orthonormal loadings; this rescales scores relative to the paper
 98    but leaves signal estimates identical. The factor scores themselves are
 99    not robust against individual corrupted observations. No temporal mean is
100    subtracted. The paper's guarantees require its matrix elliptical model;
101    arbitrary contamination or serial dependence need separate justification.
102
103    ``ranks=None`` uses eigenvalue ratios with a relative machine-precision
104    floor and a half-dimension search bound, not the paper's optional tuned
105    statistical ridge. Explicit ranks are recommended in comparative studies.
106
107    References
108    ----------
109    He, Wang, Yu, Zhou and Zhou (2025), A New Non-Parametric Kendall's Tau for
110    Matrix-Valued Elliptical Observations. https://arxiv.org/abs/2207.09633
111    The implementation's optional sampled-pair approximation is described in
112    ``matrix_kendall``; it is not the exact U-statistic used in the theory.
113    """
114    X = as_series(X)
115    ranks = _factor_ranks(ranks, X.shape[1:])
116    moments = matrix_kendall(
117        X, max_pairs=max_pairs, batch_size=batch_size, random_state=random_state
118    )
119    pairs = [_eigenspace(moment, rank) for moment, rank in zip(moments, ranks)]
120    return _result(
121        X,
122        [pair[0] for pair in pairs],
123        np.zeros(X.shape[1:]),
124        [pair[1] for pair in pairs],
125        "matrix-kendall",
126        1.0,
127    )

Fit the matrix robust two-step (MRTS) factor estimator.

Leading eigenvectors of row/column matrix Kendall's tau give robust loading spaces, then least-squares projection gives factor scores. Modern MaVaTS uses orthonormal loadings; this rescales scores relative to the paper but leaves signal estimates identical. The factor scores themselves are not robust against individual corrupted observations. No temporal mean is subtracted. The paper's guarantees require its matrix elliptical model; arbitrary contamination or serial dependence need separate justification.

ranks=None uses eigenvalue ratios with a relative machine-precision floor and a half-dimension search bound, not the paper's optional tuned statistical ridge. Explicit ranks are recommended in comparative studies.

References

He, Wang, Yu, Zhou and Zhou (2025), A New Non-Parametric Kendall's Tau for Matrix-Valued Elliptical Observations. https://arxiv.org/abs/2207.09633 The implementation's optional sampled-pair approximation is described in matrix_kendall; it is not the exact U-statistic used in the theory.

def fit_naive(X, *, strategy='last'):
157def fit_naive(X, *, strategy="last"):
158    """Fit ``last`` (random walk), ``mean``, or ``zero`` baseline.
159
160    References
161    ----------
162    Hyndman and Koehler (2006), *Another Look at Measures of Forecast Accuracy*,
163    https://doi.org/10.1016/j.ijforecast.2006.03.001, discusses random-walk and
164    historical-mean benchmarks. Applying them entrywise to matrices/tensors
165    and the fixed zero benchmark are elementary comparison conventions, not
166    distinct published matrix estimators.
167    """
168    X = as_series(X, min_samples=1, ndim=None)
169    if strategy == "last":
170        value = X[-1].copy()
171    elif strategy == "mean":
172        scale = np.max(np.abs(X)) or 1.0
173        value = (X / scale).mean(axis=0) * scale
174    elif strategy == "zero":
175        value = np.zeros(X.shape[1:])
176    else:
177        raise ValueError("strategy must be 'last', 'mean', or 'zero'")
178    return NaiveResult(value)

Fit last (random walk), mean, or zero baseline.

References

Hyndman and Koehler (2006), Another Look at Measures of Forecast Accuracy, https://doi.org/10.1016/j.ijforecast.2006.03.001, discusses random-walk and historical-mean benchmarks. Applying them entrywise to matrices/tensors and the fixed zero benchmark are elementary comparison conventions, not distinct published matrix estimators.

def fit_projected_pca(X, ranks=None, *, max_iter=1, tol=1e-08, center=False):
245def fit_projected_pca(X, ranks=None, *, max_iter=1, tol=1e-8, center=False):
246    """Fit projected matrix PCA of Yu, He, Kong and Zhang (2022).
247
248    The default implements Algorithm 1: alpha=0 initialization followed by
249    one simultaneous update of both spaces using projections onto the opposite
250    initial loading space. ``max_iter>1`` recursively repeats those simultaneous
251    updates. Automatic ranks are selected once from the initial moments; this
252    is not the paper's iterative rank selection algorithm.
253
254    References
255    ----------
256    Yu, He, Kong and Zhang (2022), Projected Estimation for Large-Dimensional
257    Matrix Factor Models, Algorithm 1. https://doi.org/10.1016/j.jeconom.2021.04.001
258    Author manuscript: https://arxiv.org/abs/2003.10285
259    """
260    max_iter = positive_int(max_iter, "max_iter")
261    tol = finite_scalar(tol, "tol", minimum=0)
262    if tol == 0:
263        raise ValueError("tol must be finite and positive")
264    X, work, mean, scale = _prepare(X, center)
265    ranks = _factor_ranks(ranks, X.shape[1:])
266    pairs = [
267        _eigenspace(moment, rank) for moment, rank in zip(_matrix_moments(work), ranks)
268    ]
269    loadings = [pair[0] for pair in pairs]
270    ranks = [loading.shape[1] for loading in loadings]
271    converged = max_iter == 1
272    for iteration in range(1, max_iter + 1):
273        previous = loadings
274        projected_rows = work @ previous[1]
275        projected_cols = work.transpose(0, 2, 1) @ previous[0]
276        denominator = np.prod(X.shape)
277        moments = [
278            np.einsum("tij,tkj->ik", Z, Z, optimize=True) / denominator
279            for Z in (projected_rows, projected_cols)
280        ]
281        pairs = [_eigenspace(moment, rank) for moment, rank in zip(moments, ranks)]
282        loadings = [pair[0] for pair in pairs]
283        if _space_distance(loadings, previous) < tol:
284            converged = True
285            break
286    return _result(
287        X,
288        loadings,
289        mean,
290        [pair[1] for pair in pairs],
291        "projected-pca",
292        scale,
293        iteration,
294        converged,
295    )

Fit projected matrix PCA of Yu, He, Kong and Zhang (2022).

The default implements Algorithm 1: alpha=0 initialization followed by one simultaneous update of both spaces using projections onto the opposite initial loading space. max_iter>1 recursively repeats those simultaneous updates. Automatic ranks are selected once from the initial moments; this is not the paper's iterative rank selection algorithm.

References

Yu, He, Kong and Zhang (2022), Projected Estimation for Large-Dimensional Matrix Factor Models, Algorithm 1. https://doi.org/10.1016/j.jeconom.2021.04.001 Author manuscript: https://arxiv.org/abs/2003.10285

def fit_tensor_factor( X, ranks=None, *, method='tipup', lags=1, iterative=False, max_iter=100, tol=1e-08, center=False):
 23def fit_tensor_factor(
 24    X,
 25    ranks=None,
 26    *,
 27    method="tipup",
 28    lags=1,
 29    iterative=False,
 30    max_iter=100,
 31    tol=1e-8,
 32    center=False,
 33):
 34    """Fit TOPUP, TIPUP, iTOPUP or iTIPUP to matrix or higher-order tensor series.
 35
 36    Parameters
 37    ----------
 38    X : array_like, shape (T, d1, ..., dK)
 39        Finite real observations, with at least two spatial modes.
 40    ranks : sequence of int or None, optional
 41        One rank per spatial mode. Missing ranks use the initial eigenvalue
 42        ratio heuristic and stay fixed during iteration.
 43    method : {'topup', 'tipup'}, default 'tipup'
 44        TOPUP uses all lagged fiber cross-products; TIPUP contracts matching
 45        fibers before squaring and is cheaper but can suffer cancellation.
 46    lags : int or sequence of int, default 1
 47        Integer h includes lags 1,...,h. A sequence selects specific lags.
 48    iterative : bool, default False
 49        Project the other modes onto the current loading estimates and update
 50        modes in sequence (Gauss-Seidel) until projectors converge.
 51    max_iter : int, default 100
 52        Maximum projection sweeps after initialization.
 53    tol : float, default 1e-8
 54        Maximum Frobenius change of a loading projector for convergence.
 55    center : bool, default False
 56        Remove and retain the training mean for future transformations.
 57
 58    Returns
 59    -------
 60    FactorResult
 61        Orthonormal loadings, core factors, signal, residuals and diagnostics.
 62
 63    Notes
 64    -----
 65    Assumes serially white measurement error and informative factor lag
 66    moments. Nonzero means require centering or a justified model for the mean.
 67    TOPUP bounds its intermediate allocation using exact column blocks.
 68
 69    References
 70    ----------
 71    Chen, Yang and Zhang (2022), Factor Models for High-Dimensional Tensor
 72    Time Series. https://arxiv.org/abs/1905.07530
 73    Han, Chen, Yang and Zhang (2024), Tensor Factor Model Estimation by Iterative
 74    Projection. https://doi.org/10.1214/24-AOS2412
 75    Algorithm and sequential implementation described in tensorTS, eq. (14):
 76    https://yuefenghan.github.io/papers/R_software_paper_tensorTS.pdf
 77    """
 78    method = str(method).lower()
 79    if method not in {"topup", "tipup"}:
 80        raise ValueError("method must be 'topup' or 'tipup'")
 81    if not isinstance(iterative, (bool, np.bool_)):
 82        raise ValueError("iterative must be boolean")
 83    max_iter = positive_int(max_iter, "max_iter")
 84    tol = finite_scalar(tol, "tol", minimum=0)
 85    if tol == 0:
 86        raise ValueError("tol must be finite and positive")
 87    ndim = np.ndim(X)
 88    if ndim < 3:
 89        raise ValueError("X needs a time axis and at least two spatial modes")
 90    X, work, mean, scale = _prepare(X, center, ndim=ndim)
 91    ranks = _factor_ranks(ranks, X.shape[1:])
 92    lags = _lags(lags, len(X))
 93    pairs = [
 94        _eigenspace(_lagged_moment(_unfold_series(work, mode), lags, method), rank)
 95        for mode, rank in enumerate(ranks)
 96    ]
 97    loadings = [pair[0] for pair in pairs]
 98    spectra = [pair[1] for pair in pairs]
 99    ranks = [loading.shape[1] for loading in loadings]
100    converged = not iterative
101    iteration = 0
102    if iterative:
103        for iteration in range(1, max_iter + 1):
104            previous = list(loadings)
105            for mode, rank in enumerate(ranks):
106                projected = _project(work, loadings, skip=mode)
107                moment = _lagged_moment(_unfold_series(projected, mode), lags, method)
108                loadings[mode], spectra[mode] = _eigenspace(moment, rank)
109            if _space_distance(loadings, previous) < tol:
110                converged = True
111                break
112    return _result(
113        X,
114        loadings,
115        mean,
116        spectra,
117        ("i" if iterative else "") + method,
118        scale,
119        iteration,
120        converged,
121    )

Fit TOPUP, TIPUP, iTOPUP or iTIPUP to matrix or higher-order tensor series.

Parameters
  • X (array_like, shape (T, d1, ..., dK)): Finite real observations, with at least two spatial modes.
  • ranks (sequence of int or None, optional): One rank per spatial mode. Missing ranks use the initial eigenvalue ratio heuristic and stay fixed during iteration.
  • method ({'topup', 'tipup'}, default 'tipup'): TOPUP uses all lagged fiber cross-products; TIPUP contracts matching fibers before squaring and is cheaper but can suffer cancellation.
  • lags (int or sequence of int, default 1): Integer h includes lags 1,...,h. A sequence selects specific lags.
  • iterative (bool, default False): Project the other modes onto the current loading estimates and update modes in sequence (Gauss-Seidel) until projectors converge.
  • max_iter (int, default 100): Maximum projection sweeps after initialization.
  • tol (float, default 1e-8): Maximum Frobenius change of a loading projector for convergence.
  • center (bool, default False): Remove and retain the training mean for future transformations.
Returns
  • FactorResult: Orthonormal loadings, core factors, signal, residuals and diagnostics.
Notes

Assumes serially white measurement error and informative factor lag moments. Nonzero means require centering or a justified model for the mean. TOPUP bounds its intermediate allocation using exact column blocks.

References

Chen, Yang and Zhang (2022), Factor Models for High-Dimensional Tensor Time Series. https://arxiv.org/abs/1905.07530 Han, Chen, Yang and Zhang (2024), Tensor Factor Model Estimation by Iterative Projection. https://doi.org/10.1214/24-AOS2412 Algorithm and sequential implementation described in tensorTS, eq. (14): https://yuefenghan.github.io/papers/R_software_paper_tensorTS.pdf

def fit_var(X, *, order=1, intercept=True, ridge=0.0):
 85def fit_var(X, *, order=1, intercept=True, ridge=0.0):
 86    """Fit unrestricted VAR(p) using SVD least squares.
 87
 88    Ridge minimizes ``sum(error**2) + ridge * sum(coefficients**2)``;
 89    the intercept is not penalized. It is implemented by augmented least
 90    squares, avoiding squared condition numbers from normal equations.
 91    A common scale is removed before centering and fitting. Returned
 92    ``singular_values`` describe that scaled augmented design; multiply by
 93    ``design_scale`` to obtain the original units when representable.
 94
 95    References
 96    ----------
 97    Chen, Xiao and Yang (2021), *Autoregressive Models for Matrix-Valued Time
 98    Series*, https://doi.org/10.1016/j.jeconom.2020.07.015 (vector baseline).
 99    Optional ridge uses the quadratic penalty of Hoerl and Kennard (1970),
100    *Ridge Regression: Biased Estimation for Nonorthogonal Problems*,
101    https://doi.org/10.1080/00401706.1970.10488634. The matrix flattening,
102    unpenalized intercept and scale-safe SVD are implementation choices.
103    """
104    order = positive_int(order, "order")
105    X = as_series(X, min_samples=order + 1)
106    ridge = finite_scalar(ridge, "ridge", minimum=0)
107    if not isinstance(intercept, (bool, np.bool_)):
108        raise ValueError("intercept must be boolean")
109    t, m, n = X.shape
110    scale = float(max(np.max(np.abs(X)), np.sqrt(ridge))) or 1.0
111    flat = (X / scale).transpose(0, 2, 1).reshape(t, m * n)
112    design = np.concatenate(
113        [flat[order - lag : t - lag] for lag in range(1, order + 1)], axis=1
114    )
115    target = flat[order:]
116    dx = design.mean(axis=0) if intercept else np.zeros(design.shape[1])
117    dy = target.mean(axis=0) if intercept else np.zeros(target.shape[1])
118    a, b = design - dx, target - dy
119    if ridge:
120        a = np.concatenate((a, (np.sqrt(ridge) / scale) * np.eye(a.shape[1])))
121        b = np.concatenate((b, np.zeros((design.shape[1], b.shape[1]))))
122    beta, _, rank, singular = np.linalg.lstsq(a, b, rcond=None)
123    offset = dy - dx @ beta
124    with np.errstate(over="ignore", invalid="ignore"):
125        fitted = ((design @ beta + offset) * scale).reshape(-1, n, m).transpose(0, 2, 1)
126        offset = offset * scale
127        residuals = X[order:] - fitted
128    if not all(np.isfinite(value).all() for value in (beta, fitted, offset, residuals)):
129        raise FloatingPointError(
130            "VAR fitted parameters or residuals exceed floating-point range"
131        )
132    coefficients = beta.reshape(order, m * n, m * n).transpose(0, 2, 1).copy()
133    return VARResult(
134        coefficients,
135        offset,
136        fitted,
137        residuals,
138        X[-order:].copy(),
139        int(rank),
140        singular,
141        scale,
142    )

Fit unrestricted VAR(p) using SVD least squares.

Ridge minimizes sum(error**2) + ridge * sum(coefficients**2); the intercept is not penalized. It is implemented by augmented least squares, avoiding squared condition numbers from normal equations. A common scale is removed before centering and fitting. Returned singular_values describe that scaled augmented design; multiply by design_scale to obtain the original units when representable.

References

Chen, Xiao and Yang (2021), Autoregressive Models for Matrix-Valued Time Series, https://doi.org/10.1016/j.jeconom.2020.07.015 (vector baseline). Optional ridge uses the quadratic penalty of Hoerl and Kennard (1970), Ridge Regression: Biased Estimation for Nonorthogonal Problems, https://doi.org/10.1080/00401706.1970.10488634. The matrix flattening, unpenalized intercept and scale-safe SVD are implementation choices.

def matrix_kendall(X, *, max_pairs=None, batch_size=256, random_state=None):
19def matrix_kendall(X, *, max_pairs=None, batch_size=256, random_state=None):
20    """Return row and column sample matrix Kendall matrices.
21
22    Average ``D @ D.T / ||D||_F**2`` and ``D.T @ D / ||D||_F**2`` over
23    unordered temporal pairs, D = X[t] - X[s]. Exact evaluation is quadratic
24    in sample count but batch memory is linear in ``batch_size``. Duplicate
25    observations contribute zero; all-identical observations are rejected.
26
27    ``max_pairs`` opts into a Monte Carlo approximation: that many independent
28    uniform unordered pairs are sampled *with replacement*. This is unbiased
29    for the exact pair average, but it is not the paper's exact U-statistic.
30    ``random_state`` controls only this approximation.
31
32    References
33    ----------
34    He, Wang, Yu, Zhou and Zhou (2025), A New Non-Parametric Kendall's Tau for
35    Matrix-Valued Elliptical Observations, equations (2.3)--(2.4).
36    https://arxiv.org/abs/2207.09633
37    Earlier manuscript title: Matrix Kendall's Tau in High-Dimensions:
38    A Robust Statistic for Matrix Factor Model.
39    """
40    X = as_series(X)
41    batch_size = positive_int(batch_size, "batch_size")
42    t, m, n = X.shape
43    total_pairs = t * (t - 1) // 2
44    if max_pairs is not None:
45        max_pairs = positive_int(max_pairs, "max_pairs")
46    # Scaling before subtraction prevents overflow on opposite huge values.
47    scale = max(float(np.max(np.abs(X))), np.finfo(float).tiny)
48    work = X / scale
49    row, col = np.zeros((m, m)), np.zeros((n, n))
50    nonzero, pairs = 0, 0
51
52    def accumulate(differences):
53        nonlocal row, col, nonzero, pairs
54        pairs += len(differences)
55        pair_scale = np.max(np.abs(differences), axis=(1, 2))
56        valid = pair_scale > 0
57        nonzero += int(valid.sum())
58        differences = differences[valid] / pair_scale[valid, None, None]
59        if not len(differences):
60            return
61        differences /= np.sqrt(np.sum(differences**2, axis=(1, 2)))[:, None, None]
62        row += np.einsum("tij,tkj->ik", differences, differences, optimize=True)
63        col += np.einsum("tij,tik->jk", differences, differences, optimize=True)
64
65    if max_pairs is not None and max_pairs < total_pairs:
66        rng = random_generator(random_state)
67        for start in range(0, max_pairs, batch_size):
68            count = min(batch_size, max_pairs - start)
69            first = rng.integers(t, size=count)
70            second = rng.integers(t - 1, size=count)
71            second += second >= first
72            accumulate(work[first] - work[second])
73    else:
74        for first in range(t - 1):
75            for start in range(first + 1, t, batch_size):
76                accumulate(work[first] - work[start : start + batch_size])
77    if not nonzero:
78        raise ValueError(
79            "no distinct sampled observations; Kendall loading spaces are undefined"
80        )
81    if nonzero < pairs:
82        warnings.warn(
83            "duplicate observations contribute zero to matrix Kendall's tau",
84            RuntimeWarning,
85            stacklevel=2,
86        )
87    return row / pairs, col / pairs

Return row and column sample matrix Kendall matrices.

Average D @ D.T / ||D||_F**2 and D.T @ D / ||D||_F**2 over unordered temporal pairs, D = X[t] - X[s]. Exact evaluation is quadratic in sample count but batch memory is linear in batch_size. Duplicate observations contribute zero; all-identical observations are rejected.

max_pairs opts into a Monte Carlo approximation: that many independent uniform unordered pairs are sampled with replacement. This is unbiased for the exact pair average, but it is not the paper's exact U-statistic. random_state controls only this approximation.

References

He, Wang, Yu, Zhou and Zhou (2025), A New Non-Parametric Kendall's Tau for Matrix-Valued Elliptical Observations, equations (2.3)--(2.4). https://arxiv.org/abs/2207.09633 Earlier manuscript title: Matrix Kendall's Tau in High-Dimensions: A Robust Statistic for Matrix Factor Model.

def nearest_kronecker_product(coefficient, shape):
58def nearest_kronecker_product(coefficient, shape):
59    """Return A, B minimizing ``||coefficient - kron(B, A)||_F``.
60
61    ``shape=(m,n)`` gives square factors of size m and n. A has unit
62    Frobenius norm, with its largest-magnitude entry nonnegative. The
63    approximation is obtained by the leading singular triplet after the
64    Van Loan rearrangement; repeated leading singular values imply a
65    nonunique answer. No explicit inverse or normal equations are used.
66
67    References
68    ----------
69    Chen, R., Xiao, H. and Yang, D. (2021), "Autoregressive Models for
70    Matrix-Valued Time Series", Section 3.1 (rearranged-SVD projection),
71    https://doi.org/10.1016/j.jeconom.2020.07.015.
72    """
73    if len(shape) != 2:
74        raise ValueError("shape must contain row and column dimensions")
75    m, n = (positive_int(v, "dimension") for v in shape)
76    raw = np.asarray(coefficient)
77    if np.iscomplexobj(raw):
78        raise ValueError("coefficient must be real-valued")
79    Phi = np.asarray(raw, dtype=float)
80    if Phi.shape != (m * n, m * n) or not np.isfinite(Phi).all():
81        raise ValueError("coefficient must be finite with shape (m*n, m*n)")
82    u, s, vh = np.linalg.svd(_rearrange_phi(Phi, m, n), full_matrices=False)
83    return _normalize(
84        u[:, 0].reshape(m, m, order="F"), (s[0] * vh[0]).reshape(n, n, order="F")
85    )

Return A, B minimizing ||coefficient - kron(B, A)||_F.

shape=(m,n) gives square factors of size m and n. A has unit Frobenius norm, with its largest-magnitude entry nonnegative. The approximation is obtained by the leading singular triplet after the Van Loan rearrangement; repeated leading singular values imply a nonunique answer. No explicit inverse or normal equations are used.

References

Chen, R., Xiao, H. and Yang, D. (2021), "Autoregressive Models for Matrix-Valued Time Series", Section 3.1 (rearranged-SVD projection), https://doi.org/10.1016/j.jeconom.2020.07.015.

def select_mar_rank(X, *, max_ranks=None, max_iter=200, tol=1e-08, init='auto'):
637def select_mar_rank(X, *, max_ranks=None, max_iter=200, tol=1e-8, init="auto"):
638    """Select MAR(1) coefficient ranks with the published joint RR.LS EBIC.
639
640    Every candidate fits a zero-intercept, unpenalized RR.LS model. The score
641    uses T total observations, exactly as in the paper:
642    ``log(SSE/(T*m*n)) + (log(T*n)*r*(2*m-r) + log(T*m)*s*(2*n-s))/(T*m*n)``.
643    Center or difference data before selection when the zero-mean model is
644    inappropriate. The grid includes positive ranks up to ``max_ranks``
645    (default matrix dimensions). Candidate convergence is exposed; local
646    minima and unconverged fits can affect selection. The separate rank
647    search approximation and the likelihood RR.CC criterion are not used.
648
649    References
650    ----------
651    Xiao, H., Han, Y., Chen, R. and Liu, C., "Reduced Rank Autoregressive
652    Models for Matrix Time Series", Equation (13), Section 5,
653    https://yuefenghan.github.io/papers/Reduced_Rank_MAR.pdf.
654    """
655    X = as_series(X)
656    T, m, n = X.shape
657    maximum = ranks_tuple(max_ranks, (m, n)) or (m, n)
658    scores = np.empty(maximum)
659    converged = np.empty(maximum, dtype=bool)
660    best, best_rank, best_score = None, None, np.inf
661    for r in range(1, maximum[0] + 1):
662        for s in range(1, maximum[1] + 1):
663            result = fit_mar(X, ranks=(r, s), max_iter=max_iter, tol=tol, init=init)
664            sse = np.sum((result.residuals / result.data_scale) ** 2)
665            if sse <= 0:
666                raise ValueError(
667                    "rank EBIC is undefined for a perfect zero-variance fit"
668                )
669            score = (
670                np.log(sse / (T * m * n))
671                + 2 * np.log(result.data_scale)
672                + (np.log(T * n) * r * (2 * m - r) + np.log(T * m) * s * (2 * n - s))
673                / (T * m * n)
674            )
675            scores[r - 1, s - 1], converged[r - 1, s - 1] = score, result.converged
676            if score < best_score:
677                best, best_rank, best_score = result, (r, s), score
678    return MARRankSelection(best_rank, best, scores, converged)

Select MAR(1) coefficient ranks with the published joint RR.LS EBIC.

Every candidate fits a zero-intercept, unpenalized RR.LS model. The score uses T total observations, exactly as in the paper: log(SSE/(T*m*n)) + (log(T*n)*r*(2*m-r) + log(T*m)*s*(2*n-s))/(T*m*n). Center or difference data before selection when the zero-mean model is inappropriate. The grid includes positive ranks up to max_ranks (default matrix dimensions). Candidate convergence is exposed; local minima and unconverged fits can affect selection. The separate rank search approximation and the likelihood RR.CC criterion are not used.

References

Xiao, H., Han, Y., Chen, R. and Liu, C., "Reduced Rank Autoregressive Models for Matrix Time Series", Equation (13), Section 5, https://yuefenghan.github.io/papers/Reduced_Rank_MAR.pdf.