modewise_vlmd/
params.rs

1use modewise_method::MethodError;
2
3/// Defaults follow the reference implementation.
4#[derive(Debug, Clone, PartialEq)]
5pub struct VlmdParams {
6    /// Number of modes K.
7    pub num_modes: usize,
8    /// Number of latent components L (at most the number of channels). `None` uses one per channel.
9    pub num_latents: Option<usize>,
10    /// Bandwidth penalty α: larger values give narrower modes.
11    pub alpha: f64,
12    /// Sparsity λ of the loadings, on the reference scale (see `lasso`).
13    pub reg_lambda: f64,
14    /// ADMM penalty ρ: how closely the latent components must equal the sum of their modes.
15    pub reg_rho: f64,
16    /// Dual step τ in [0, 1].
17    pub tau: f64,
18    /// Stop when Σ((ωₙ₊₁ − ωₙ)/ωₙ)² falls to this.
19    pub tolerance: f64,
20    pub max_iterations: usize,
21}
22
23impl VlmdParams {
24    pub fn new(num_modes: usize) -> Self {
25        Self {
26            num_modes,
27            num_latents: None,
28            alpha: 100.0,
29            reg_lambda: 0.01,
30            reg_rho: 1.0,
31            tau: 0.9,
32            tolerance: 1e-3,
33            max_iterations: 1000,
34        }
35    }
36
37    pub(crate) fn validate(&self) -> Result<(), MethodError> {
38        let positive = |v: f64| v.is_finite() && v > 0.0;
39        if self.num_modes == 0 {
40            return Err(MethodError::invalid("num_modes", "must be at least 1"));
41        }
42        if self.num_latents == Some(0) {
43            return Err(MethodError::invalid("num_latents", "must be at least 1"));
44        }
45        if !positive(self.alpha) {
46            return Err(MethodError::invalid("alpha", "must be finite and positive"));
47        }
48        if !(self.reg_lambda.is_finite() && self.reg_lambda >= 0.0) {
49            return Err(MethodError::invalid(
50                "reg_lambda",
51                "must be finite and non-negative",
52            ));
53        }
54        if !positive(self.reg_rho) {
55            return Err(MethodError::invalid(
56                "reg_rho",
57                "must be finite and positive",
58            ));
59        }
60        if !(0.0..=1.0).contains(&self.tau) {
61            return Err(MethodError::invalid("tau", "must be between 0 and 1"));
62        }
63        if !positive(self.tolerance) {
64            return Err(MethodError::invalid(
65                "tolerance",
66                "must be finite and positive",
67            ));
68        }
69        if self.max_iterations == 0 {
70            return Err(MethodError::invalid("max_iterations", "must be at least 1"));
71        }
72        Ok(())
73    }
74
75    pub(crate) fn latents_for(&self, n_channels: usize) -> Result<usize, MethodError> {
76        let latents = self.num_latents.unwrap_or(n_channels);
77        if latents > n_channels {
78            return Err(MethodError::invalid(
79                "num_latents",
80                format!(
81                    "must be at most the number of channels ({n_channels}); the model assumes L ≤ C"
82                ),
83            ));
84        }
85        Ok(latents)
86    }
87}
88
89#[cfg(test)]
90mod tests {
91    use super::*;
92
93    #[test]
94    fn rejects_invalid_values() {
95        let base = VlmdParams::new(3);
96        assert!(base.validate().is_ok());
97        let cases = [
98            VlmdParams {
99                num_modes: 0,
100                ..base.clone()
101            },
102            VlmdParams {
103                num_latents: Some(0),
104                ..base.clone()
105            },
106            VlmdParams {
107                alpha: 0.0,
108                ..base.clone()
109            },
110            VlmdParams {
111                alpha: f64::INFINITY,
112                ..base.clone()
113            },
114            VlmdParams {
115                reg_lambda: -1e-9,
116                ..base.clone()
117            },
118            VlmdParams {
119                reg_lambda: f64::NAN,
120                ..base.clone()
121            },
122            VlmdParams {
123                reg_rho: 0.0,
124                ..base.clone()
125            },
126            VlmdParams {
127                reg_rho: f64::NAN,
128                ..base.clone()
129            },
130            VlmdParams {
131                tau: 1.0 + 1e-9,
132                ..base.clone()
133            },
134            VlmdParams {
135                tau: -1e-9,
136                ..base.clone()
137            },
138            VlmdParams {
139                tolerance: 0.0,
140                ..base.clone()
141            },
142            VlmdParams {
143                max_iterations: 0,
144                ..base.clone()
145            },
146        ];
147        for case in cases {
148            assert!(case.validate().is_err(), "{case:?}");
149        }
150        for ok in [
151            VlmdParams {
152                reg_lambda: 0.0,
153                ..base.clone()
154            },
155            VlmdParams {
156                tau: 0.0,
157                ..base.clone()
158            },
159            VlmdParams {
160                tau: 1.0,
161                ..base.clone()
162            },
163        ] {
164            assert!(ok.validate().is_ok(), "{ok:?}");
165        }
166    }
167
168    #[test]
169    fn latents_default_to_channels_and_cannot_exceed_them() {
170        let p = VlmdParams::new(2);
171        assert_eq!(p.latents_for(5).unwrap(), 5);
172        let three = VlmdParams {
173            num_latents: Some(3),
174            ..p
175        };
176        assert_eq!(three.latents_for(3).unwrap(), 3);
177        assert!(three.latents_for(2).is_err());
178    }
179}