Skip to main content

dspk_ops/linalg/
ppca.rs

1use cubecl::prelude::*;
2use crate::core::DspFloat;
3use super::covariance::covariance_of_host;
4use super::eigen::{symmetric_eigen, EigenOptions};
5use super::projection::DeviceProjection;
6
7/// Floor of the isotropic noise variance (keeps `M = WᵀW + σ²I` invertible).
8pub const MIN_NOISE_VARIANCE: f64 = 1e-8;
9
10/// Noise variance used when every component is kept (no discarded eigenvalues to average).
11pub const FULL_RANK_NOISE_VARIANCE: f64 = 1e-6;
12
13/// Probabilistic Principal Component Analysis (PPCA) model (Tipping & Bishop, 1999).
14///
15/// Generative Gaussian latent variable model:
16/// $$\mathbf{x} = \mathbf{W}\mathbf{z} + \boldsymbol{\mu} + \boldsymbol{\epsilon}, \quad \mathbf{z} \sim \mathcal{N}(\mathbf{0}, \mathbf{I}_q), \quad \boldsymbol{\epsilon} \sim \mathcal{N}(\mathbf{0}, \sigma^2 \mathbf{I}_D)$$
17#[derive(Debug, Clone)]
18pub struct PpcaModel {
19    pub num_channels: usize,
20    pub num_components: usize,
21    pub mean: Vec<f32>,
22    /// Factor loading matrix $\mathbf{W}$ of shape `[num_channels, num_components]` (row-major).
23    pub weights: Vec<f32>,
24    /// Posterior projection matrix $\mathbf{P} = \mathbf{W} (\mathbf{W}^\top \mathbf{W} + \sigma^2 \mathbf{I})^{-1}$
25    /// of shape `[num_channels, num_components]` so $\mathbb{E}[\mathbf{z} \mid \mathbf{x}] = \mathbf{P}^\top (\mathbf{x} - \boldsymbol{\mu})$.
26    pub posterior_projection: Vec<f32>,
27    /// Isotropic observation noise variance $\sigma^2$.
28    pub noise_variance: f32,
29    /// Top-$q$ eigenvalues of the sample covariance matrix.
30    pub eigenvalues: Vec<f32>,
31}
32
33impl PpcaModel {
34    /// Fits PPCA in closed form (maximum likelihood from the covariance eigendecomposition) on host
35    /// data `[channels, samples]`; covariance and eigendecomposition on the device in `F`.
36    pub fn fit<F: DspFloat>(client: &Client, data: &[f32], channels: usize, samples: usize, num_components: usize) -> Self {
37        assert!(channels > 0 && samples > 0, "Channels and samples must be > 0");
38        let num_components = num_components.clamp(1, channels);
39        let (cov, mean) = covariance_of_host::<F>(client, data, channels, samples);
40        let eig = symmetric_eigen::<F>(client, &cov, channels, EigenOptions::default());
41
42        // Tipping & Bishop (1999) Eq. 8: sigma_ML^2 is the average of the discarded eigenvalues
43        let noise_variance = if num_components < channels {
44            let tail: f64 = eig.values[num_components..].iter().map(|&v| v.max(0.0)).sum();
45            (tail / (channels - num_components) as f64).max(MIN_NOISE_VARIANCE)
46        } else {
47            FULL_RANK_NOISE_VARIANCE
48        };
49
50        let mut weights = vec![0.0f32; channels * num_components];
51        let mut posterior_projection = vec![0.0f32; channels * num_components];
52        let mut eigenvalues = Vec::with_capacity(num_components);
53        for k in 0..num_components {
54            let lam = eig.values[k].max(noise_variance + MIN_NOISE_VARIANCE);
55            eigenvalues.push(lam as f32);
56            let scale_w = (lam - noise_variance).max(0.0).sqrt();
57            // W^T W + sigma^2 I = diag(lam_1, ..., lam_q), so P = W M^-1 scales column k by scale_w / lam
58            let scale_p = scale_w / lam;
59            for c in 0..channels {
60                let u_ck = eig.vectors[c * channels + k];
61                weights[c * num_components + k] = (u_ck * scale_w) as f32;
62                posterior_projection[c * num_components + k] = (u_ck * scale_p) as f32;
63            }
64        }
65
66        Self {
67            num_channels: channels,
68            num_components,
69            mean: mean.iter().map(|&m| m as f32).collect(),
70            weights,
71            posterior_projection,
72            noise_variance: noise_variance as f32,
73            eigenvalues,
74        }
75    }
76
77    /// Fits PPCA via Expectation-Maximization (EM) with an observation mask (`[channels, samples]`,
78    /// `true` = observed, `false` = missing/outside local neighborhood).
79    pub fn fit_em_masked<F: DspFloat>(
80        client: &Client,
81        data: &[f32],
82        observed_mask: &[bool],
83        channels: usize,
84        samples: usize,
85        mut num_components: usize,
86        max_iters: usize,
87    ) -> Self {
88        assert_eq!(data.len(), channels * samples);
89        assert_eq!(observed_mask.len(), channels * samples);
90        num_components = num_components.clamp(1, channels);
91
92        // Compute observed per-channel means and impute initial matrix for warm-start
93        let mut mean = vec![0.0f32; channels];
94        for c in 0..channels {
95            let mut sum = 0.0f32;
96            let mut count = 0usize;
97            for t in 0..samples {
98                if observed_mask[c * samples + t] {
99                    sum += data[c * samples + t];
100                    count += 1;
101                }
102            }
103            mean[c] = if count > 0 { sum / (count as f32) } else { 0.0 };
104        }
105
106        let mut imputed = data.to_vec();
107        for c in 0..channels {
108            for t in 0..samples {
109                if !observed_mask[c * samples + t] {
110                    imputed[c * samples + t] = mean[c];
111                }
112            }
113        }
114
115        let mut model = Self::fit::<F>(client, &imputed, channels, samples, num_components);
116        for _iter in 0..max_iters.max(1) {
117            // E-step: project current imputed data to posterior latent expectations Z = E[z | x]
118            let z = model.project_cpu(&imputed, channels, samples);
119            // Reconstruct missing entries x_hat = W * z + mu
120            let recon = model.reconstruct_cpu(&z, samples);
121            for c in 0..channels {
122                for t in 0..samples {
123                    let idx = c * samples + t;
124                    if !observed_mask[idx] {
125                        imputed[idx] = recon[idx];
126                    }
127                }
128            }
129            // M-step: re-estimate closed-form subspace on completed sufficient statistics
130            model = Self::fit::<F>(client, &imputed, channels, samples, num_components);
131        }
132        model
133    }
134
135    /// Computes posterior latent expectations $\mathbb{E}[\mathbf{z} \mid \mathbf{x}] = \mathbf{M}^{-1}\mathbf{W}^\top(\mathbf{x} - \boldsymbol{\mu})$
136    /// of shape `[num_components, samples]` on the CPU.
137    pub fn project_cpu(&self, data: &[f32], channels: usize, samples: usize) -> Vec<f32> {
138        assert_eq!(channels, self.num_channels);
139        assert_eq!(data.len(), channels * samples);
140        let mut out = vec![0.0f32; self.num_components * samples];
141
142        for k in 0..self.num_components {
143            let out_row = &mut out[k * samples..(k + 1) * samples];
144            for c in 0..channels {
145                let p_ck = self.posterior_projection[c * self.num_components + k];
146                let mean_c = self.mean[c];
147                let in_row = &data[c * samples..(c + 1) * samples];
148                for t in 0..samples {
149                    out_row[t] += (in_row[t] - mean_c) * p_ck;
150                }
151            }
152        }
153        out
154    }
155
156    /// Reconstructs observations $\hat{\mathbf{x}} = \mathbf{W}\mathbf{z} + \boldsymbol{\mu}$ (`[channels, samples]`) from latent codes `z` (`[num_components, samples]`).
157    pub fn reconstruct_cpu(&self, z: &[f32], samples: usize) -> Vec<f32> {
158        assert_eq!(z.len(), self.num_components * samples);
159        let mut recon = vec![0.0f32; self.num_channels * samples];
160
161        for c in 0..self.num_channels {
162            let out_row = &mut recon[c * samples..(c + 1) * samples];
163            out_row.fill(self.mean[c]);
164            for k in 0..self.num_components {
165                let w_ck = self.weights[c * self.num_components + k];
166                let z_row = &z[k * samples..(k + 1) * samples];
167                for t in 0..samples {
168                    out_row[t] += w_ck * z_row[t];
169                }
170            }
171        }
172        recon
173    }
174
175    /// The projection with its weights uploaded once as `F`, for repeated calls.
176    pub fn to_device<F: DspFloat>(&self, client: &Client) -> DeviceProjection {
177        DeviceProjection::upload::<F>(client, &self.posterior_projection, &self.mean, self.num_channels, self.num_components)
178    }
179
180    /// One-off device projection of `[channels, samples]` into `[num_components, samples]` (uploads
181    /// the weights; use [`Self::to_device`] for repeated calls).
182    pub fn project_gpu<F: DspFloat>(
183        &self,
184        client: &Client,
185        input_handle: &cubecl::server::Handle,
186        output_handle: &cubecl::server::Handle,
187        channels: usize,
188        samples: usize,
189    ) {
190        assert_eq!(channels, self.num_channels);
191        self.to_device::<F>(client).project::<F>(client, input_handle, output_handle, samples);
192    }
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198
199    fn recovers(client: &Client) {
200        let channels = 5;
201        let samples = 3000;
202        let true_sigma2 = 0.25f32; // sigma = 0.5
203        let true_sigma = true_sigma2.sqrt();
204
205        let mut state = 0xCAFE_BABEu64;
206        let mut next_norm = || -> f32 {
207            state = state.wrapping_mul(6364136223846793005).wrapping_add(1);
208            let u1 = ((state >> 32) as f32 / (u32::MAX as f32)).clamp(1e-6, 1.0 - 1e-6);
209            state = state.wrapping_mul(6364136223846793005).wrapping_add(1);
210            let u2 = (state >> 32) as f32 / (u32::MAX as f32);
211            (-2.0 * u1.ln()).sqrt() * (2.0 * std::f32::consts::PI * u2).cos()
212        };
213
214        // 1D latent factor z ~ N(0, 1) with loading w = [3, -2, 1, 0, 0]^T + isotropic noise
215        let w_true = [3.0f32, -2.0, 1.0, 0.0, 0.0];
216        let mut data = vec![0.0f32; channels * samples];
217        for t in 0..samples {
218            let z = next_norm();
219            for c in 0..channels {
220                data[c * samples + t] = w_true[c] * z + true_sigma * next_norm();
221            }
222        }
223
224        let ppca = PpcaModel::fit::<f32>(client, &data, channels, samples, 1);
225        assert!((ppca.noise_variance - true_sigma2).abs() < 0.05, "noise_var={}", ppca.noise_variance);
226
227        // Norm of W_ML should be close to ||w_true|| = sqrt(9 + 4 + 1) = sqrt(14) = 3.7417
228        let w_norm_sq: f32 = ppca.weights.iter().map(|&v| v * v).sum();
229        assert!((w_norm_sq - 14.0).abs() < 1.0, "w_norm_sq={w_norm_sq}");
230    }
231    runtime_test!(test_ppca_recovers_subspace_and_isotropic_noise_variance, recovers);
232}