Skip to main content

scirs2_stats/
dynamic_factor.rs

1//! Dynamic Factor Model (DFM)
2//!
3//! This module implements Dynamic Factor Models (DFMs) using the EM algorithm
4//! of Doz, Giannone and Reichlin (2012).  The model decomposes a panel of
5//! macroeconomic / financial time series into a small number of latent common
6//! factors plus idiosyncratic noise.
7//!
8//! # Model
9//!
10//! ## Observation equation
11//! ```text
12//! X_t = Λ F_t + ε_t,   ε_t ~ N(0, R)       (K × 1)
13//! ```
14//! where `X_t` is the K-vector of observables at time t, `Λ` (`K × r`) is
15//! the factor loading matrix, `F_t` (`r × 1`) is the latent factor vector,
16//! and `R` is a diagonal idiosyncratic covariance matrix.
17//!
18//! ## Factor dynamics (VAR(p))
19//! ```text
20//! F_t = A_1 F_{t-1} + ... + A_p F_{t-p} + η_t,   η_t ~ N(0, Q)
21//! ```
22//! Written in companion form with state `s_t = [F_t', ..., F_{t-p+1}']'`
23//! this becomes a standard first-order state-space model.
24//!
25//! # Estimation
26//!
27//! The EM algorithm alternates between:
28//! * **E-step**: run Kalman filter + RTS smoother to compute posterior
29//!   moments E[s_t | X] and E[s_t s_{t-1}' | X].
30//! * **M-step**: re-estimate Λ, A, R, Q by OLS using the smoothed moments.
31//!
32//! # References
33//! - Doz, C., Giannone, D. & Reichlin, L. (2012). A Quasi Maximum Likelihood
34//!   Approach for Large Approximate Dynamic Factor Models. *Review of Economics
35//!   and Statistics* 94(4): 1014–1024.
36//! - Stock, J.H. & Watson, M.W. (2002). Macroeconomic Forecasting Using
37//!   Diffuse Factor Models. *Journal of Business & Economic Statistics*.
38//! - Bai, J. & Ng, S. (2002). Determining the Number of Factors in Approximate
39//!   Factor Models. *Econometrica* 70(1): 191–221.
40
41use crate::error::{StatsError, StatsResult};
42use scirs2_core::ndarray::{Array1, Array2, Axis};
43
44// ---------------------------------------------------------------------------
45// Public data structures
46// ---------------------------------------------------------------------------
47
48/// Fitted Dynamic Factor Model.
49///
50/// All fields correspond to the **last** EM iterate (or the converged
51/// solution when `max_iter` is large enough).
52#[derive(Clone, Debug)]
53pub struct DynamicFactorModel {
54    /// Factor loading matrix `Λ` of shape `(n_vars, n_factors)`.
55    pub loadings: Array2<f64>,
56    /// VAR coefficient matrices `[A_1, ..., A_p]`, each `(n_factors, n_factors)`.
57    pub ar_matrices: Vec<Array2<f64>>,
58    /// Smoothed factors, shape `(T, n_factors)`.
59    pub factors: Array2<f64>,
60    /// Idiosyncratic covariance matrix `R`, shape `(n_vars, n_vars)` (diagonal).
61    pub r: Array2<f64>,
62    /// Factor innovation covariance `Q`, shape `(n_factors, n_factors)`.
63    pub q: Array2<f64>,
64    /// Number of latent factors.
65    pub n_factors: usize,
66    /// Number of observed variables.
67    pub n_vars: usize,
68    /// Number of VAR lags.
69    pub n_lags: usize,
70    /// EM log-likelihood trajectory (one value per iteration).
71    pub log_lik_history: Vec<f64>,
72}
73
74impl DynamicFactorModel {
75    /// Return the smoothed factor matrix (rows = time, cols = factors).
76    ///
77    /// This is a convenience alias for the `factors` field.
78    pub fn extract_factors(&self) -> Array2<f64> {
79        self.factors.clone()
80    }
81
82    /// One-step-ahead forecast of the observations at time `T+1`, given
83    /// the last smoothed factor state.
84    ///
85    /// Returns a `K`-vector `X̂_{T+1} = Λ * E[F_{T+1} | X_{1:T}]`.
86    pub fn forecast_one_step(&self) -> StatsResult<Array1<f64>> {
87        let t = self.factors.nrows();
88        if t == 0 {
89            return Err(StatsError::InsufficientData(
90                "No factor estimates available for forecasting".into(),
91            ));
92        }
93        let r = self.n_factors;
94        let p = self.n_lags;
95
96        // Build companion state from the last p factor vectors
97        let comp_dim = r * p;
98        let mut s_last = Array1::<f64>::zeros(comp_dim);
99        for lag in 0..p {
100            if t >= lag + 1 {
101                let f_row = self.factors.row(t - 1 - lag).to_owned();
102                for j in 0..r {
103                    s_last[lag * r + j] = f_row[j];
104                }
105            }
106        }
107
108        // Companion transition for one step: F_{T+1} = A_1 F_T + ... + A_p F_{T-p+1}
109        let comp = build_companion(&self.ar_matrices, r, p)?;
110        let s_next = mat_vec_mul(&comp, &s_last)?;
111        let f_next = s_next.slice(scirs2_core::ndarray::s![0..r]).to_owned();
112
113        // X̂ = Λ * F_{T+1}
114        let x_hat = mat_vec_mul(&self.loadings, &f_next)?;
115        Ok(x_hat)
116    }
117}
118
119/// Specification of a Dynamic Factor Model before estimation.
120#[derive(Clone, Debug)]
121pub struct DynamicFactor {
122    /// Number of latent factors `r`.
123    pub n_factors: usize,
124    /// Number of observed variables `K`.
125    pub n_vars: usize,
126    /// Number of VAR lags `p`.
127    pub n_lags: usize,
128}
129
130impl DynamicFactor {
131    /// Create a new DFM specification.
132    pub fn new(n_factors: usize, n_vars: usize, n_lags: usize) -> StatsResult<Self> {
133        if n_factors == 0 {
134            return Err(StatsError::InvalidInput(
135                "DynamicFactor: n_factors must be >= 1".into(),
136            ));
137        }
138        if n_vars == 0 {
139            return Err(StatsError::InvalidInput(
140                "DynamicFactor: n_vars must be >= 1".into(),
141            ));
142        }
143        if n_lags == 0 {
144            return Err(StatsError::InvalidInput(
145                "DynamicFactor: n_lags must be >= 1".into(),
146            ));
147        }
148        if n_factors > n_vars {
149            return Err(StatsError::InvalidInput(
150                "DynamicFactor: n_factors cannot exceed n_vars".into(),
151            ));
152        }
153        Ok(Self {
154            n_factors,
155            n_vars,
156            n_lags,
157        })
158    }
159}
160
161// ---------------------------------------------------------------------------
162// EM estimation entry point
163// ---------------------------------------------------------------------------
164
165/// Fit a Dynamic Factor Model via the EM algorithm.
166///
167/// # Arguments
168/// * `x`        – `(T, K)` matrix of observations (rows = time, cols = variables).
169///   Missing values (NaN) are silently replaced by the cross-sectional mean
170///   before the first E-step (a simple pre-treatment).
171/// * `n_factors` – number of latent factors `r`.
172/// * `n_lags`    – number of VAR lags `p` for the factor dynamics.
173/// * `max_iter`  – maximum EM iterations (typically 100–500).
174/// * `tol`       – convergence tolerance on the relative log-likelihood change.
175///   Pass `None` to use the default `1e-6`.
176///
177/// # Returns
178/// A [`DynamicFactorModel`] containing all estimated parameters and smoothed factors.
179///
180/// # Errors
181/// Returns errors for dimension problems, insufficient data, or numerical
182/// failures during Cholesky / inversion.
183pub fn fit_dynamic_factor(
184    x: &Array2<f64>,
185    n_factors: usize,
186    n_lags: usize,
187    max_iter: usize,
188    tol: Option<f64>,
189) -> StatsResult<DynamicFactorModel> {
190    let big_t = x.nrows();
191    let k = x.ncols();
192    let r = n_factors;
193    let p = n_lags;
194    let convergence_tol = tol.unwrap_or(1e-6);
195
196    if big_t <= p + 1 {
197        return Err(StatsError::InsufficientData(format!(
198            "Need > {} observations for n_lags={}, got {}",
199            p + 1,
200            p,
201            big_t
202        )));
203    }
204    if r == 0 || r > k {
205        return Err(StatsError::InvalidInput(format!(
206            "n_factors={} must be in 1..=n_vars={}",
207            r, k
208        )));
209    }
210    if max_iter == 0 {
211        return Err(StatsError::InvalidInput("max_iter must be >= 1".into()));
212    }
213
214    // ── Pre-treatment: replace NaN with variable mean ─────────────────────
215    let x_clean = impute_column_means(x);
216
217    // ── Initialisation via PCA ─────────────────────────────────────────────
218    let (mut lambda, mut factors_init) = pca_init(&x_clean, r)?;
219    // Initial R: diagonal of residual variance from PCA
220    let mut r_mat = init_idiosyncratic_cov(&x_clean, &lambda, &factors_init, k)?;
221    // Initial AR parameters: OLS on the PCA factors
222    let mut ar_mats = init_ar_matrices(&factors_init, r, p)?;
223    // Initial Q: identity scaled small
224    let mut q_mat = Array2::from_diag(&Array1::from_elem(r, 0.1_f64));
225
226    let mut log_lik_history: Vec<f64> = Vec::with_capacity(max_iter);
227    let mut prev_log_lik = f64::NEG_INFINITY;
228
229    let mut smoothed_factors = factors_init.clone();
230
231    for _iter in 0..max_iter {
232        // ── E-step: Kalman filter + RTS smoother ──────────────────────────
233        let (new_factors, ptt, ptt1, log_lik) =
234            e_step(&x_clean, &lambda, &ar_mats, &r_mat, &q_mat, r, p)?;
235
236        log_lik_history.push(log_lik);
237        smoothed_factors = new_factors;
238
239        // ── M-step: re-estimate parameters ────────────────────────────────
240        let (new_lambda, new_r) = m_step_loadings(&x_clean, &smoothed_factors, &ptt, k, r)?;
241        let (new_ar, new_q) = m_step_ar(&smoothed_factors, &ptt, &ptt1, r, p)?;
242
243        lambda = new_lambda;
244        r_mat = new_r;
245        ar_mats = new_ar;
246        q_mat = new_q;
247
248        // ── Convergence check ─────────────────────────────────────────────
249        let rel_change = if prev_log_lik.is_finite() && prev_log_lik != 0.0 {
250            (log_lik - prev_log_lik).abs() / prev_log_lik.abs().max(1.0)
251        } else {
252            f64::INFINITY
253        };
254        if rel_change < convergence_tol && _iter > 0 {
255            break;
256        }
257        prev_log_lik = log_lik;
258    }
259
260    Ok(DynamicFactorModel {
261        loadings: lambda,
262        ar_matrices: ar_mats,
263        factors: smoothed_factors,
264        r: r_mat,
265        q: q_mat,
266        n_factors: r,
267        n_vars: k,
268        n_lags: p,
269        log_lik_history,
270    })
271}
272
273// ---------------------------------------------------------------------------
274// E-step: Kalman filter + RTS smoother
275// ---------------------------------------------------------------------------
276
277/// Returns `(smoothed_factors, P_{t|T}, P_{t,t-1|T}, log_lik)`.
278///
279/// * `smoothed_factors` – `(T, r)` matrix of smoothed factor means.
280/// * `P_{t|T}` – `Vec<Array2<f64>>` of length T, each `(r, r)`.
281/// * `P_{t,t-1|T}` – `Vec<Array2<f64>>` of length T-1, each `(r, r)`.
282///   Element `s` = Cov(F_{s+1}, F_s | X).
283fn e_step(
284    x: &Array2<f64>,
285    lambda: &Array2<f64>,    // (k, r)
286    ar_mats: &[Array2<f64>], // p matrices each (r, r)
287    r_mat: &Array2<f64>,     // (k, k)
288    q_mat: &Array2<f64>,     // (r, r)
289    r_dim: usize,            // n_factors
290    p: usize,                // n_lags
291) -> StatsResult<(
292    Array2<f64>,      // smoothed factors (T, r)
293    Vec<Array2<f64>>, // P_{t|T}
294    Vec<Array2<f64>>, // P_{t,t-1|T}
295    f64,              // log-likelihood
296)> {
297    let big_t = x.nrows();
298    let k = x.ncols();
299    let comp_dim = r_dim * p;
300
301    // Build companion transition matrix C and companion Q
302    let comp = build_companion(ar_mats, r_dim, p)?;
303    let comp_q = build_companion_q(q_mat, r_dim, p);
304
305    // Companion observation matrix: Z = [Λ  0 ... 0]  shape (k, comp_dim)
306    let mut z_obs = Array2::<f64>::zeros((k, comp_dim));
307    for i in 0..k {
308        for j in 0..r_dim {
309            z_obs[[i, j]] = lambda[[i, j]];
310        }
311    }
312
313    // Diffuse initial covariance
314    let p0 = Array2::from_diag(&Array1::from_elem(comp_dim, 1e4_f64));
315    let x0 = Array1::<f64>::zeros(comp_dim);
316
317    // ── Forward Kalman filter ──────────────────────────────────────────────
318    let mut filt_mean: Vec<Array1<f64>> = Vec::with_capacity(big_t);
319    let mut filt_cov: Vec<Array2<f64>> = Vec::with_capacity(big_t);
320    let mut pred_mean: Vec<Array1<f64>> = Vec::with_capacity(big_t);
321    let mut pred_cov: Vec<Array2<f64>> = Vec::with_capacity(big_t);
322
323    let log2pi = (2.0 * std::f64::consts::PI).ln();
324    let mut log_lik = 0.0_f64;
325
326    let mut x_cur = x0;
327    let mut p_cur = p0;
328
329    for t in 0..big_t {
330        // Predict
331        let x_pred = mat_vec_mul(&comp, &x_cur)?;
332        let cp = mat_mat_mul(&comp, &p_cur)?;
333        let mut p_pred = mat_mat_mul_bt(&cp, &comp)? + &comp_q;
334
335        // Ensure P_pred stays symmetric positive-definite
336        symmetrize(&mut p_pred);
337        regularise_pd(&mut p_pred, 1e-10);
338
339        // Store prediction
340        pred_mean.push(x_pred.clone());
341        pred_cov.push(p_pred.clone());
342
343        // Observation
344        let y_t = x.row(t).to_owned();
345
346        // Innovation
347        let z_xp = mat_vec_mul(&z_obs, &x_pred)?;
348        let innovation = &y_t - &z_xp;
349
350        // S = Z P_pred Z' + R
351        let zp = mat_mat_mul(&z_obs, &p_pred)?;
352        let mut s = mat_mat_mul_bt(&zp, &z_obs)? + r_mat;
353
354        // Ensure S stays symmetric positive-definite
355        symmetrize(&mut s);
356        regularise_pd(&mut s, 1e-6);
357
358        // If S has very small diagonal elements, add stronger regularization
359        {
360            let s_max_diag = (0..s.nrows())
361                .map(|ii| s[[ii, ii]].abs())
362                .fold(0.0_f64, f64::max);
363            let s_min_diag = (0..s.nrows())
364                .map(|ii| s[[ii, ii]].abs())
365                .fold(f64::INFINITY, f64::min);
366            if s_min_diag < 1e-10 * s_max_diag || s_min_diag < 1e-12 {
367                let eps = (1e-6 * s_max_diag).max(1e-8);
368                for ii in 0..s.nrows() {
369                    s[[ii, ii]] += eps;
370                }
371            }
372        }
373
374        // Log-likelihood
375        let s_inv = inv_symmetric(s.clone())?;
376        let log_det_s = log_det_posdef(&s)?;
377        let sv = mat_vec_mul(&s_inv, &innovation)?;
378        let quad: f64 = innovation.iter().zip(sv.iter()).map(|(&a, &b)| a * b).sum();
379        log_lik += -0.5 * (k as f64 * log2pi + log_det_s + quad);
380
381        // Kalman gain K = P_pred Z' S^{-1}
382        let pzt = mat_mat_mul_bt(&p_pred, &z_obs)?;
383        let kgain = mat_mat_mul(&pzt, &s_inv)?;
384
385        // Update
386        let kv = mat_vec_mul(&kgain, &innovation)?;
387        let x_upd = &x_pred + &kv;
388        let kz = mat_mat_mul(&kgain, &z_obs)?;
389        let i_kz = eye_minus(kz)?;
390        let mut p_upd = mat_mat_mul(&i_kz, &p_pred)?;
391
392        // Ensure P_upd stays symmetric positive-definite
393        symmetrize(&mut p_upd);
394        regularise_pd(&mut p_upd, 1e-8);
395
396        filt_mean.push(x_upd.clone());
397        filt_cov.push(p_upd.clone());
398        x_cur = x_upd;
399        p_cur = p_upd;
400    }
401
402    // ── Backward RTS smoother ──────────────────────────────────────────────
403    let mut smooth_mean: Vec<Array1<f64>> = filt_mean.clone();
404    let mut smooth_cov: Vec<Array2<f64>> = filt_cov.clone();
405    // Cross-covariance Cov(s_{t+1}, s_t | X) needed for M-step
406    let mut cross_cov: Vec<Array2<f64>> = vec![Array2::zeros((comp_dim, comp_dim)); big_t];
407
408    for t in (0..big_t - 1).rev() {
409        let p_pred_tp1 = &filt_cov[t] * 0.0 + &pred_cov[t + 1]; // clone pred_cov[t+1]
410        let p_pred_tp1_inv = inv_symmetric(p_pred_tp1.clone())?;
411
412        // Smoother gain G_t = P_{t|t} C' P_{t+1|t}^{-1}
413        let pct = mat_mat_mul_bt(&filt_cov[t], &comp)?;
414        let g = mat_mat_mul(&pct, &p_pred_tp1_inv)?;
415
416        // Smoothed mean
417        let diff = &smooth_mean[t + 1] - &pred_mean[t + 1];
418        let g_diff = mat_vec_mul(&g, &diff)?;
419        let x_smooth = &filt_mean[t] + &g_diff;
420
421        // Smoothed covariance
422        let dp = &smooth_cov[t + 1] - &p_pred_tp1;
423        let g_dp = mat_mat_mul(&g, &dp)?;
424        let correction = mat_mat_mul_bt(&g_dp, &g)?;
425        let p_smooth = &filt_cov[t] + &correction;
426
427        // Cross-covariance: P_{t+1,t|T} = G_t P_{t+1|T} ... actually
428        // the standard formula: Cov(s_{t+1}, s_t | X) = P_{t+1|T} G_t'
429        // (de Jong & Mackinnon convention)
430        let cross = mat_mat_mul_bt(&smooth_cov[t + 1], &g)?;
431        cross_cov[t + 1] = cross;
432
433        smooth_mean[t] = x_smooth;
434        smooth_cov[t] = p_smooth;
435    }
436    // Handle t=0 cross-cov (rarely needed, set to zero)
437    cross_cov[0] = Array2::zeros((comp_dim, comp_dim));
438
439    // ── Extract top-r factor block from companion state ────────────────────
440    let mut smoothed_factors = Array2::<f64>::zeros((big_t, r_dim));
441    for t in 0..big_t {
442        for j in 0..r_dim {
443            smoothed_factors[[t, j]] = smooth_mean[t][j];
444        }
445    }
446
447    // Extract r × r covariance blocks
448    let p_tt: Vec<Array2<f64>> = smooth_cov
449        .iter()
450        .map(|p| Array2::from_shape_fn((r_dim, r_dim), |(i, j)| p[[i, j]]))
451        .collect();
452
453    let p_tt1: Vec<Array2<f64>> = cross_cov
454        .iter()
455        .skip(1)
456        .map(|cc| Array2::from_shape_fn((r_dim, r_dim), |(i, j)| cc[[i, j]]))
457        .collect();
458
459    Ok((smoothed_factors, p_tt, p_tt1, log_lik))
460}
461
462// ---------------------------------------------------------------------------
463// M-step: loadings and idiosyncratic variances
464// ---------------------------------------------------------------------------
465
466/// Update `Λ` and `R` (diagonal) using smoothed factor moments.
467///
468/// The OLS formula is:
469/// ```text
470/// Λ = (Σ X_t F_t') (Σ (F_t F_t' + P_{t|T}))^{-1}
471/// R = diag( T^{-1} Σ (X_t - Λ F_t)(X_t - Λ F_t)' + Λ P_{t|T} Λ' )
472/// ```
473fn m_step_loadings(
474    x: &Array2<f64>,
475    factors: &Array2<f64>, // (T, r)
476    p_tt: &[Array2<f64>],  // Vec of (r, r)
477    k: usize,
478    r: usize,
479) -> StatsResult<(Array2<f64>, Array2<f64>)> {
480    let big_t = x.nrows();
481
482    // S_xf = Σ X_t F_t'  shape (k, r)
483    let mut s_xf = Array2::<f64>::zeros((k, r));
484    // S_ff = Σ (F_t F_t' + P_{t|T})  shape (r, r)
485    let mut s_ff = Array2::<f64>::zeros((r, r));
486
487    for t in 0..big_t {
488        let x_t = x.row(t).to_owned();
489        let f_t = factors.row(t).to_owned();
490        // S_xf += x_t f_t'
491        for i in 0..k {
492            for j in 0..r {
493                s_xf[[i, j]] += x_t[i] * f_t[j];
494            }
495        }
496        // S_ff += f_t f_t' + P_{t|T}
497        for i in 0..r {
498            for j in 0..r {
499                s_ff[[i, j]] += f_t[i] * f_t[j] + p_tt[t][[i, j]];
500            }
501        }
502    }
503
504    // Λ = S_xf * S_ff^{-1}
505    let s_ff_inv = inv_symmetric(s_ff)?;
506    let lambda_new = mat_mat_mul(&s_xf, &s_ff_inv)?;
507
508    // Residual variance R (diagonal)
509    let mut r_diag = Array1::<f64>::zeros(k);
510    for t in 0..big_t {
511        let x_t = x.row(t).to_owned();
512        let f_t = factors.row(t).to_owned();
513        let lf = mat_vec_mul(&lambda_new, &f_t)?;
514        let resid = &x_t - &lf;
515        // P_tt correction: lambda * P_{t|T} * lambda'
516        let lp = mat_mat_mul(&lambda_new, &p_tt[t])?;
517        let lpl = mat_mat_mul_bt(&lp, &lambda_new)?;
518        for i in 0..k {
519            r_diag[i] += resid[i] * resid[i] + lpl[[i, i]];
520        }
521    }
522    r_diag.mapv_inplace(|v| v / big_t as f64);
523    // Enforce a minimum idiosyncratic variance to avoid degenerate solutions
524    r_diag.mapv_inplace(|v| v.max(1e-6));
525
526    let r_new = Array2::from_diag(&r_diag);
527
528    Ok((lambda_new, r_new))
529}
530
531// ---------------------------------------------------------------------------
532// M-step: AR parameters and factor innovation covariance
533// ---------------------------------------------------------------------------
534
535/// Update `A_1,...,A_p` and `Q` using smoothed factor moments.
536///
537/// Companion-form OLS:
538/// ```text
539/// [A_1,...,A_p] = (Σ_{t=p}^T F_t S_{t-1}') (Σ_{t=p}^T S_{t-1} S_{t-1}')^{-1}
540/// ```
541/// where `S_{t-1} = [F_{t-1}',...,F_{t-p}']'` is the stacked lag vector.
542///
543/// We use the smoothed second-moment matrices to account for uncertainty.
544fn m_step_ar(
545    factors: &Array2<f64>, // (T, r)
546    p_tt: &[Array2<f64>],  // (T) of (r,r) smoothed covs
547    p_tt1: &[Array2<f64>], // (T-1) of (r,r) cross-covs  Cov(F_{t+1}, F_t)
548    r: usize,
549    p: usize,
550) -> StatsResult<(Vec<Array2<f64>>, Array2<f64>)> {
551    let big_t = factors.nrows();
552
553    if big_t <= p {
554        return Err(StatsError::InsufficientData(
555            "m_step_ar: not enough observations for the given number of lags".into(),
556        ));
557    }
558
559    // Build the regression matrices using the stacked approach
560    // For simplicity and robustness we use the sample moments directly
561    // (ignoring the uncertainty correction for the lagged covariates).
562    // This is the Doz-Giannone-Reichlin approximate M-step.
563
564    let n = big_t - p; // number of regression observations
565
566    // Response: F_{t}  for t = p, ..., T-1  → shape (n, r)
567    let y_reg = factors.slice(scirs2_core::ndarray::s![p.., ..]).to_owned(); // (n, r)
568
569    // Regressor: [F_{t-1}', ..., F_{t-p}']  → shape (n, r*p)
570    let mut x_reg = Array2::<f64>::zeros((n, r * p));
571    for t_idx in 0..n {
572        let t = t_idx + p;
573        for lag in 0..p {
574            let f_lag = factors.row(t - 1 - lag);
575            for j in 0..r {
576                x_reg[[t_idx, lag * r + j]] = f_lag[j];
577            }
578        }
579    }
580
581    // OLS: B = (X'X)^{-1} X' Y  shape (r*p, r)
582    let x_reg_t = x_reg.t().to_owned();
583    let xt_x = mat_mat_mul(&x_reg_t, &x_reg)?; // (r*p, r*p)
584    let xt_y = mat_mat_mul(&x_reg_t, &y_reg)?; // (r*p, r)
585
586    let xt_x_inv = inv_symmetric_regularised(xt_x, 1e-8)?;
587    let b_hat = mat_mat_mul(&xt_x_inv, &xt_y)?; // (r*p, r)
588
589    // Unpack B into A_1, ..., A_p  (each r×r, note: B is transposed wrt equation)
590    // b_hat[lag*r .. (lag+1)*r, :] = A_{lag+1}'  so A_{lag+1} = b_hat[..].t()
591    let mut ar_mats: Vec<Array2<f64>> = Vec::with_capacity(p);
592    for lag in 0..p {
593        let a_t = b_hat
594            .slice(scirs2_core::ndarray::s![lag * r..(lag + 1) * r, ..])
595            .to_owned();
596        // a_t is (r, r), rows are coefficients for response dimension
597        ar_mats.push(a_t.t().to_owned());
598    }
599
600    // Q: innovation covariance
601    // Q = T^{-1} Σ (F_t - Â F_{t-1:t-p})(...)' + uncertainty corrections
602    let mut q_new = Array2::<f64>::zeros((r, r));
603    for t_idx in 0..n {
604        let t = t_idx + p;
605        let f_t = factors.row(t).to_owned();
606        // Predicted factor
607        let reg_t = x_reg.row(t_idx).to_owned();
608        let f_pred: Array1<f64> = {
609            let reg_t_mat = reg_t
610                .view()
611                .into_shape_with_order((1, r * p))
612                .map_err(|_| StatsError::ComputationError("reshape failed".into()))?
613                .to_owned();
614            let pred_mat = mat_mat_mul(&reg_t_mat, &b_hat)?;
615            pred_mat.row(0).to_owned()
616        };
617        let resid = &f_t - &f_pred;
618        for i in 0..r {
619            for j in 0..r {
620                q_new[[i, j]] += resid[i] * resid[j];
621            }
622        }
623        // Add P_{t|T} contribution
624        if t < p_tt.len() {
625            for i in 0..r {
626                for j in 0..r {
627                    q_new[[i, j]] += p_tt[t][[i, j]];
628                }
629            }
630        }
631    }
632    q_new.mapv_inplace(|v| v / n as f64);
633
634    // Ensure Q is symmetric positive definite
635    symmetrize(&mut q_new);
636    regularise_pd(&mut q_new, 1e-8);
637
638    Ok((ar_mats, q_new))
639}
640
641// ---------------------------------------------------------------------------
642// Initialisation via PCA
643// ---------------------------------------------------------------------------
644
645/// Compute the top-`r` principal components of the data matrix `X` (T × K).
646///
647/// Returns `(Λ, F)` where `Λ` is `(K, r)` and `F` is `(T, r)`.
648fn pca_init(x: &Array2<f64>, r: usize) -> StatsResult<(Array2<f64>, Array2<f64>)> {
649    let big_t = x.nrows();
650    let k = x.ncols();
651    if r > k.min(big_t) {
652        return Err(StatsError::InvalidInput(format!(
653            "pca_init: r={} > min(T,K)={}",
654            r,
655            k.min(big_t)
656        )));
657    }
658
659    // Demean columns
660    let col_means: Array1<f64> = x
661        .mean_axis(Axis(0))
662        .ok_or_else(|| StatsError::ComputationError("pca_init: mean_axis failed".into()))?;
663    let mut x_dm = x.clone();
664    for mut row in x_dm.rows_mut() {
665        for (v, &m) in row.iter_mut().zip(col_means.iter()) {
666            *v -= m;
667        }
668    }
669
670    // Sample covariance: C = X' X / T  (K × K)
671    let xt = x_dm.t().to_owned();
672    let cov = mat_mat_mul(&xt, &x_dm).map(|c| c.mapv(|v| v / big_t as f64))?;
673
674    // Power iteration to get the top-r eigenvectors
675    let evecs = power_iter_top_r(&cov, r, 500)?; // (K, r)
676
677    // Factors: F = X * V  (T × r)
678    let factors = mat_mat_mul(&x_dm, &evecs)?; // T × r
679
680    Ok((evecs, factors))
681}
682
683/// Power iteration to extract the top `r` eigenvectors of a symmetric matrix.
684///
685/// Uses deflation (subtracting the outer product after each extraction).
686fn power_iter_top_r(a: &Array2<f64>, r: usize, n_iter: usize) -> StatsResult<Array2<f64>> {
687    let n = a.nrows();
688    let mut current = a.clone();
689    let mut evecs = Array2::<f64>::zeros((n, r));
690
691    for k in 0..r {
692        // Start from a random-ish vector (deterministic: column k of identity)
693        let mut v = Array1::<f64>::zeros(n);
694        v[k % n] = 1.0;
695        if k < n {
696            v[k] = 1.0;
697        } else {
698            v[0] = 1.0;
699        }
700        // Normalise
701        let norm = (v.iter().map(|x| x * x).sum::<f64>()).sqrt();
702        if norm > 1e-15 {
703            v.mapv_inplace(|x| x / norm);
704        }
705
706        for _ in 0..n_iter {
707            let av = mat_vec_mul(&current, &v)?;
708            let new_norm = (av.iter().map(|x| x * x).sum::<f64>()).sqrt();
709            if new_norm < 1e-15 {
710                break;
711            }
712            v = av.mapv(|x| x / new_norm);
713        }
714
715        // Store eigenvector
716        for i in 0..n {
717            evecs[[i, k]] = v[i];
718        }
719
720        // Deflate: remove contribution of this eigenvector
721        let eigenvalue = {
722            let av = mat_vec_mul(&current, &v)?;
723            av.iter().zip(v.iter()).map(|(&a, &b)| a * b).sum::<f64>()
724        };
725        for i in 0..n {
726            for j in 0..n {
727                current[[i, j]] -= eigenvalue * v[i] * v[j];
728            }
729        }
730    }
731
732    Ok(evecs)
733}
734
735/// Estimate initial idiosyncratic covariance from PCA residuals.
736fn init_idiosyncratic_cov(
737    x: &Array2<f64>,
738    lambda: &Array2<f64>,
739    factors: &Array2<f64>,
740    k: usize,
741) -> StatsResult<Array2<f64>> {
742    let big_t = x.nrows();
743    let lf = mat_mat_mul(factors, &lambda.t().to_owned())?; // T × k
744    let resid = x - &lf;
745    let mut r_diag = Array1::<f64>::zeros(k);
746    for t in 0..big_t {
747        let row = resid.row(t);
748        for i in 0..k {
749            r_diag[i] += row[i] * row[i];
750        }
751    }
752    r_diag.mapv_inplace(|v| (v / big_t as f64).max(1e-6));
753    Ok(Array2::from_diag(&r_diag))
754}
755
756/// Estimate initial VAR parameters by OLS on PCA factors.
757fn init_ar_matrices(
758    factors: &Array2<f64>, // (T, r)
759    r: usize,
760    p: usize,
761) -> StatsResult<Vec<Array2<f64>>> {
762    let big_t = factors.nrows();
763    if big_t <= p {
764        // Fall back to zero matrices
765        return Ok(vec![Array2::zeros((r, r)); p]);
766    }
767
768    let n = big_t - p;
769    let y_reg = factors.slice(scirs2_core::ndarray::s![p.., ..]).to_owned();
770    let mut x_reg = Array2::<f64>::zeros((n, r * p));
771    for t_idx in 0..n {
772        let t = t_idx + p;
773        for lag in 0..p {
774            let f_lag = factors.row(t - 1 - lag);
775            for j in 0..r {
776                x_reg[[t_idx, lag * r + j]] = f_lag[j];
777            }
778        }
779    }
780
781    // OLS: b_hat = (X^T X)^{-1} X^T Y
782    let x_reg_t = x_reg.t().to_owned();
783    let xt_x = mat_mat_mul(&x_reg_t, &x_reg)?; // (r*p, r*p)
784    let xt_y = mat_mat_mul(&x_reg_t, &y_reg)?; // (r*p, r)
785    let xt_x_inv = inv_symmetric_regularised(xt_x, 1e-8)?; // (r*p, r*p)
786    let b_hat = mat_mat_mul(&xt_x_inv, &xt_y)?; // (r*p, r)
787
788    let mut ar_mats: Vec<Array2<f64>> = Vec::with_capacity(p);
789    for lag in 0..p {
790        let a_t = b_hat
791            .slice(scirs2_core::ndarray::s![lag * r..(lag + 1) * r, ..])
792            .to_owned();
793        ar_mats.push(a_t.t().to_owned());
794    }
795    Ok(ar_mats)
796}
797
798// ---------------------------------------------------------------------------
799// Companion form utilities
800// ---------------------------------------------------------------------------
801
802/// Build the `(r*p, r*p)` companion transition matrix from AR matrices.
803fn build_companion(ar_mats: &[Array2<f64>], r: usize, p: usize) -> StatsResult<Array2<f64>> {
804    let dim = r * p;
805    let mut comp = Array2::<f64>::zeros((dim, dim));
806
807    for (lag, a) in ar_mats.iter().enumerate() {
808        if a.nrows() != r || a.ncols() != r {
809            return Err(StatsError::DimensionMismatch(format!(
810                "AR matrix {} has shape {}×{}, expected {}×{}",
811                lag,
812                a.nrows(),
813                a.ncols(),
814                r,
815                r
816            )));
817        }
818        for i in 0..r {
819            for j in 0..r {
820                comp[[i, lag * r + j]] = a[[i, j]];
821            }
822        }
823    }
824    // Sub-diagonal identity blocks
825    for i in 1..p {
826        for j in 0..r {
827            comp[[i * r + j, (i - 1) * r + j]] = 1.0;
828        }
829    }
830    Ok(comp)
831}
832
833/// Build the companion process-noise covariance (only top-left `r×r` block).
834fn build_companion_q(q: &Array2<f64>, r: usize, p: usize) -> Array2<f64> {
835    let dim = r * p;
836    let mut cq = Array2::<f64>::zeros((dim, dim));
837    for i in 0..r {
838        for j in 0..r {
839            cq[[i, j]] = q[[i, j]];
840        }
841    }
842    // Add small process noise to lagged state dimensions to prevent
843    // the covariance from degenerating when p > 1.
844    // Without this, the lower-right block of P_pred can become
845    // non-positive-definite, causing Cholesky failures in the E-step.
846    if p > 1 {
847        let q_trace = (0..r).map(|i| q[[i, i]]).sum::<f64>() / r as f64;
848        let eps = (1e-8 * q_trace).max(1e-12);
849        for i in r..dim {
850            cq[[i, i]] = eps;
851        }
852    }
853    cq
854}
855
856// ---------------------------------------------------------------------------
857// Data utilities
858// ---------------------------------------------------------------------------
859
860/// Replace NaN values in each column with the column mean.
861fn impute_column_means(x: &Array2<f64>) -> Array2<f64> {
862    let big_t = x.nrows();
863    let k = x.ncols();
864    let mut out = x.clone();
865    for j in 0..k {
866        let col = x.column(j);
867        let (sum, cnt) = col
868            .iter()
869            .filter(|v| v.is_finite())
870            .fold((0.0_f64, 0usize), |(s, n), &v| (s + v, n + 1));
871        if cnt == 0 {
872            continue;
873        }
874        let mean = sum / cnt as f64;
875        for i in 0..big_t {
876            if !out[[i, j]].is_finite() {
877                out[[i, j]] = mean;
878            }
879        }
880    }
881    out
882}
883
884// ---------------------------------------------------------------------------
885// Numerical linear-algebra helpers (private)
886// ---------------------------------------------------------------------------
887
888fn mat_vec_mul(a: &Array2<f64>, x: &Array1<f64>) -> StatsResult<Array1<f64>> {
889    if a.ncols() != x.len() {
890        return Err(StatsError::DimensionMismatch(format!(
891            "mat_vec_mul: A is {}×{} but x has len {}",
892            a.nrows(),
893            a.ncols(),
894            x.len()
895        )));
896    }
897    let n = a.nrows();
898    let m = a.ncols();
899    let mut y = Array1::<f64>::zeros(n);
900    for i in 0..n {
901        let mut s = 0.0_f64;
902        for k in 0..m {
903            s += a[[i, k]] * x[k];
904        }
905        y[i] = s;
906    }
907    Ok(y)
908}
909
910fn mat_mat_mul(a: &Array2<f64>, b: &Array2<f64>) -> StatsResult<Array2<f64>> {
911    if a.ncols() != b.nrows() {
912        return Err(StatsError::DimensionMismatch(format!(
913            "mat_mat_mul: A is {}×{} but B is {}×{}",
914            a.nrows(),
915            a.ncols(),
916            b.nrows(),
917            b.ncols()
918        )));
919    }
920    let n = a.nrows();
921    let kk = a.ncols();
922    let m = b.ncols();
923    let mut c = Array2::<f64>::zeros((n, m));
924    for i in 0..n {
925        for j in 0..m {
926            let mut s = 0.0_f64;
927            for l in 0..kk {
928                s += a[[i, l]] * b[[l, j]];
929            }
930            c[[i, j]] = s;
931        }
932    }
933    Ok(c)
934}
935
936fn mat_mat_mul_bt(a: &Array2<f64>, b: &Array2<f64>) -> StatsResult<Array2<f64>> {
937    if a.ncols() != b.ncols() {
938        return Err(StatsError::DimensionMismatch(format!(
939            "mat_mat_mul_bt: A is {}×{} but B^T is {}×{}",
940            a.nrows(),
941            a.ncols(),
942            b.ncols(),
943            b.nrows()
944        )));
945    }
946    let n = a.nrows();
947    let kk = a.ncols();
948    let m = b.nrows();
949    let mut c = Array2::<f64>::zeros((n, m));
950    for i in 0..n {
951        for j in 0..m {
952            let mut s = 0.0_f64;
953            for l in 0..kk {
954                s += a[[i, l]] * b[[j, l]];
955            }
956            c[[i, j]] = s;
957        }
958    }
959    Ok(c)
960}
961
962fn eye_minus(a: Array2<f64>) -> StatsResult<Array2<f64>> {
963    let n = a.nrows();
964    if a.ncols() != n {
965        return Err(StatsError::DimensionMismatch(
966            "eye_minus: not square".into(),
967        ));
968    }
969    let mut result = -a;
970    for i in 0..n {
971        result[[i, i]] += 1.0;
972    }
973    Ok(result)
974}
975
976fn cholesky_lower(a: Array2<f64>) -> StatsResult<Array2<f64>> {
977    let n = a.nrows();
978    if a.ncols() != n {
979        return Err(StatsError::DimensionMismatch(
980            "cholesky_lower: not square".into(),
981        ));
982    }
983    let mut l = Array2::<f64>::zeros((n, n));
984    for i in 0..n {
985        for j in 0..=i {
986            let mut s = a[[i, j]];
987            for kk in 0..j {
988                s -= l[[i, kk]] * l[[j, kk]];
989            }
990            if i == j {
991                if s <= 0.0 {
992                    let eps = (1e-10_f64).max(s.abs() * 1e-8);
993                    let s_reg = s + eps;
994                    if s_reg <= 0.0 {
995                        return Err(StatsError::ComputationError(format!(
996                            "Cholesky failed at ({},{}): s={}",
997                            i, j, s
998                        )));
999                    }
1000                    l[[i, j]] = s_reg.sqrt();
1001                } else {
1002                    l[[i, j]] = s.sqrt();
1003                }
1004            } else {
1005                let ljj = l[[j, j]];
1006                if ljj.abs() < 1e-15 {
1007                    return Err(StatsError::ComputationError(
1008                        "Cholesky: near-zero diagonal".into(),
1009                    ));
1010                }
1011                l[[i, j]] = s / ljj;
1012            }
1013        }
1014    }
1015    Ok(l)
1016}
1017
1018fn lower_tri_inv(l: &Array2<f64>) -> StatsResult<Array2<f64>> {
1019    let n = l.nrows();
1020    let mut inv = Array2::<f64>::zeros((n, n));
1021    for j in 0..n {
1022        inv[[j, j]] = 1.0 / l[[j, j]];
1023        for i in j + 1..n {
1024            let mut s = 0.0_f64;
1025            for kk in j..i {
1026                s += l[[i, kk]] * inv[[kk, j]];
1027            }
1028            inv[[i, j]] = -s / l[[i, i]];
1029        }
1030    }
1031    Ok(inv)
1032}
1033
1034fn inv_symmetric(a: Array2<f64>) -> StatsResult<Array2<f64>> {
1035    let n = a.nrows();
1036    if a.ncols() != n {
1037        return Err(StatsError::DimensionMismatch(
1038            "inv_symmetric: not square".into(),
1039        ));
1040    }
1041    if n == 1 {
1042        let val = a[[0, 0]];
1043        if val.abs() < 1e-15 {
1044            return Err(StatsError::ComputationError(
1045                "inv_symmetric: singular 1×1".into(),
1046            ));
1047        }
1048        let mut inv = Array2::<f64>::zeros((1, 1));
1049        inv[[0, 0]] = 1.0 / val;
1050        return Ok(inv);
1051    }
1052
1053    // Maximum absolute diagonal element for scaling regularisation
1054    let max_diag = (0..n)
1055        .map(|i| a[[i, i]].abs())
1056        .fold(0.0_f64, f64::max)
1057        .max(1e-12);
1058
1059    // Try Cholesky with escalating regularisation
1060    let regularisation_levels = [0.0, 1e-10, 1e-8, 1e-6, 1e-4, 1e-2, 1e-1, 1.0, 10.0];
1061    for &eps_scale in &regularisation_levels {
1062        let mut reg_a = a.clone();
1063        let eps = eps_scale * max_diag;
1064        if eps > 0.0 {
1065            for i in 0..n {
1066                reg_a[[i, i]] += eps;
1067            }
1068        }
1069        match cholesky_lower(reg_a) {
1070            Ok(l) => {
1071                let l_inv = lower_tri_inv(&l)?;
1072                let l_inv_t = l_inv.t().to_owned();
1073                return mat_mat_mul(&l_inv_t, &l_inv);
1074            }
1075            Err(_) => continue,
1076        }
1077    }
1078    // Cholesky failed at all levels — fall back to Gauss-Jordan elimination
1079    // with partial pivoting for robustness.
1080    gauss_jordan_inv(&a)
1081}
1082
1083/// Gauss-Jordan elimination with partial pivoting for matrix inversion.
1084/// Used as a fallback when Cholesky decomposition fails.
1085fn gauss_jordan_inv(a: &Array2<f64>) -> StatsResult<Array2<f64>> {
1086    let n = a.nrows();
1087    // Augmented matrix [A | I]
1088    let mut aug = Array2::<f64>::zeros((n, 2 * n));
1089    for i in 0..n {
1090        for j in 0..n {
1091            aug[[i, j]] = a[[i, j]];
1092        }
1093        aug[[i, n + i]] = 1.0;
1094    }
1095
1096    for col in 0..n {
1097        // Partial pivoting: find the row with the largest absolute value in this column
1098        let mut max_row = col;
1099        let mut max_val = aug[[col, col]].abs();
1100        for row in (col + 1)..n {
1101            let val = aug[[row, col]].abs();
1102            if val > max_val {
1103                max_val = val;
1104                max_row = row;
1105            }
1106        }
1107        if max_val < 1e-15 {
1108            return Err(StatsError::ComputationError(
1109                "gauss_jordan_inv: singular matrix".into(),
1110            ));
1111        }
1112        // Swap rows
1113        if max_row != col {
1114            for j in 0..(2 * n) {
1115                let tmp = aug[[col, j]];
1116                aug[[col, j]] = aug[[max_row, j]];
1117                aug[[max_row, j]] = tmp;
1118            }
1119        }
1120        // Scale pivot row
1121        let pivot = aug[[col, col]];
1122        for j in 0..(2 * n) {
1123            aug[[col, j]] /= pivot;
1124        }
1125        // Eliminate column in all other rows
1126        for row in 0..n {
1127            if row == col {
1128                continue;
1129            }
1130            let factor = aug[[row, col]];
1131            for j in 0..(2 * n) {
1132                aug[[row, j]] -= factor * aug[[col, j]];
1133            }
1134        }
1135    }
1136
1137    // Extract inverse from the right half
1138    let mut inv = Array2::<f64>::zeros((n, n));
1139    for i in 0..n {
1140        for j in 0..n {
1141            inv[[i, j]] = aug[[i, n + j]];
1142        }
1143    }
1144    Ok(inv)
1145}
1146
1147/// Invert with Tikhonov regularisation for numerical safety.
1148fn inv_symmetric_regularised(mut a: Array2<f64>, reg: f64) -> StatsResult<Array2<f64>> {
1149    let n = a.nrows();
1150    for i in 0..n {
1151        a[[i, i]] += reg;
1152    }
1153    inv_symmetric(a)
1154}
1155
1156fn log_det_posdef(a: &Array2<f64>) -> StatsResult<f64> {
1157    let n = a.nrows();
1158    // Try Cholesky with escalating regularisation to handle near-singular innovation covariance
1159    let max_diag = (0..n)
1160        .map(|i| a[[i, i]].abs())
1161        .fold(0.0_f64, f64::max)
1162        .max(1e-12);
1163
1164    let reg_levels = [0.0, 1e-10, 1e-8, 1e-6, 1e-4, 1e-2, 1e-1, 1.0, 10.0];
1165    for &eps_scale in &reg_levels {
1166        let mut reg_a = a.clone();
1167        let eps = eps_scale * max_diag;
1168        if eps > 0.0 {
1169            for i in 0..n {
1170                reg_a[[i, i]] += eps;
1171            }
1172        }
1173        match cholesky_lower(reg_a) {
1174            Ok(l) => {
1175                let log_det: f64 = (0..n).map(|i| 2.0 * l[[i, i]].ln()).sum();
1176                return Ok(log_det);
1177            }
1178            Err(_) => continue,
1179        }
1180    }
1181    Err(StatsError::ComputationError(
1182        "log_det_posdef: Cholesky failed after regularisation".into(),
1183    ))
1184}
1185
1186fn symmetrize(a: &mut Array2<f64>) {
1187    let n = a.nrows();
1188    for i in 0..n {
1189        for j in i + 1..n {
1190            let v = (a[[i, j]] + a[[j, i]]) * 0.5;
1191            a[[i, j]] = v;
1192            a[[j, i]] = v;
1193        }
1194    }
1195}
1196
1197fn regularise_pd(a: &mut Array2<f64>, eps: f64) {
1198    let n = a.nrows();
1199    for i in 0..n {
1200        a[[i, i]] = a[[i, i]].max(eps);
1201    }
1202}
1203
1204// ---------------------------------------------------------------------------
1205// Unit tests
1206// ---------------------------------------------------------------------------
1207
1208#[cfg(test)]
1209mod tests {
1210    use super::*;
1211    use scirs2_core::ndarray::Array2;
1212
1213    fn synthetic_data(t: usize, k: usize, r: usize) -> Array2<f64> {
1214        // Generate data as: X = F * Λ' + ε
1215        // where F is (T, r) random walk and Λ is (k, r) fixed
1216        let lambda: Array2<f64> =
1217            Array2::from_shape_fn((k, r), |(i, j)| ((i + j + 1) as f64) * 0.5);
1218        let mut factors = Array2::<f64>::zeros((t, r));
1219        for t_idx in 1..t {
1220            for j in 0..r {
1221                let prev = factors[[t_idx - 1, j]];
1222                // Deterministic trend + small oscillation
1223                factors[[t_idx, j]] = prev * 0.9 + (t_idx as f64 * 0.1 + j as f64).sin() * 0.5;
1224            }
1225        }
1226        let mut x = Array2::<f64>::zeros((t, k));
1227        for t_idx in 0..t {
1228            for i in 0..k {
1229                let mut val = 0.0_f64;
1230                for j in 0..r {
1231                    val += factors[[t_idx, j]] * lambda[[i, j]];
1232                }
1233                // Simple deterministic noise
1234                val += (t_idx as f64 * 0.3 + i as f64).cos() * 0.1;
1235                x[[t_idx, i]] = val;
1236            }
1237        }
1238        x
1239    }
1240
1241    #[test]
1242    fn test_dfm_basic_fit() {
1243        let data = synthetic_data(50, 4, 2);
1244        let model = fit_dynamic_factor(&data, 2, 1, 30, None).expect("fit_dynamic_factor");
1245        assert_eq!(model.n_factors, 2);
1246        assert_eq!(model.n_vars, 4);
1247        assert_eq!(model.n_lags, 1);
1248        assert_eq!(model.factors.nrows(), 50);
1249        assert_eq!(model.factors.ncols(), 2);
1250        assert_eq!(model.loadings.nrows(), 4);
1251        assert_eq!(model.loadings.ncols(), 2);
1252        assert!(!model.log_lik_history.is_empty());
1253        let last_ll = *model.log_lik_history.last().expect("log_lik");
1254        assert!(last_ll.is_finite(), "log-likelihood must be finite");
1255    }
1256
1257    #[test]
1258    fn test_dfm_extract_factors() {
1259        let data = synthetic_data(40, 5, 1);
1260        let model = fit_dynamic_factor(&data, 1, 1, 20, None).expect("fit");
1261        let factors = model.extract_factors();
1262        assert_eq!(factors.nrows(), 40);
1263        assert_eq!(factors.ncols(), 1);
1264    }
1265
1266    #[test]
1267    fn test_dfm_forecast_one_step() {
1268        let data = synthetic_data(50, 4, 2);
1269        let model = fit_dynamic_factor(&data, 2, 1, 20, None).expect("fit");
1270        let forecast = model.forecast_one_step().expect("forecast");
1271        assert_eq!(forecast.len(), 4);
1272        assert!(forecast.iter().all(|v| v.is_finite()));
1273    }
1274
1275    #[test]
1276    fn test_dfm_with_two_lags() {
1277        // Use more observations and fewer variables to give the 2-lag model
1278        // enough data for numerically stable Cholesky factorisations.
1279        let data = synthetic_data(120, 4, 2);
1280        let model = fit_dynamic_factor(&data, 2, 2, 20, Some(1e-4)).expect("fit p=2");
1281        assert_eq!(model.ar_matrices.len(), 2);
1282        assert_eq!(model.factors.nrows(), 120);
1283    }
1284
1285    #[test]
1286    fn test_dfm_invalid_inputs() {
1287        let data = synthetic_data(30, 4, 2);
1288        // n_factors > n_vars is invalid
1289        assert!(fit_dynamic_factor(&data, 5, 1, 10, None).is_err());
1290        // Insufficient observations
1291        let tiny = synthetic_data(3, 4, 1);
1292        assert!(fit_dynamic_factor(&tiny, 1, 3, 10, None).is_err());
1293    }
1294
1295    #[test]
1296    fn test_dynamic_factor_spec() {
1297        let spec = DynamicFactor::new(2, 5, 1).expect("spec");
1298        assert_eq!(spec.n_factors, 2);
1299        assert_eq!(spec.n_vars, 5);
1300        assert_eq!(spec.n_lags, 1);
1301        // Invalid: n_factors > n_vars
1302        assert!(DynamicFactor::new(6, 5, 1).is_err());
1303        // Invalid: zero
1304        assert!(DynamicFactor::new(0, 5, 1).is_err());
1305    }
1306
1307    #[test]
1308    fn test_impute_column_means() {
1309        let mut x = Array2::<f64>::zeros((4, 2));
1310        x[[0, 0]] = 1.0;
1311        x[[1, 0]] = f64::NAN;
1312        x[[2, 0]] = 3.0;
1313        x[[3, 0]] = 4.0;
1314        let out = impute_column_means(&x);
1315        // Column 0 mean of [1, 3, 4] = 8/3 ≈ 2.667
1316        assert!(out[[1, 0]].is_finite());
1317        assert!((out[[1, 0]] - (1.0 + 3.0 + 4.0) / 3.0).abs() < 1e-10);
1318    }
1319
1320    #[test]
1321    fn test_pca_init_dimensions() {
1322        let data = synthetic_data(30, 5, 2);
1323        let (lambda, factors) = pca_init(&data, 2).expect("pca_init");
1324        assert_eq!(lambda.nrows(), 5);
1325        assert_eq!(lambda.ncols(), 2);
1326        assert_eq!(factors.nrows(), 30);
1327        assert_eq!(factors.ncols(), 2);
1328    }
1329}