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]
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.
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.
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).
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.
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.
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
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
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.
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.
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
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.
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.
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.
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.
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.
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.
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.
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.
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
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.
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.
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
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 )
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.
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.
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
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
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).
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.
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).
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).
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.
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.
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().
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.
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.
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.
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.
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).
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.
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.
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.
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.
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.
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.
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 )
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.
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.
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 q. 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. 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. 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. 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.ranks=(r1,r2) are fixed before fitting, rj<=dimension_j and
r1r2method='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.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.References
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.
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.
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.
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.
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
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.
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.
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.
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.
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()))))
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
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.
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.
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.
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)
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.
365 @property 366 def steps(self): 367 """Immutable sequence of accepted monitoring diagnostics.""" 368 return tuple(self._steps)
Immutable sequence of accepted monitoring diagnostics.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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
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.
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.
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"):
ratioselects 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.
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.
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 meansz[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 < lowerandz >= 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.
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.
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.
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.
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.
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.
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.
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.
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.
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_varianceof 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
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.
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.
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.
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.
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.
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.
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.
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).
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'.
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).
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.
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].
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.
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.
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.
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
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
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
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_regularizedis 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_likelihoodis 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)
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.
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.
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
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
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.
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.
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.
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.