Skip to main content

ferrolearn_decomp/
dictionary_learning.rs

1//! Dictionary Learning.
2//!
3//! [`DictionaryLearning`] learns a dictionary `D` and sparse codes `A` such
4//! that `X ~ A * D`. The dictionary atoms form an overcomplete basis, and
5//! the codes are encouraged to be sparse via an L1 penalty.
6//!
7//! # Algorithm
8//!
9//! Alternating optimisation:
10//!
11//! 1. **Sparse coding step**: with `D` fixed, solve for `A` using coordinate
12//!    descent (lasso) or orthogonal matching pursuit (OMP).
13//! 2. **Dictionary update step**: with `A` fixed, update `D` by solving a
14//!    least-squares problem and normalising the atoms.
15//!
16//! # Examples
17//!
18//! ```
19//! use ferrolearn_decomp::DictionaryLearning;
20//! use ferrolearn_core::traits::{Fit, Transform};
21//! use ndarray::Array2;
22//!
23//! let x = Array2::<f64>::from_shape_fn((20, 10), |(i, j)| {
24//!     ((i * 7 + j * 3) % 11) as f64
25//! });
26//! let dl = DictionaryLearning::new(5)
27//!     .with_max_iter(50)
28//!     .with_random_state(42);
29//! let fitted = dl.fit(&x, &()).unwrap();
30//! let codes = fitted.transform(&x).unwrap();
31//! assert_eq!(codes.dim(), (20, 5));
32//! ```
33//!
34//! ## REQ status
35//!
36//! Design: `.design/decomp/dictionary_learning.md`. Tracking: #1512. Each REQ is
37//! BINARY — SHIPPED (impl + non-test consumer + tests + green verification) or
38//! NOT-STARTED (concrete open blocker). Non-test consumer: crate re-export
39//! (`lib.rs:83`); there is NO PyO3 binding. Oracle = live sklearn 1.5.2
40//! (`_dict_learning.py`, `class DictionaryLearning`), run from `/tmp` (R-CHAR-3).
41//! ferrolearn is a SIMPLIFIED f64-only reimplementation (random-Gaussian init +
42//! soft-threshold CD + normal-equations dict update) — exact component VALUES are a
43//! carve-out (SVD init + LARS + `_update_dict` resampling + RNG all differ).
44//!
45//! | REQ | Scope | Status | Evidence / Blocker |
46//! |---|---|---|---|
47//! | REQ-1 | Structural: `components_` shape `(n_components,n_features)`, transform codes shape, OMP n-nonzero cap + LassoCd sparsity, finite `reconstruction_err_`, `n_iter_` in `[1, max_iter]`, seed-determinism | SHIPPED (scoped) | `fit`/`transform` (`dictionary_learning.rs:495`/`:622`); green-guards in `tests/divergence_dictionary_learning.rs` + in-module tests. STRUCTURAL only, NOT component values (REQ-4) |
48//! | REQ-2 | Dictionary atoms unit L2-norm | SHIPPED | `normalise_dictionary` (`:251`); `test_dictlearn_dictionary_atoms_normalised` + green-guard. FLAG: sklearn `_update_dict` projects onto the unit BALL `/= max(norm,1)` (`_dict_learning.py:548`) so sklearn atoms have norm ≤ 1; ferrolearn uses the unit SPHERE (norm == 1) — coincide at convergence, folds into REQ-7 carve-out |
49//! | REQ-3 | Error/parameter contracts (n_components 0, n_samples 0, n_features 0, alpha<0, transform col mismatch, NON-FINITE rejection) | SHIPPED (scoped) | `fit`/`transform` guards. NON-FINITE: `fit`+`transform` call `reject_non_finite` (`dictionary_learning.rs` symbol `reject_non_finite`) BEFORE the alternating optimisation / sparse-coding, returning the CLEAN finiteness `InvalidParameter{name:"X", reason:"Input X contains NaN or infinity."}` = sklearn `_validate_data(force_all_finite=True)` (`_dict_learning.py:1674`,`:1113`,`utils/validation.py:147-154`). `tests/divergence_nonfinite_spillover.rs::divergence_dictionary_learning_fit_nan` matches the live sklearn 1.5.2 oracle (#2290). FLAG: sklearn raises `InvalidParameterError`, accepts `n_components=None` |
50//! | REQ-4 | EXACT `components_` value parity with sklearn `_dict_learning` | NOT-STARTED | CARVE-OUT (R-DEFER-3): SVD init + LARS lasso + `_update_dict` BCD/resampling + numpy RNG vs ferrolearn random-Gaussian-init + CD + normal-equations; all value candidates gated on the RNG-coupled dictionary (no injectable-dict API) — blocker #1513 |
51//! | REQ-5 | `fit_algorithm="lars"` / LARS solver | NOT-STARTED | sklearn default `_dict_learning.py:1595,:1671`; ferrolearn CD-only (`DictFitAlgorithm::CoordinateDescent`) — blocker #1514 |
52//! | REQ-6 | SVD-based `dict_init`/`code_init` init | NOT-STARTED | sklearn `_dict_learning.py:581-584`; ferrolearn random Gaussian — blocker #1515 |
53//! | REQ-7 | `_update_dict` BCD + unused-atom resampling + unit-BALL projection | NOT-STARTED | sklearn `_dict_learning.py:474-551`; ferrolearn normal-equations LS + unit-SPHERE — blocker #1516 |
54//! | REQ-8 | `transform_alpha` + lasso_lars/lars/threshold algorithms + alpha/n_features scaling | NOT-STARTED | sklearn `_dict_learning.py:141,:1141`; ferrolearn Omp/LassoCd only, raw alpha — blocker #1517 |
55//! | REQ-9 | `split_sign` | NOT-STARTED | sklearn `_dict_learning.py:1131-1137` — blocker #1518 |
56//! | REQ-10 | `positive_code`/`positive_dict` | NOT-STARTED | sklearn `_dict_learning.py:544-545` — blocker #1519 |
57//! | REQ-11 | `transform_max_iter` ctor param | NOT-STARTED | sklearn `_dict_learning.py:1608` — blocker #1520 |
58//! | REQ-12 | `MiniBatchDictionaryLearning` online variant | NOT-STARTED | sklearn `_dict_learning.py:1715`; ABSENT in ferrolearn — blocker #1521 |
59//! | REQ-13 | Fitted attrs `error_`/`n_components_`/`n_features_in_` | NOT-STARTED | sklearn `_dict_learning.py:1700`; ferrolearn has `n_iter_`/`reconstruction_err_` only — blocker #1522 |
60//! | REQ-14 | Generic `F` support | NOT-STARTED | f64-only (`Fit<Array2<f64>, ()>` `:483`) — blocker #1523 |
61//! | REQ-15 | PyO3 binding | NOT-STARTED | no `_RsDictionaryLearning`; only consumer is re-export `lib.rs:83` — blocker #1524 |
62//! | REQ-16 | ferray substrate | NOT-STARTED | dense `ndarray::Array2` + `rand`/`rand_distr` — blocker #1525 |
63//!
64//! Count: **3 SHIPPED (REQ-1,2,3) / 13 NOT-STARTED (REQ-4..16)**.
65
66use ferrolearn_core::error::FerroError;
67use ferrolearn_core::traits::{Fit, Transform};
68use ndarray::Array2;
69use rand::SeedableRng;
70use rand_distr::{Distribution, Normal};
71use rand_xoshiro::Xoshiro256PlusPlus;
72
73/// Reject non-finite input the way sklearn's `_validate_data` does.
74///
75/// sklearn runs `check_array` with the default `force_all_finite=True` at the
76/// top of `DictionaryLearning.fit_transform`/`transform`
77/// (`sklearn/decomposition/_dict_learning.py:1674`,`:1113`), raising
78/// `ValueError("Input X contains NaN.")` / `"... contains infinity ..."`
79/// (`sklearn/utils/validation.py:147-154`) BEFORE any sparse-coding /
80/// dictionary-update math. NaN AND infinity are both rejected. The message
81/// names "NaN" and "infinity" to mirror sklearn's `ValueError`. Never panics
82/// (R-CODE-2).
83fn reject_non_finite(x: &Array2<f64>) -> Result<(), FerroError> {
84    if x.iter().any(|v| !v.is_finite()) {
85        return Err(FerroError::InvalidParameter {
86            name: "X".into(),
87            reason: "Input X contains NaN or infinity.".into(),
88        });
89    }
90    Ok(())
91}
92
93// ---------------------------------------------------------------------------
94// Algorithm enums
95// ---------------------------------------------------------------------------
96
97/// The algorithm for the sparse coding step during fitting.
98#[derive(Debug, Clone, Copy, PartialEq, Eq)]
99pub enum DictFitAlgorithm {
100    /// Coordinate descent (lasso).
101    CoordinateDescent,
102}
103
104/// The algorithm for the sparse coding step during transform.
105#[derive(Debug, Clone, Copy, PartialEq, Eq)]
106pub enum DictTransformAlgorithm {
107    /// Orthogonal Matching Pursuit.
108    Omp,
109    /// Coordinate descent (lasso).
110    LassoCd,
111}
112
113// ---------------------------------------------------------------------------
114// DictionaryLearning (unfitted)
115// ---------------------------------------------------------------------------
116
117/// Dictionary Learning configuration.
118///
119/// Holds hyperparameters for the dictionary learning algorithm. Calling
120/// [`Fit::fit`] learns a dictionary and returns a [`FittedDictionaryLearning`].
121#[derive(Debug, Clone)]
122pub struct DictionaryLearning {
123    /// Number of dictionary atoms (components).
124    n_components: usize,
125    /// Sparsity penalty (L1 coefficient). Default 1.0.
126    alpha: f64,
127    /// Maximum number of alternating optimisation iterations. Default 1000.
128    max_iter: usize,
129    /// Convergence tolerance. Default 1e-8.
130    tol: f64,
131    /// Algorithm for fitting. Default coordinate descent.
132    fit_algorithm: DictFitAlgorithm,
133    /// Algorithm for transform. Default OMP.
134    transform_algorithm: DictTransformAlgorithm,
135    /// Maximum atoms per sample for OMP. Default n_components.
136    transform_n_nonzero_coefs: Option<usize>,
137    /// Optional random seed.
138    random_state: Option<u64>,
139}
140
141impl DictionaryLearning {
142    /// Create a new `DictionaryLearning` with `n_components` atoms.
143    ///
144    /// Defaults: `alpha=1.0`, `max_iter=1000`, `tol=1e-8`,
145    /// `fit_algorithm=CoordinateDescent`, `transform_algorithm=Omp`.
146    #[must_use]
147    pub fn new(n_components: usize) -> Self {
148        Self {
149            n_components,
150            alpha: 1.0,
151            max_iter: 1000,
152            tol: 1e-8,
153            fit_algorithm: DictFitAlgorithm::CoordinateDescent,
154            transform_algorithm: DictTransformAlgorithm::Omp,
155            transform_n_nonzero_coefs: None,
156            random_state: None,
157        }
158    }
159
160    /// Set the sparsity penalty.
161    #[must_use]
162    pub fn with_alpha(mut self, alpha: f64) -> Self {
163        self.alpha = alpha;
164        self
165    }
166
167    /// Set the maximum number of iterations.
168    #[must_use]
169    pub fn with_max_iter(mut self, n: usize) -> Self {
170        self.max_iter = n;
171        self
172    }
173
174    /// Set the convergence tolerance.
175    #[must_use]
176    pub fn with_tol(mut self, tol: f64) -> Self {
177        self.tol = tol;
178        self
179    }
180
181    /// Set the fit algorithm.
182    #[must_use]
183    pub fn with_fit_algorithm(mut self, algo: DictFitAlgorithm) -> Self {
184        self.fit_algorithm = algo;
185        self
186    }
187
188    /// Set the transform algorithm.
189    #[must_use]
190    pub fn with_transform_algorithm(mut self, algo: DictTransformAlgorithm) -> Self {
191        self.transform_algorithm = algo;
192        self
193    }
194
195    /// Set the maximum number of non-zero coefficients for OMP transform.
196    #[must_use]
197    pub fn with_transform_n_nonzero_coefs(mut self, n: usize) -> Self {
198        self.transform_n_nonzero_coefs = Some(n);
199        self
200    }
201
202    /// Set the random seed.
203    #[must_use]
204    pub fn with_random_state(mut self, seed: u64) -> Self {
205        self.random_state = Some(seed);
206        self
207    }
208
209    /// Return the configured number of components.
210    #[must_use]
211    pub fn n_components(&self) -> usize {
212        self.n_components
213    }
214
215    /// Return the configured alpha.
216    #[must_use]
217    pub fn alpha(&self) -> f64 {
218        self.alpha
219    }
220
221    /// Return the configured maximum iterations.
222    #[must_use]
223    pub fn max_iter(&self) -> usize {
224        self.max_iter
225    }
226
227    /// Return the configured tolerance.
228    #[must_use]
229    pub fn tol(&self) -> f64 {
230        self.tol
231    }
232
233    /// Return the configured fit algorithm.
234    #[must_use]
235    pub fn fit_algorithm(&self) -> DictFitAlgorithm {
236        self.fit_algorithm
237    }
238
239    /// Return the configured transform algorithm.
240    #[must_use]
241    pub fn transform_algorithm(&self) -> DictTransformAlgorithm {
242        self.transform_algorithm
243    }
244
245    /// Return the configured random state, if any.
246    #[must_use]
247    pub fn random_state(&self) -> Option<u64> {
248        self.random_state
249    }
250}
251
252// ---------------------------------------------------------------------------
253// FittedDictionaryLearning
254// ---------------------------------------------------------------------------
255
256/// A fitted dictionary learning model.
257///
258/// Created by calling [`Fit::fit`] on a [`DictionaryLearning`]. The learned
259/// dictionary is accessible via [`FittedDictionaryLearning::components`].
260/// Implements [`Transform<Array2<f64>>`] to compute sparse codes for new data.
261#[derive(Debug, Clone)]
262pub struct FittedDictionaryLearning {
263    /// Learned dictionary, shape `(n_components, n_features)`.
264    /// Each row is a dictionary atom.
265    components_: Array2<f64>,
266    /// Sparsity penalty used during fitting.
267    alpha_: f64,
268    /// Number of iterations performed.
269    n_iter_: usize,
270    /// Final reconstruction error (Frobenius norm).
271    reconstruction_err_: f64,
272    /// Transform algorithm to use.
273    transform_algorithm_: DictTransformAlgorithm,
274    /// Max non-zero coefs for OMP.
275    transform_n_nonzero_coefs_: usize,
276}
277
278impl FittedDictionaryLearning {
279    /// The learned dictionary, shape `(n_components, n_features)`.
280    #[must_use]
281    pub fn components(&self) -> &Array2<f64> {
282        &self.components_
283    }
284
285    /// Number of iterations performed.
286    #[must_use]
287    pub fn n_iter(&self) -> usize {
288        self.n_iter_
289    }
290
291    /// The reconstruction error at convergence.
292    #[must_use]
293    pub fn reconstruction_err(&self) -> f64 {
294        self.reconstruction_err_
295    }
296}
297
298// ---------------------------------------------------------------------------
299// Internal helpers
300// ---------------------------------------------------------------------------
301
302/// Normalise dictionary rows to unit L2 norm.
303fn normalise_dictionary(d: &mut Array2<f64>) {
304    let n_components = d.nrows();
305    let n_features = d.ncols();
306    for k in 0..n_components {
307        let mut norm = 0.0;
308        for j in 0..n_features {
309            norm += d[[k, j]] * d[[k, j]];
310        }
311        let norm = norm.sqrt();
312        if norm > 1e-16 {
313            for j in 0..n_features {
314                d[[k, j]] /= norm;
315            }
316        }
317    }
318}
319
320/// Lasso coordinate descent for a single sample: solve
321///   min_a 0.5 * ||x - D^T a||^2 + alpha * ||a||_1
322/// where x is (n_features,), D is (n_components, n_features), a is (n_components,).
323fn lasso_cd_single(x_row: &[f64], d: &Array2<f64>, alpha: f64, max_iter: usize) -> Vec<f64> {
324    let n_components = d.nrows();
325    let n_features = d.ncols();
326    let mut a = vec![0.0; n_components];
327
328    // Pre-compute D * D^T (Gram matrix) and D * x.
329    let mut gram = vec![vec![0.0; n_components]; n_components];
330    let mut dx = vec![0.0; n_components];
331    for k in 0..n_components {
332        for j in 0..n_features {
333            dx[k] += d[[k, j]] * x_row[j];
334        }
335        for l in k..n_components {
336            let mut val = 0.0;
337            for j in 0..n_features {
338                val += d[[k, j]] * d[[l, j]];
339            }
340            gram[k][l] = val;
341            gram[l][k] = val;
342        }
343    }
344
345    for _iter in 0..max_iter {
346        let mut max_change = 0.0;
347        for k in 0..n_components {
348            // Compute partial residual: dx[k] - sum_{l!=k} gram[k][l] * a[l]
349            let mut rho = dx[k];
350            for l in 0..n_components {
351                if l != k {
352                    rho -= gram[k][l] * a[l];
353                }
354            }
355
356            // Soft threshold.
357            let gram_kk = gram[k][k];
358            let new_a = if gram_kk.abs() < 1e-16 {
359                0.0
360            } else {
361                soft_threshold(rho, alpha) / gram_kk
362            };
363
364            let change = (new_a - a[k]).abs();
365            if change > max_change {
366                max_change = change;
367            }
368            a[k] = new_a;
369        }
370        if max_change < 1e-6 {
371            break;
372        }
373    }
374
375    a
376}
377
378/// Orthogonal Matching Pursuit for a single sample.
379fn omp_single(x_row: &[f64], d: &Array2<f64>, max_nonzero: usize) -> Vec<f64> {
380    let n_components = d.nrows();
381    let n_features = d.ncols();
382    let mut a = vec![0.0; n_components];
383    let mut residual: Vec<f64> = x_row.to_vec();
384    let mut selected: Vec<usize> = Vec::new();
385    let max_k = max_nonzero.min(n_components).min(n_features);
386
387    for _step in 0..max_k {
388        // Find the atom most correlated with the residual.
389        let mut best_idx = 0;
390        let mut best_corr = 0.0;
391        for k in 0..n_components {
392            if selected.contains(&k) {
393                continue;
394            }
395            let mut corr = 0.0;
396            for j in 0..n_features {
397                corr += d[[k, j]] * residual[j];
398            }
399            if corr.abs() > best_corr {
400                best_corr = corr.abs();
401                best_idx = k;
402            }
403        }
404
405        if best_corr < 1e-12 {
406            break;
407        }
408
409        selected.push(best_idx);
410
411        // Solve least squares: x = D_selected^T * a_selected
412        // Use normal equations: (D_s D_s^T) a_s = D_s x
413        let m = selected.len();
414        let mut gram = vec![vec![0.0; m]; m];
415        let mut rhs = vec![0.0; m];
416        for (ii, &ki) in selected.iter().enumerate() {
417            for j in 0..n_features {
418                rhs[ii] += d[[ki, j]] * x_row[j];
419            }
420            for (jj, &kj) in selected.iter().enumerate() {
421                let mut val = 0.0;
422                for f in 0..n_features {
423                    val += d[[ki, f]] * d[[kj, f]];
424                }
425                gram[ii][jj] = val;
426            }
427        }
428
429        // Solve gram * coefs = rhs via Cholesky-like.
430        if let Some(coefs) = solve_symmetric(&gram, &rhs) {
431            // Update residual.
432            residual = x_row.to_vec();
433            for (ii, &ki) in selected.iter().enumerate() {
434                a[ki] = coefs[ii];
435                for j in 0..n_features {
436                    residual[j] -= coefs[ii] * d[[ki, j]];
437                }
438            }
439        } else {
440            break;
441        }
442
443        // Check if residual is small enough.
444        let res_norm: f64 = residual.iter().map(|v| v * v).sum::<f64>().sqrt();
445        if res_norm < 1e-10 {
446            break;
447        }
448    }
449
450    a
451}
452
453/// Solve a small symmetric positive definite system Ax = b using
454/// Gaussian elimination with partial pivoting. Returns None if singular.
455#[allow(clippy::needless_range_loop)]
456fn solve_symmetric(a: &[Vec<f64>], b: &[f64]) -> Option<Vec<f64>> {
457    let n = b.len();
458    if n == 0 {
459        return Some(vec![]);
460    }
461
462    // Augmented matrix.
463    let mut aug: Vec<Vec<f64>> = Vec::with_capacity(n);
464    for (i, row) in a.iter().enumerate().take(n) {
465        let mut r = row.clone();
466        r.push(b[i]);
467        aug.push(r);
468    }
469
470    // Forward elimination with partial pivoting.
471    for col in 0..n {
472        // Find pivot.
473        let mut max_val = aug[col][col].abs();
474        let mut max_row = col;
475        for row in (col + 1)..n {
476            if aug[row][col].abs() > max_val {
477                max_val = aug[row][col].abs();
478                max_row = row;
479            }
480        }
481        if max_val < 1e-14 {
482            return None;
483        }
484        aug.swap(col, max_row);
485
486        let pivot = aug[col][col];
487        for row in (col + 1)..n {
488            let factor = aug[row][col] / pivot;
489            for j in col..=n {
490                let val = aug[col][j];
491                aug[row][j] -= factor * val;
492            }
493        }
494    }
495
496    // Back substitution.
497    let mut x = vec![0.0; n];
498    for i in (0..n).rev() {
499        let mut sum = aug[i][n];
500        for j in (i + 1)..n {
501            sum -= aug[i][j] * x[j];
502        }
503        x[i] = sum / aug[i][i];
504    }
505
506    Some(x)
507}
508
509/// Soft thresholding: sign(x) * max(|x| - lambda, 0).
510fn soft_threshold(x: f64, lambda: f64) -> f64 {
511    if x > lambda {
512        x - lambda
513    } else if x < -lambda {
514        x + lambda
515    } else {
516        0.0
517    }
518}
519
520/// Compute Frobenius norm of X - A * D.
521fn reconstruction_error(x: &Array2<f64>, a: &Array2<f64>, d: &Array2<f64>) -> f64 {
522    let ad = a.dot(d);
523    let mut err = 0.0;
524    for (xi, adi) in x.iter().zip(ad.iter()) {
525        let diff = xi - adi;
526        err += diff * diff;
527    }
528    err.sqrt()
529}
530
531// ---------------------------------------------------------------------------
532// Trait implementations
533// ---------------------------------------------------------------------------
534
535impl Fit<Array2<f64>, ()> for DictionaryLearning {
536    type Fitted = FittedDictionaryLearning;
537    type Error = FerroError;
538
539    /// Fit the dictionary learning model.
540    ///
541    /// # Errors
542    ///
543    /// - [`FerroError::InvalidParameter`] if `n_components` is zero or
544    ///   `alpha` is negative.
545    /// - [`FerroError::InsufficientSamples`] if there are zero samples or
546    ///   zero features.
547    fn fit(&self, x: &Array2<f64>, _y: &()) -> Result<FittedDictionaryLearning, FerroError> {
548        let (n_samples, n_features) = x.dim();
549
550        // Validate.
551        if self.n_components == 0 {
552            return Err(FerroError::InvalidParameter {
553                name: "n_components".into(),
554                reason: "must be at least 1".into(),
555            });
556        }
557        if n_samples == 0 {
558            return Err(FerroError::InsufficientSamples {
559                required: 1,
560                actual: 0,
561                context: "DictionaryLearning::fit".into(),
562            });
563        }
564        if n_features == 0 {
565            return Err(FerroError::InvalidParameter {
566                name: "X".into(),
567                reason: "must have at least 1 feature".into(),
568            });
569        }
570        if self.alpha < 0.0 {
571            return Err(FerroError::InvalidParameter {
572                name: "alpha".into(),
573                reason: "must be non-negative".into(),
574            });
575        }
576        // Reject NaN/Inf BEFORE the SVD-free alternating optimisation (sklearn's
577        // `_validate_data(force_all_finite=True)` at `_dict_learning.py:1674`,
578        // `utils/validation.py:147-154`).
579        reject_non_finite(x)?;
580
581        let n_components = self.n_components;
582        let seed = self.random_state.unwrap_or(0);
583        let transform_n_nonzero = self.transform_n_nonzero_coefs.unwrap_or(n_components);
584
585        // Initialise dictionary from random Gaussian, then normalise.
586        let mut rng = Xoshiro256PlusPlus::seed_from_u64(seed);
587        let normal = Normal::new(0.0, 1.0).map_err(|e| FerroError::NumericalInstability {
588            message: format!(
589                "DictionaryLearning::fit: failed to construct Normal(0,1) for dictionary init: {e}"
590            ),
591        })?;
592        let mut d = Array2::<f64>::zeros((n_components, n_features));
593        for elem in &mut d {
594            *elem = normal.sample(&mut rng);
595        }
596        normalise_dictionary(&mut d);
597
598        let mut prev_err = f64::MAX;
599        let mut n_iter = 0;
600
601        for iteration in 0..self.max_iter {
602            n_iter = iteration + 1;
603
604            // Sparse coding step: compute codes A.
605            let mut a = Array2::<f64>::zeros((n_samples, n_components));
606            for i in 0..n_samples {
607                let x_row: Vec<f64> = (0..n_features).map(|j| x[[i, j]]).collect();
608                let codes = lasso_cd_single(&x_row, &d, self.alpha, 200);
609                for k in 0..n_components {
610                    a[[i, k]] = codes[k];
611                }
612            }
613
614            // Dictionary update step: D = (A^T A)^{-1} A^T X
615            // We solve the normal equations for each atom.
616            let ata = a.t().dot(&a);
617            let atx = a.t().dot(x);
618
619            // Solve K x K system for each feature column of D.
620            // Build the Gram matrix as Vec<Vec<f64>>.
621            let gram: Vec<Vec<f64>> = (0..n_components)
622                .map(|i| (0..n_components).map(|j| ata[[i, j]]).collect())
623                .collect();
624
625            // Add small regularisation for stability.
626            let mut gram_reg = gram.clone();
627            for (k, row) in gram_reg.iter_mut().enumerate() {
628                row[k] += 1e-10;
629            }
630
631            for j in 0..n_features {
632                let rhs: Vec<f64> = (0..n_components).map(|k| atx[[k, j]]).collect();
633                if let Some(sol) = solve_symmetric(&gram_reg, &rhs) {
634                    for k in 0..n_components {
635                        d[[k, j]] = sol[k];
636                    }
637                }
638            }
639
640            normalise_dictionary(&mut d);
641
642            // Check convergence.
643            let err = reconstruction_error(x, &a, &d);
644            if (prev_err - err).abs() < self.tol {
645                break;
646            }
647            prev_err = err;
648        }
649
650        // Final sparse coding for reconstruction error.
651        let mut a_final = Array2::<f64>::zeros((n_samples, n_components));
652        for i in 0..n_samples {
653            let x_row: Vec<f64> = (0..n_features).map(|j| x[[i, j]]).collect();
654            let codes = lasso_cd_single(&x_row, &d, self.alpha, 200);
655            for k in 0..n_components {
656                a_final[[i, k]] = codes[k];
657            }
658        }
659        let final_err = reconstruction_error(x, &a_final, &d);
660
661        Ok(FittedDictionaryLearning {
662            components_: d,
663            alpha_: self.alpha,
664            n_iter_: n_iter,
665            reconstruction_err_: final_err,
666            transform_algorithm_: self.transform_algorithm,
667            transform_n_nonzero_coefs_: transform_n_nonzero,
668        })
669    }
670}
671
672impl Transform<Array2<f64>> for FittedDictionaryLearning {
673    type Output = Array2<f64>;
674    type Error = FerroError;
675
676    /// Compute sparse codes for new data using the learned dictionary.
677    ///
678    /// # Errors
679    ///
680    /// Returns [`FerroError::ShapeMismatch`] if the number of features does
681    /// not match the dictionary.
682    fn transform(&self, x: &Array2<f64>) -> Result<Array2<f64>, FerroError> {
683        let n_features = self.components_.ncols();
684        if x.ncols() != n_features {
685            return Err(FerroError::ShapeMismatch {
686                expected: vec![x.nrows(), n_features],
687                actual: vec![x.nrows(), x.ncols()],
688                context: "FittedDictionaryLearning::transform".into(),
689            });
690        }
691
692        // Reject NaN/Inf BEFORE the sparse-coding step (sklearn re-validates with
693        // `_validate_data(reset=False, force_all_finite=True)` at
694        // `_dict_learning.py:1113`, `utils/validation.py:147-154`).
695        reject_non_finite(x)?;
696
697        let n_samples = x.nrows();
698        let n_components = self.components_.nrows();
699        let mut codes = Array2::<f64>::zeros((n_samples, n_components));
700
701        for i in 0..n_samples {
702            let x_row: Vec<f64> = (0..n_features).map(|j| x[[i, j]]).collect();
703            let a = match self.transform_algorithm_ {
704                DictTransformAlgorithm::Omp => {
705                    omp_single(&x_row, &self.components_, self.transform_n_nonzero_coefs_)
706                }
707                DictTransformAlgorithm::LassoCd => {
708                    lasso_cd_single(&x_row, &self.components_, self.alpha_, 200)
709                }
710            };
711            for k in 0..n_components {
712                codes[[i, k]] = a[k];
713            }
714        }
715
716        Ok(codes)
717    }
718}
719
720// ---------------------------------------------------------------------------
721// Tests
722// ---------------------------------------------------------------------------
723
724#[cfg(test)]
725mod tests {
726    use super::*;
727    use ndarray::Array2;
728
729    /// Create a simple test dataset.
730    fn test_data() -> Array2<f64> {
731        Array2::<f64>::from_shape_fn((20, 10), |(i, j)| ((i * 7 + j * 3) % 11) as f64)
732    }
733
734    #[test]
735    fn test_dictlearn_basic_shape() {
736        let x = test_data();
737        let dl = DictionaryLearning::new(5)
738            .with_max_iter(20)
739            .with_random_state(42);
740        let fitted = dl.fit(&x, &()).unwrap();
741        assert_eq!(fitted.components().dim(), (5, 10));
742    }
743
744    #[test]
745    fn test_dictlearn_transform_shape() {
746        let x = test_data();
747        let dl = DictionaryLearning::new(5)
748            .with_max_iter(20)
749            .with_random_state(42);
750        let fitted = dl.fit(&x, &()).unwrap();
751        let codes = fitted.transform(&x).unwrap();
752        assert_eq!(codes.dim(), (20, 5));
753    }
754
755    #[test]
756    fn test_dictlearn_reconstruction_error_decreases() {
757        let x = test_data();
758        let dl_few = DictionaryLearning::new(5)
759            .with_max_iter(5)
760            .with_random_state(42);
761        let dl_many = DictionaryLearning::new(5)
762            .with_max_iter(50)
763            .with_random_state(42);
764        let fitted_few = dl_few.fit(&x, &()).unwrap();
765        let fitted_many = dl_many.fit(&x, &()).unwrap();
766        assert!(
767            fitted_many.reconstruction_err() <= fitted_few.reconstruction_err() + 1.0,
768            "more iterations should reduce error: few={}, many={}",
769            fitted_few.reconstruction_err(),
770            fitted_many.reconstruction_err()
771        );
772    }
773
774    #[test]
775    fn test_dictlearn_dictionary_atoms_normalised() {
776        let x = test_data();
777        let dl = DictionaryLearning::new(5)
778            .with_max_iter(20)
779            .with_random_state(42);
780        let fitted = dl.fit(&x, &()).unwrap();
781        let d = fitted.components();
782        for k in 0..d.nrows() {
783            let norm: f64 = d.row(k).iter().map(|v| v * v).sum::<f64>().sqrt();
784            assert!(
785                (norm - 1.0).abs() < 1e-6,
786                "atom {k} should be unit norm, got {norm}"
787            );
788        }
789    }
790
791    #[test]
792    fn test_dictlearn_sparsity_of_codes() {
793        let x = test_data();
794        let dl = DictionaryLearning::new(8)
795            .with_alpha(2.0) // Higher alpha = more sparsity.
796            .with_max_iter(20)
797            .with_random_state(42);
798        let fitted = dl.fit(&x, &()).unwrap();
799        let codes = fitted.transform(&x).unwrap();
800        // Count zero entries.
801        let total = codes.len();
802        let zeros = codes.iter().filter(|&&v| v.abs() < 1e-10).count();
803        let sparsity = zeros as f64 / total as f64;
804        assert!(
805            sparsity > 0.1,
806            "codes should have some sparsity, got {:.1}%",
807            sparsity * 100.0
808        );
809    }
810
811    #[test]
812    fn test_dictlearn_omp_transform() {
813        let x = test_data();
814        let dl = DictionaryLearning::new(5)
815            .with_max_iter(20)
816            .with_transform_algorithm(DictTransformAlgorithm::Omp)
817            .with_random_state(42);
818        let fitted = dl.fit(&x, &()).unwrap();
819        let codes = fitted.transform(&x).unwrap();
820        assert_eq!(codes.dim(), (20, 5));
821    }
822
823    #[test]
824    fn test_dictlearn_lasso_cd_transform() {
825        let x = test_data();
826        let dl = DictionaryLearning::new(5)
827            .with_max_iter(20)
828            .with_transform_algorithm(DictTransformAlgorithm::LassoCd)
829            .with_random_state(42);
830        let fitted = dl.fit(&x, &()).unwrap();
831        let codes = fitted.transform(&x).unwrap();
832        assert_eq!(codes.dim(), (20, 5));
833    }
834
835    #[test]
836    fn test_dictlearn_transform_shape_mismatch() {
837        let x = test_data();
838        let dl = DictionaryLearning::new(5)
839            .with_max_iter(10)
840            .with_random_state(42);
841        let fitted = dl.fit(&x, &()).unwrap();
842        let x_bad = Array2::<f64>::zeros((5, 3)); // wrong number of features
843        assert!(fitted.transform(&x_bad).is_err());
844    }
845
846    #[test]
847    fn test_dictlearn_invalid_n_components_zero() {
848        let x = test_data();
849        let dl = DictionaryLearning::new(0);
850        assert!(dl.fit(&x, &()).is_err());
851    }
852
853    #[test]
854    fn test_dictlearn_invalid_alpha_negative() {
855        let x = test_data();
856        let dl = DictionaryLearning::new(5).with_alpha(-1.0);
857        assert!(dl.fit(&x, &()).is_err());
858    }
859
860    #[test]
861    fn test_dictlearn_empty_data() {
862        let x = Array2::<f64>::zeros((0, 5));
863        let dl = DictionaryLearning::new(2);
864        assert!(dl.fit(&x, &()).is_err());
865    }
866
867    #[test]
868    fn test_dictlearn_zero_features() {
869        let x = Array2::<f64>::zeros((10, 0));
870        let dl = DictionaryLearning::new(2);
871        assert!(dl.fit(&x, &()).is_err());
872    }
873
874    #[test]
875    fn test_dictlearn_getters() {
876        let dl = DictionaryLearning::new(5)
877            .with_alpha(0.5)
878            .with_max_iter(100)
879            .with_tol(1e-6)
880            .with_fit_algorithm(DictFitAlgorithm::CoordinateDescent)
881            .with_transform_algorithm(DictTransformAlgorithm::LassoCd)
882            .with_random_state(99);
883        assert_eq!(dl.n_components(), 5);
884        assert!((dl.alpha() - 0.5).abs() < 1e-10);
885        assert_eq!(dl.max_iter(), 100);
886        assert!((dl.tol() - 1e-6).abs() < 1e-12);
887        assert_eq!(dl.fit_algorithm(), DictFitAlgorithm::CoordinateDescent);
888        assert_eq!(dl.transform_algorithm(), DictTransformAlgorithm::LassoCd);
889        assert_eq!(dl.random_state(), Some(99));
890    }
891
892    #[test]
893    fn test_dictlearn_fitted_accessors() {
894        let x = test_data();
895        let dl = DictionaryLearning::new(5)
896            .with_max_iter(10)
897            .with_random_state(42);
898        let fitted = dl.fit(&x, &()).unwrap();
899        assert!(fitted.n_iter() > 0);
900        assert!(fitted.reconstruction_err() >= 0.0);
901    }
902
903    #[test]
904    fn test_dictlearn_single_component() {
905        let x = test_data();
906        let dl = DictionaryLearning::new(1)
907            .with_max_iter(20)
908            .with_random_state(42);
909        let fitted = dl.fit(&x, &()).unwrap();
910        assert_eq!(fitted.components().nrows(), 1);
911        let codes = fitted.transform(&x).unwrap();
912        assert_eq!(codes.ncols(), 1);
913    }
914
915    #[test]
916    fn test_dictlearn_omp_nonzero_coefs() {
917        let x = test_data();
918        let dl = DictionaryLearning::new(5)
919            .with_max_iter(20)
920            .with_transform_algorithm(DictTransformAlgorithm::Omp)
921            .with_transform_n_nonzero_coefs(2)
922            .with_random_state(42);
923        let fitted = dl.fit(&x, &()).unwrap();
924        let codes = fitted.transform(&x).unwrap();
925        // Each row should have at most 2 non-zero entries.
926        for i in 0..codes.nrows() {
927            let nnz = codes.row(i).iter().filter(|&&v| v.abs() > 1e-10).count();
928            assert!(nnz <= 2, "row {i} has {nnz} non-zeros, expected at most 2");
929        }
930    }
931
932    #[test]
933    fn test_soft_threshold() {
934        assert!((soft_threshold(5.0, 2.0) - 3.0).abs() < 1e-10);
935        assert!((soft_threshold(-5.0, 2.0) - (-3.0)).abs() < 1e-10);
936        assert!((soft_threshold(1.0, 2.0)).abs() < 1e-10);
937        assert!((soft_threshold(0.0, 2.0)).abs() < 1e-10);
938    }
939}