Skip to main content

solow_decomposition/
dictionary_learning.rs

1//! DictionaryLearning — sparse dictionary learning à la Mairal-Bach-
2//! Ponce-Sapiro (2009). Alternating scheme:
3//!   1. Fix the dictionary `D`, solve LASSO for the sparse codes `α`.
4//!   2. Fix the codes, update `D` column-wise via a projected gradient
5//!      step that renormalises to the unit ball.
6
7use ndarray::{Array1, Array2, ArrayView2};
8use solow_core::{Error, Result};
9
10/// Fitted DictionaryLearning.
11#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
12#[derive(Clone, Debug, PartialEq)]
13pub struct DictionaryLearning {
14    /// Dictionary `(n_components × d)`.
15    pub components: Array2<f64>,
16    /// Sparse codes `(n × n_components)` at fit time.
17    pub codes: Array2<f64>,
18    /// Kept rank.
19    pub n_components: usize,
20    /// L1 penalty α used.
21    pub alpha: f64,
22    /// Convergence iterations run.
23    pub n_iter: usize,
24}
25
26impl DictionaryLearning {
27    /// Fit with defaults `alpha = 1.0`, `max_iter = 100`, `tol = 1e-6`.
28    pub fn fit(x: ArrayView2<'_, f64>, n_components: usize) -> Result<Self> {
29        Self::fit_with(x, n_components, 1.0, 100, 1e-6)
30    }
31
32    /// Full-configuration fit.
33    pub fn fit_with(
34        x: ArrayView2<'_, f64>,
35        n_components: usize,
36        alpha: f64,
37        max_iter: usize,
38        tol: f64,
39    ) -> Result<Self> {
40        let n = x.nrows();
41        let d = x.ncols();
42        if n_components == 0 {
43            return Err(Error::Value("DictionaryLearning: n_components must be ≥ 1".into()));
44        }
45        if alpha < 0.0 {
46            return Err(Error::Value("DictionaryLearning: alpha must be ≥ 0".into()));
47        }
48        // Init dictionary: first `n_components` rows of X, normalised.
49        let mut dict = Array2::<f64>::zeros((n_components, d));
50        for k in 0..n_components.min(n) {
51            for j in 0..d {
52                dict[[k, j]] = x[[k, j]];
53            }
54        }
55        for k in 0..n_components {
56            let mut nrm = 0.0_f64;
57            for j in 0..d {
58                nrm += dict[[k, j]] * dict[[k, j]];
59            }
60            let nrm = nrm.sqrt().max(1e-30);
61            for j in 0..d {
62                dict[[k, j]] /= nrm;
63            }
64        }
65        let mut codes = Array2::<f64>::zeros((n, n_components));
66        let mut iters = 0_usize;
67        for it in 0..max_iter {
68            iters = it + 1;
69            // Sparse coding: coordinate descent Lasso per row.
70            for i in 0..n {
71                for _ in 0..50 {
72                    let mut delta = 0.0_f64;
73                    for k in 0..n_components {
74                        // Residual r_k = xᵢ − Σ_{k'≠k} α_{i,k'} d_{k'}
75                        //           = xᵢ − Σ_{k'} α_{i,k'} d_{k'} + α_{i,k} d_k
76                        let mut num = 0.0_f64;
77                        for j in 0..d {
78                            let mut recon = 0.0_f64;
79                            for kk in 0..n_components {
80                                if kk == k {
81                                    continue;
82                                }
83                                recon += codes[[i, kk]] * dict[[kk, j]];
84                            }
85                            num += dict[[k, j]] * (x[[i, j]] - recon);
86                        }
87                        // ‖d_k‖² = 1 after normalisation.
88                        let new = soft_threshold(num, alpha);
89                        let dd = (new - codes[[i, k]]).abs();
90                        if dd > delta {
91                            delta = dd;
92                        }
93                        codes[[i, k]] = new;
94                    }
95                    if delta < tol {
96                        break;
97                    }
98                }
99            }
100            // Dictionary update — projected gradient with unit-norm reset.
101            let mut new_dict = Array2::<f64>::zeros((n_components, d));
102            for k in 0..n_components {
103                let mut num = Array1::<f64>::zeros(d);
104                let mut denom = 0.0_f64;
105                for i in 0..n {
106                    denom += codes[[i, k]] * codes[[i, k]];
107                    for j in 0..d {
108                        // r_k = xᵢ − Σ_{k'} α_{i,k'} d_{k'} + α_{i,k} d_k
109                        let mut recon = 0.0_f64;
110                        for kk in 0..n_components {
111                            recon += codes[[i, kk]] * dict[[kk, j]];
112                        }
113                        num[j] += codes[[i, k]] * (x[[i, j]] - recon + codes[[i, k]] * dict[[k, j]]);
114                    }
115                }
116                if denom > 1e-30 {
117                    let mut nrm2 = 0.0_f64;
118                    for j in 0..d {
119                        new_dict[[k, j]] = num[j] / denom;
120                        nrm2 += new_dict[[k, j]] * new_dict[[k, j]];
121                    }
122                    let nrm = nrm2.sqrt().max(1e-30);
123                    for j in 0..d {
124                        new_dict[[k, j]] /= nrm;
125                    }
126                } else {
127                    for j in 0..d {
128                        new_dict[[k, j]] = dict[[k, j]];
129                    }
130                }
131            }
132            let mut delta = 0.0_f64;
133            for k in 0..n_components {
134                for j in 0..d {
135                    let dd = new_dict[[k, j]] - dict[[k, j]];
136                    delta += dd * dd;
137                }
138            }
139            dict = new_dict;
140            if delta.sqrt() < tol {
141                break;
142            }
143        }
144        Ok(Self {
145            components: dict,
146            codes,
147            n_components,
148            alpha,
149            n_iter: iters,
150        })
151    }
152}
153
154fn soft_threshold(z: f64, alpha: f64) -> f64 {
155    if z > alpha {
156        z - alpha
157    } else if z < -alpha {
158        z + alpha
159    } else {
160        0.0
161    }
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167    use ndarray::array;
168
169    #[test]
170    fn dict_learning_returns_dict_of_the_right_shape() {
171        let x = array![
172            [1.0_f64, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0],
173            [1.0, 1.0, 0.0], [0.0, 1.0, 1.0]
174        ];
175        let m = DictionaryLearning::fit_with(x.view(), 3, 0.1, 20, 1e-4).unwrap();
176        assert_eq!(m.components.shape(), &[3, 3]);
177        assert_eq!(m.codes.shape(), &[5, 3]);
178    }
179}