Skip to main content

fdars_core/alignment/
generative.rs

1//! Gaussian generative model for random curve synthesis from aligned data.
2
3use super::srsf::{reparameterize_curve, srsf_inverse};
4use super::KarcherMeanResult;
5use crate::elastic_fpca::{horiz_fpca, sphere_karcher_mean, vert_fpca, warps_to_normalized_psi};
6use crate::error::FdarError;
7use crate::matrix::FdMatrix;
8use crate::warping::{exp_map_sphere, normalize_warp, psi_to_gam};
9
10use rand::prelude::*;
11use rand_distr::StandardNormal;
12
13// ─── Types ──────────────────────────────────────────────────────────────────
14
15/// Result of Gaussian generative model sampling.
16#[derive(Debug, Clone, PartialEq)]
17#[non_exhaustive]
18pub struct GenerativeModelResult {
19    /// Generated function samples (n_samples x m).
20    pub samples: FdMatrix,
21    /// Generated warping functions (n_samples x m).
22    pub warps: FdMatrix,
23    /// FPCA scores used for generation (n_samples x ncomp).
24    pub scores: FdMatrix,
25}
26
27// ─── Gaussian Generative Model ──────────────────────────────────────────────
28
29/// Generate random curves from a fitted Gaussian model on aligned data.
30///
31/// Samples amplitude and phase components independently from their
32/// respective FPCA score distributions (Gaussian with covariance = diag(eigenvalues)),
33/// then combines them to produce synthetic functional data.
34///
35/// # Arguments
36/// * `karcher` — Pre-computed Karcher mean result (with aligned data and gammas)
37/// * `argvals` — Evaluation points (length m)
38/// * `ncomp` — Number of principal components for both amplitude and phase
39/// * `n_samples` — Number of curves to generate
40/// * `seed` — RNG seed for reproducibility
41///
42/// # Errors
43/// Returns `FdarError::InvalidDimension` if dimensions are inconsistent or
44/// `FdarError::ComputationFailed` if FPCA fails.
45#[must_use = "expensive computation whose result should not be discarded"]
46pub fn gauss_model(
47    karcher: &KarcherMeanResult,
48    argvals: &[f64],
49    ncomp: usize,
50    n_samples: usize,
51    seed: u64,
52) -> Result<GenerativeModelResult, FdarError> {
53    let (n, m) = karcher.aligned_data.shape();
54    if argvals.len() != m {
55        return Err(FdarError::InvalidDimension {
56            parameter: "argvals",
57            expected: format!("length {m}"),
58            actual: format!("length {}", argvals.len()),
59        });
60    }
61    if n < 2 || m < 2 {
62        return Err(FdarError::InvalidDimension {
63            parameter: "aligned_data",
64            expected: "n >= 2, m >= 2".to_string(),
65            actual: format!("n={n}, m={m}"),
66        });
67    }
68    if ncomp < 1 {
69        return Err(FdarError::InvalidParameter {
70            parameter: "ncomp",
71            message: "ncomp must be >= 1".to_string(),
72        });
73    }
74    if n_samples < 1 {
75        return Err(FdarError::InvalidParameter {
76            parameter: "n_samples",
77            message: "n_samples must be >= 1".to_string(),
78        });
79    }
80
81    // Amplitude FPCA
82    let vert = vert_fpca(karcher, argvals, ncomp)?;
83    let vert_ncomp = vert.eigenvalues.len();
84    let m_aug = m + 1;
85
86    // Phase FPCA
87    let horiz = horiz_fpca(karcher, argvals, ncomp)?;
88    let horiz_ncomp = horiz.eigenvalues.len();
89
90    let t0 = argvals[0];
91    let t1 = argvals[m - 1];
92    let domain = t1 - t0;
93    let time: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
94
95    // Get the mean psi on the sphere for phase generation
96    let psis = warps_to_normalized_psi(&karcher.gammas, argvals);
97    let mu_psi = sphere_karcher_mean(&psis, &time, 50);
98
99    // Mean SRSF (augmented)
100    let mean_q = &vert.mean_q;
101
102    let total_ncomp = vert_ncomp + horiz_ncomp;
103    let mut samples = FdMatrix::zeros(n_samples, m);
104    let mut warps = FdMatrix::zeros(n_samples, m);
105    let mut scores = FdMatrix::zeros(n_samples, total_ncomp);
106
107    for i in 0..n_samples {
108        let mut rng = StdRng::seed_from_u64(seed + i as u64);
109
110        // Generate amplitude scores and reconstruct SRSF
111        let mut q_new = vec![0.0; m_aug];
112        q_new[..m_aug].copy_from_slice(&mean_q[..m_aug]);
113        for k in 0..vert_ncomp {
114            let std_dev = vert.eigenvalues[k].max(0.0).sqrt();
115            let z: f64 = rng.sample(StandardNormal);
116            let score_k = z * std_dev;
117            scores[(i, k)] = score_k;
118            for j in 0..m_aug {
119                q_new[j] += score_k * vert.eigenfunctions_q[(k, j)];
120            }
121        }
122
123        // Reconstruct curve from SRSF. The augmented coordinate encodes the
124        // curve level at the domain MIDPOINT (see `build_augmented_srsfs`), not
125        // at `argvals[0]`. Reconstruct from a zero start and shift so the
126        // midpoint matches; passing the level as `srsf_inverse`'s `f0` (the
127        // value at the start) offsets the whole curve by a constant (GH #34).
128        let aug_val = q_new[m];
129        let f_mid = aug_val.signum() * aug_val * aug_val;
130        let mut f_new = srsf_inverse(&q_new[..m], argvals, 0.0);
131        let shift = f_mid - f_new[m / 2];
132        for val in f_new.iter_mut() {
133            *val += shift;
134        }
135
136        // Generate phase scores and reconstruct warping function
137        let mut v = vec![0.0; m];
138        for k in 0..horiz_ncomp {
139            let std_dev = horiz.eigenvalues[k].max(0.0).sqrt();
140            let z: f64 = rng.sample(StandardNormal);
141            let score_k = z * std_dev;
142            scores[(i, vert_ncomp + k)] = score_k;
143            for j in 0..m {
144                v[j] += score_k * horiz.eigenfunctions_psi[(k, j)];
145            }
146        }
147
148        // Map shooting vector to sphere via exp map at mean psi
149        let psi_new = exp_map_sphere(&mu_psi, &v, &time);
150        let gam_01 = psi_to_gam(&psi_new, &time);
151
152        // Rescale gamma to original domain
153        let mut gamma: Vec<f64> = gam_01.iter().map(|&g| t0 + g * domain).collect();
154        normalize_warp(&mut gamma, argvals);
155
156        // Apply warp to generate final sample
157        let sample = reparameterize_curve(&f_new, argvals, &gamma);
158
159        for j in 0..m {
160            samples[(i, j)] = sample[j];
161            warps[(i, j)] = gamma[j];
162        }
163    }
164
165    Ok(GenerativeModelResult {
166        samples,
167        warps,
168        scores,
169    })
170}
171
172/// Generate random curves from a joint Gaussian model preserving amplitude-phase
173/// correlation.
174///
175/// Computes amplitude and phase FPCA separately, concatenates their scores to
176/// form a joint score vector, estimates the joint covariance, and samples from
177/// the joint distribution. This preserves cross-correlation between amplitude
178/// and phase variability.
179///
180/// # Arguments
181/// * `karcher` — Pre-computed Karcher mean result
182/// * `argvals` — Evaluation points (length m)
183/// * `ncomp` — Number of principal components per domain (amplitude and phase)
184/// * `n_samples` — Number of curves to generate
185/// * `balance_c` — Weight for balancing phase vs amplitude variance
186/// * `seed` — RNG seed for reproducibility
187///
188/// # Errors
189/// Returns `FdarError` on dimension mismatch or FPCA failure.
190#[must_use = "expensive computation whose result should not be discarded"]
191pub fn joint_gauss_model(
192    karcher: &KarcherMeanResult,
193    argvals: &[f64],
194    ncomp: usize,
195    n_samples: usize,
196    balance_c: f64,
197    seed: u64,
198) -> Result<GenerativeModelResult, FdarError> {
199    let (_n, m) = karcher.aligned_data.shape();
200    if argvals.len() != m {
201        return Err(FdarError::InvalidDimension {
202            parameter: "argvals",
203            expected: format!("length {m}"),
204            actual: format!("length {}", argvals.len()),
205        });
206    }
207    if ncomp < 1 {
208        return Err(FdarError::InvalidParameter {
209            parameter: "ncomp",
210            message: "ncomp must be >= 1".to_string(),
211        });
212    }
213    if n_samples < 1 {
214        return Err(FdarError::InvalidParameter {
215            parameter: "n_samples",
216            message: "n_samples must be >= 1".to_string(),
217        });
218    }
219
220    // Amplitude FPCA
221    let vert = vert_fpca(karcher, argvals, ncomp)?;
222    let vert_ncomp = vert.eigenvalues.len();
223    let m_aug = m + 1;
224
225    // Phase FPCA
226    let horiz = horiz_fpca(karcher, argvals, ncomp)?;
227    let horiz_ncomp = horiz.eigenvalues.len();
228
229    let total_ncomp = vert_ncomp + horiz_ncomp;
230    let n = karcher.aligned_data.nrows();
231
232    // Build joint score matrix: [vert_scores | balance_c * horiz_scores]
233    let mut joint_scores = FdMatrix::zeros(n, total_ncomp);
234    for i in 0..n {
235        for k in 0..vert_ncomp {
236            joint_scores[(i, k)] = vert.scores[(i, k)];
237        }
238        for k in 0..horiz_ncomp {
239            joint_scores[(i, vert_ncomp + k)] = balance_c * horiz.scores[(i, k)];
240        }
241    }
242
243    // Estimate joint covariance (diagonal for sampling)
244    let mut joint_mean = vec![0.0; total_ncomp];
245    for k in 0..total_ncomp {
246        for i in 0..n {
247            joint_mean[k] += joint_scores[(i, k)];
248        }
249        joint_mean[k] /= n as f64;
250    }
251
252    let mut joint_var = vec![0.0; total_ncomp];
253    for k in 0..total_ncomp {
254        for i in 0..n {
255            let diff = joint_scores[(i, k)] - joint_mean[k];
256            joint_var[k] += diff * diff;
257        }
258        joint_var[k] /= (n - 1).max(1) as f64;
259    }
260
261    // Sphere/warping setup
262    let t0 = argvals[0];
263    let t1 = argvals[m - 1];
264    let domain = t1 - t0;
265    let time: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
266
267    let psis = warps_to_normalized_psi(&karcher.gammas, argvals);
268    let mu_psi = sphere_karcher_mean(&psis, &time, 50);
269    let mean_q = &vert.mean_q;
270
271    let mut samples = FdMatrix::zeros(n_samples, m);
272    let mut warps_out = FdMatrix::zeros(n_samples, m);
273    let mut scores_out = FdMatrix::zeros(n_samples, total_ncomp);
274
275    for i in 0..n_samples {
276        let mut rng = StdRng::seed_from_u64(seed + i as u64);
277
278        // Sample from joint distribution
279        let mut joint_z = vec![0.0; total_ncomp];
280        for k in 0..total_ncomp {
281            let z: f64 = rng.sample(StandardNormal);
282            joint_z[k] = joint_mean[k] + z * joint_var[k].max(0.0).sqrt();
283            scores_out[(i, k)] = joint_z[k];
284        }
285
286        // Reconstruct amplitude from SRSF
287        let mut q_new = vec![0.0; m_aug];
288        q_new[..m_aug].copy_from_slice(&mean_q[..m_aug]);
289        for k in 0..vert_ncomp {
290            let score_k = joint_z[k];
291            for j in 0..m_aug {
292                q_new[j] += score_k * vert.eigenfunctions_q[(k, j)];
293            }
294        }
295        // Anchor the reconstruction at the midpoint level (see `gauss_model`
296        // and GH #34): the augmented coordinate is the curve value at m/2, not
297        // at argvals[0], so shift rather than pass it as srsf_inverse's f0.
298        let aug_val = q_new[m];
299        let f_mid = aug_val.signum() * aug_val * aug_val;
300        let mut f_new = srsf_inverse(&q_new[..m], argvals, 0.0);
301        let shift = f_mid - f_new[m / 2];
302        for val in f_new.iter_mut() {
303            *val += shift;
304        }
305
306        // Reconstruct phase from shooting vector
307        let mut v = vec![0.0; m];
308        for k in 0..horiz_ncomp {
309            // Undo balance_c scaling
310            let score_k = if balance_c.abs() > 1e-15 {
311                joint_z[vert_ncomp + k] / balance_c
312            } else {
313                0.0
314            };
315            for j in 0..m {
316                v[j] += score_k * horiz.eigenfunctions_psi[(k, j)];
317            }
318        }
319
320        let psi_new = exp_map_sphere(&mu_psi, &v, &time);
321        let gam_01 = psi_to_gam(&psi_new, &time);
322        let mut gamma: Vec<f64> = gam_01.iter().map(|&g| t0 + g * domain).collect();
323        normalize_warp(&mut gamma, argvals);
324
325        let sample = reparameterize_curve(&f_new, argvals, &gamma);
326        for j in 0..m {
327            samples[(i, j)] = sample[j];
328            warps_out[(i, j)] = gamma[j];
329        }
330    }
331
332    Ok(GenerativeModelResult {
333        samples,
334        warps: warps_out,
335        scores: scores_out,
336    })
337}
338
339// ─── Helper ─────────────────────────────────────────────────────────────────
340
341#[cfg(test)]
342mod tests {
343    use super::*;
344    use crate::alignment::karcher_mean;
345    use std::f64::consts::PI;
346
347    fn make_test_karcher(n: usize, m: usize) -> (KarcherMeanResult, Vec<f64>) {
348        let t: Vec<f64> = (0..m).map(|j| j as f64 / (m - 1) as f64).collect();
349        let mut data = FdMatrix::zeros(n, m);
350        for i in 0..n {
351            let shift = 0.1 * (i as f64 - n as f64 / 2.0);
352            let scale = 1.0 + 0.2 * (i as f64 / n as f64);
353            for j in 0..m {
354                data[(i, j)] = scale * (2.0 * PI * (t[j] + shift)).sin();
355            }
356        }
357        let km = karcher_mean(&data, &t, 10, 1e-4, 0.0);
358        (km, t)
359    }
360
361    #[test]
362    fn gauss_model_correct_shapes() {
363        let (km, t) = make_test_karcher(15, 51);
364        let ncomp = 3;
365        let n_samples = 10;
366        let result = gauss_model(&km, &t, ncomp, n_samples, 42).unwrap();
367
368        assert_eq!(result.samples.shape(), (n_samples, 51));
369        assert_eq!(result.warps.shape(), (n_samples, 51));
370        // scores is n_samples x (vert_ncomp + horiz_ncomp)
371        let (_, score_cols) = result.scores.shape();
372        assert!(
373            score_cols >= ncomp,
374            "scores should have at least ncomp columns, got {score_cols}"
375        );
376        assert_eq!(result.scores.nrows(), n_samples);
377    }
378
379    #[test]
380    fn gauss_model_reproducible() {
381        let (km, t) = make_test_karcher(15, 51);
382        let r1 = gauss_model(&km, &t, 3, 5, 42).unwrap();
383        let r2 = gauss_model(&km, &t, 3, 5, 42).unwrap();
384
385        assert_eq!(r1.samples, r2.samples);
386        assert_eq!(r1.warps, r2.warps);
387        assert_eq!(r1.scores, r2.scores);
388    }
389
390    #[test]
391    fn gauss_model_warps_valid() {
392        let (km, t) = make_test_karcher(15, 51);
393        let result = gauss_model(&km, &t, 3, 10, 99).unwrap();
394        let m = t.len();
395
396        for i in 0..result.warps.nrows() {
397            let warp = result.warps.row(i);
398
399            // Monotone non-decreasing
400            for j in 1..m {
401                assert!(
402                    warp[j] >= warp[j - 1] - 1e-12,
403                    "warp {i} not monotone at j={j}: {} < {}",
404                    warp[j],
405                    warp[j - 1]
406                );
407            }
408
409            // Correct boundary values
410            assert!(
411                (warp[0] - t[0]).abs() < 1e-10,
412                "warp {i} start: {} != {}",
413                warp[0],
414                t[0]
415            );
416            assert!(
417                (warp[m - 1] - t[m - 1]).abs() < 1e-10,
418                "warp {i} end: {} != {}",
419                warp[m - 1],
420                t[m - 1]
421            );
422        }
423    }
424
425    /// Regression test for GH #34: generated samples must reproduce the data
426    /// mean, not sit a constant offset above it (previously ~+1 for curves whose
427    /// midpoint level was ~1 because the midpoint-anchored augmented level was
428    /// wrongly passed as `srsf_inverse`'s start value).
429    #[test]
430    fn gauss_model_sample_mean_no_constant_offset() {
431        // Single-bump curves on a zero baseline (the issue's repro shape).
432        let m = 60;
433        let t: Vec<f64> = (0..m).map(|j| j as f64 / (m - 1) as f64).collect();
434        let n = 20;
435        let mut data = FdMatrix::zeros(n, m);
436        for i in 0..n {
437            let amp = 0.8 + 0.02 * i as f64;
438            for j in 0..m {
439                let x = t[j] - 0.5;
440                data[(i, j)] = amp * (-(x * x) / 0.02).exp();
441            }
442        }
443        let km = karcher_mean(&data, &t, 15, 1e-4, 0.0);
444        let result = gauss_model(&km, &t, 3, 300, 42).unwrap();
445        let samples = &result.samples;
446        let ns = samples.nrows();
447
448        // Average pointwise offset between sample mean and data mean must be
449        // near zero (the bug produced a uniform shift of ~+1.0).
450        let mut offset = 0.0;
451        for j in 0..m {
452            let dm = (0..n).map(|i| data[(i, j)]).sum::<f64>() / n as f64;
453            let sm = (0..ns).map(|i| samples[(i, j)]).sum::<f64>() / ns as f64;
454            offset += sm - dm;
455        }
456        offset /= m as f64;
457        assert!(
458            offset.abs() < 0.3,
459            "sample mean has a constant offset of {offset:.3} from the data mean (GH #34)"
460        );
461    }
462
463    #[test]
464    fn joint_gauss_model_smoke() {
465        let (km, t) = make_test_karcher(15, 51);
466        let ncomp = 3;
467        let n_samples = 8;
468        let result = joint_gauss_model(&km, &t, ncomp, n_samples, 1.0, 42).unwrap();
469
470        assert_eq!(result.samples.shape(), (n_samples, 51));
471        assert_eq!(result.warps.shape(), (n_samples, 51));
472        assert_eq!(result.scores.nrows(), n_samples);
473
474        // All samples should be finite
475        for i in 0..n_samples {
476            for j in 0..51 {
477                assert!(
478                    result.samples[(i, j)].is_finite(),
479                    "sample ({i},{j}) is not finite"
480                );
481            }
482        }
483    }
484}