Skip to main content

fdars_core/alignment/
bayesian.rs

1//! Bayesian pairwise alignment via pCN MCMC on the Hilbert sphere.
2
3use super::dp_alignment_core;
4use super::srsf::{reparameterize_curve, srsf_single};
5use crate::error::FdarError;
6use crate::helpers::simpsons_weights;
7use crate::matrix::FdMatrix;
8use crate::warping::{
9    exp_map_sphere, gam_to_psi, inner_product_l2, inv_exp_map_sphere, l2_norm_l2, normalize_warp,
10    psi_to_gam,
11};
12
13use rand::prelude::*;
14use rand_distr::StandardNormal;
15
16// ─── Config / Result ─────────────────────────────────────────────────────────
17
18/// Configuration for Bayesian pairwise alignment.
19///
20/// Construct via `BayesianAlignConfig::default()`, then assign the fields you need (e.g. `let mut c = BayesianAlignConfig::default(); c.field = …;`). This struct is `#[non_exhaustive]`, so external crates cannot build it with a struct literal — not even functional-update `..Default::default()` form.
21#[non_exhaustive]
22#[derive(Debug, Clone, PartialEq)]
23pub struct BayesianAlignConfig {
24    /// Number of posterior samples to retain (after burn-in).
25    pub n_samples: usize,
26    /// Number of burn-in iterations to discard.
27    pub burn_in: usize,
28    /// pCN step size beta in (0, 1).
29    pub step_size: f64,
30    /// Variance scaling for random tangent-vector proposals.
31    pub proposal_variance: f64,
32    /// RNG seed for reproducibility.
33    pub seed: u64,
34}
35
36impl Default for BayesianAlignConfig {
37    fn default() -> Self {
38        Self {
39            n_samples: 1000,
40            burn_in: 200,
41            step_size: 0.1,
42            proposal_variance: 1.0,
43            seed: 42,
44        }
45    }
46}
47
48/// Result of Bayesian pairwise alignment.
49#[derive(Debug, Clone, PartialEq)]
50#[non_exhaustive]
51pub struct BayesianAlignmentResult {
52    /// Posterior warping function samples (n_samples x m), after burn-in.
53    pub posterior_gammas: FdMatrix,
54    /// Pointwise posterior mean warping function (length m).
55    pub posterior_mean_gamma: Vec<f64>,
56    /// Pointwise 2.5% credible band (length m).
57    pub credible_lower: Vec<f64>,
58    /// Pointwise 97.5% credible band (length m).
59    pub credible_upper: Vec<f64>,
60    /// MCMC acceptance rate.
61    pub acceptance_rate: f64,
62    /// f2 aligned to f1 using the posterior mean warping function.
63    pub f_aligned_mean: Vec<f64>,
64}
65
66// ─── Bayesian Alignment ─────────────────────────────────────────────────────
67
68/// Compute the SRSF-based log-likelihood for a warping function.
69///
70/// `log_lik = -0.5 * sum_j(w[j] * (q1[j] - q2_gamma[j])^2)`
71/// where q2_gamma is the SRSF of f2 composed with gamma (with sqrt(gamma') factor).
72fn log_likelihood(q1: &[f64], q2: &[f64], argvals: &[f64], gamma: &[f64], weights: &[f64]) -> f64 {
73    let m = q1.len();
74    let q2_warped = reparameterize_curve(q2, argvals, gamma);
75
76    // Compute gamma' via finite differences
77    let mut gamma_dot = vec![0.0; m];
78    gamma_dot[0] = (gamma[1] - gamma[0]) / (argvals[1] - argvals[0]);
79    for j in 1..(m - 1) {
80        gamma_dot[j] = (gamma[j + 1] - gamma[j - 1]) / (argvals[j + 1] - argvals[j - 1]);
81    }
82    gamma_dot[m - 1] = (gamma[m - 1] - gamma[m - 2]) / (argvals[m - 1] - argvals[m - 2]);
83
84    let mut ll = 0.0;
85    for j in 0..m {
86        let q2g = q2_warped[j] * gamma_dot[j].max(0.0).sqrt();
87        let diff = q1[j] - q2g;
88        ll -= 0.5 * weights[j] * diff * diff;
89    }
90    ll
91}
92
93/// Project a vector onto the tangent plane at a point on the sphere.
94///
95/// Removes the component along `psi_base`: `v - <v, psi_base> * psi_base`
96fn project_to_tangent(v: &[f64], psi_base: &[f64], time: &[f64]) -> Vec<f64> {
97    let ip = inner_product_l2(v, psi_base, time);
98    v.iter()
99        .zip(psi_base.iter())
100        .map(|(&vi, &pi)| vi - ip * pi)
101        .collect()
102}
103
104/// Perform Bayesian pairwise alignment of f2 to f1 via pCN MCMC on the
105/// Hilbert sphere.
106///
107/// Uses a preconditioned Crank-Nicolson (pCN) proposal in the tangent space
108/// of the identity warping function on the Hilbert sphere. The DP-optimal
109/// alignment serves as initialization.
110///
111/// # Arguments
112/// * `f1` — Target curve (length m)
113/// * `f2` — Curve to align (length m)
114/// * `argvals` — Evaluation points (length m)
115/// * `config` — MCMC configuration
116///
117/// # Errors
118/// Returns `FdarError::InvalidDimension` if lengths don't match or m < 2.
119/// Returns `FdarError::InvalidParameter` if config values are out of range.
120#[must_use = "expensive computation whose result should not be discarded"]
121pub fn bayesian_align_pair(
122    f1: &[f64],
123    f2: &[f64],
124    argvals: &[f64],
125    config: &BayesianAlignConfig,
126) -> Result<BayesianAlignmentResult, FdarError> {
127    let m = f1.len();
128
129    // ── Validation ──────────────────────────────────────────────────────
130    if m != f2.len() || m != argvals.len() {
131        return Err(FdarError::InvalidDimension {
132            parameter: "f1/f2/argvals",
133            expected: format!("all length {m}"),
134            actual: format!("f1={}, f2={}, argvals={}", m, f2.len(), argvals.len()),
135        });
136    }
137    if m < 2 {
138        return Err(FdarError::InvalidDimension {
139            parameter: "f1",
140            expected: "length >= 2".to_string(),
141            actual: format!("length {m}"),
142        });
143    }
144    if config.n_samples == 0 {
145        return Err(FdarError::InvalidParameter {
146            parameter: "n_samples",
147            message: "n_samples must be > 0".to_string(),
148        });
149    }
150    if config.step_size <= 0.0 || config.step_size >= 1.0 {
151        return Err(FdarError::InvalidParameter {
152            parameter: "step_size",
153            message: format!("step_size must be in (0, 1), got {}", config.step_size),
154        });
155    }
156
157    let t0 = argvals[0];
158    let t1 = argvals[m - 1];
159    let domain = t1 - t0;
160    let time: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
161    let binsize = 1.0 / (m - 1) as f64;
162
163    // Compute SRSFs
164    let q1 = srsf_single(f1, argvals);
165    let q2 = srsf_single(f2, argvals);
166
167    // Simpson's weights for log-likelihood
168    let weights = simpsons_weights(argvals);
169
170    // Identity warp psi on sphere: constant 1, normalized
171    let psi_id: Vec<f64> = {
172        let raw = vec![1.0; m];
173        let norm = l2_norm_l2(&raw, &time);
174        raw.iter().map(|&v| v / norm).collect()
175    };
176
177    // DP initialization
178    let gamma_dp = dp_alignment_core(&q1, &q2, argvals, 0.0);
179    let gam_01: Vec<f64> = gamma_dp.iter().map(|&g| (g - t0) / domain).collect();
180    let mut psi_curr = gam_to_psi(&gam_01, binsize);
181    let psi_norm = l2_norm_l2(&psi_curr, &time);
182    if psi_norm > 1e-10 {
183        for v in &mut psi_curr {
184            *v /= psi_norm;
185        }
186    }
187
188    // Current tangent vector and log-likelihood
189    let mut v_curr = inv_exp_map_sphere(&psi_id, &psi_curr, &time);
190    let mut ll_curr = log_likelihood(&q1, &q2, argvals, &gamma_dp, &weights);
191
192    let beta = config.step_size;
193    let sqrt_1_beta2 = (1.0 - beta * beta).sqrt();
194    let total_iter = config.n_samples + config.burn_in;
195
196    let mut rng = StdRng::seed_from_u64(config.seed);
197    let mut stored_gammas: Vec<Vec<f64>> = Vec::with_capacity(config.n_samples);
198    let mut n_accepted = 0usize;
199
200    for iter in 0..total_iter {
201        // Generate random tangent vector at identity
202        let xi_raw: Vec<f64> = (0..m)
203            .map(|_| rng.sample::<f64, _>(StandardNormal))
204            .collect();
205        let xi_tangent = project_to_tangent(&xi_raw, &psi_id, &time);
206        let xi_scaled: Vec<f64> = xi_tangent
207            .iter()
208            .map(|&v| v * config.proposal_variance.sqrt())
209            .collect();
210
211        // pCN proposal: v_prop = sqrt(1 - beta^2) * v_curr + beta * xi
212        let v_prop: Vec<f64> = v_curr
213            .iter()
214            .zip(xi_scaled.iter())
215            .map(|(&vc, &xi)| sqrt_1_beta2 * vc + beta * xi)
216            .collect();
217
218        // Map to sphere
219        let psi_prop = exp_map_sphere(&psi_id, &v_prop, &time);
220
221        // Convert to gamma
222        let gam_prop_01 = psi_to_gam(&psi_prop, &time);
223        let mut gamma_prop: Vec<f64> = gam_prop_01.iter().map(|&g| t0 + g * domain).collect();
224        normalize_warp(&mut gamma_prop, argvals);
225
226        // Log-likelihood of proposal
227        let ll_prop = log_likelihood(&q1, &q2, argvals, &gamma_prop, &weights);
228
229        // Accept/reject
230        let log_alpha = ll_prop - ll_curr;
231        let u: f64 = rng.gen();
232        if u.ln() < log_alpha {
233            psi_curr = psi_prop;
234            v_curr = v_prop;
235            ll_curr = ll_prop;
236            n_accepted += 1;
237
238            if iter >= config.burn_in {
239                stored_gammas.push(gamma_prop);
240            }
241        } else if iter >= config.burn_in {
242            // Store current (rejected proposal keeps previous)
243            let gam_curr_01 = psi_to_gam(&psi_curr, &time);
244            let mut gamma_curr: Vec<f64> = gam_curr_01.iter().map(|&g| t0 + g * domain).collect();
245            normalize_warp(&mut gamma_curr, argvals);
246            stored_gammas.push(gamma_curr);
247        }
248    }
249
250    let n_stored = stored_gammas.len();
251    let acceptance_rate = n_accepted as f64 / total_iter as f64;
252
253    // Build posterior gamma matrix
254    let mut posterior_gammas = FdMatrix::zeros(n_stored, m);
255    for (i, gam) in stored_gammas.iter().enumerate() {
256        for j in 0..m {
257            posterior_gammas[(i, j)] = gam[j];
258        }
259    }
260
261    // Pointwise posterior mean
262    let mut posterior_mean_gamma = vec![0.0; m];
263    for j in 0..m {
264        for i in 0..n_stored {
265            posterior_mean_gamma[j] += posterior_gammas[(i, j)];
266        }
267        posterior_mean_gamma[j] /= n_stored as f64;
268    }
269    normalize_warp(&mut posterior_mean_gamma, argvals);
270
271    // Pointwise credible bands (2.5% and 97.5% quantiles)
272    let mut credible_lower = vec![0.0; m];
273    let mut credible_upper = vec![0.0; m];
274    for j in 0..m {
275        let mut col: Vec<f64> = (0..n_stored).map(|i| posterior_gammas[(i, j)]).collect();
276        col.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
277        let idx_lo = ((0.025 * n_stored as f64).floor() as usize).min(n_stored.saturating_sub(1));
278        let idx_hi = ((0.975 * n_stored as f64).ceil() as usize).min(n_stored.saturating_sub(1));
279        credible_lower[j] = col[idx_lo];
280        credible_upper[j] = col[idx_hi];
281    }
282
283    // Align f2 using posterior mean gamma
284    let f_aligned_mean = reparameterize_curve(f2, argvals, &posterior_mean_gamma);
285
286    Ok(BayesianAlignmentResult {
287        posterior_gammas,
288        posterior_mean_gamma,
289        credible_lower,
290        credible_upper,
291        acceptance_rate,
292        f_aligned_mean,
293    })
294}
295
296#[cfg(test)]
297mod tests {
298    use super::*;
299    use std::f64::consts::PI;
300
301    fn uniform_grid(n: usize) -> Vec<f64> {
302        (0..n).map(|i| i as f64 / (n - 1) as f64).collect()
303    }
304
305    #[test]
306    fn bayesian_align_identical_curves() {
307        let m = 51;
308        let t = uniform_grid(m);
309        let f1: Vec<f64> = t.iter().map(|&ti| (2.0 * PI * ti).sin()).collect();
310        let f2 = f1.clone();
311
312        let config = BayesianAlignConfig {
313            n_samples: 200,
314            burn_in: 50,
315            step_size: 0.1,
316            proposal_variance: 0.5,
317            seed: 42,
318        };
319        let result = bayesian_align_pair(&f1, &f2, &t, &config).unwrap();
320
321        // Posterior mean gamma should be close to identity
322        for j in 0..m {
323            assert!(
324                (result.posterior_mean_gamma[j] - t[j]).abs() < 0.15,
325                "posterior mean gamma at j={j} deviates too much from identity: {} vs {}",
326                result.posterior_mean_gamma[j],
327                t[j]
328            );
329        }
330
331        // Acceptance rate should be reasonable
332        assert!(
333            result.acceptance_rate > 0.05,
334            "acceptance rate too low: {}",
335            result.acceptance_rate
336        );
337    }
338
339    #[test]
340    fn bayesian_align_credible_bands_order() {
341        let m = 51;
342        let t = uniform_grid(m);
343        let f1: Vec<f64> = t.iter().map(|&ti| (2.0 * PI * ti).sin()).collect();
344        let f2: Vec<f64> = t.iter().map(|&ti| (2.0 * PI * (ti + 0.05)).sin()).collect();
345
346        let config = BayesianAlignConfig {
347            n_samples: 200,
348            burn_in: 50,
349            step_size: 0.15,
350            proposal_variance: 0.5,
351            seed: 7,
352        };
353        let result = bayesian_align_pair(&f1, &f2, &t, &config).unwrap();
354
355        for j in 0..m {
356            assert!(
357                result.credible_lower[j] <= result.posterior_mean_gamma[j] + 1e-10,
358                "lower > mean at j={j}: {} > {}",
359                result.credible_lower[j],
360                result.posterior_mean_gamma[j]
361            );
362            assert!(
363                result.posterior_mean_gamma[j] <= result.credible_upper[j] + 1e-10,
364                "mean > upper at j={j}: {} > {}",
365                result.posterior_mean_gamma[j],
366                result.credible_upper[j]
367            );
368        }
369    }
370
371    #[test]
372    fn bayesian_align_shifted_sine() {
373        let m = 51;
374        let t = uniform_grid(m);
375        let f1: Vec<f64> = t.iter().map(|&ti| (2.0 * PI * ti).sin()).collect();
376        let shift = 0.1;
377        let f2: Vec<f64> = t
378            .iter()
379            .map(|&ti| (2.0 * PI * (ti + shift)).sin())
380            .collect();
381
382        let config = BayesianAlignConfig {
383            n_samples: 300,
384            burn_in: 100,
385            step_size: 0.15,
386            proposal_variance: 1.0,
387            seed: 99,
388        };
389        let result = bayesian_align_pair(&f1, &f2, &t, &config).unwrap();
390
391        // The aligned curve should be closer to f1 than the original f2
392        let error_original: f64 = f1
393            .iter()
394            .zip(f2.iter())
395            .map(|(&a, &b)| (a - b).powi(2))
396            .sum::<f64>();
397        let error_aligned: f64 = f1
398            .iter()
399            .zip(result.f_aligned_mean.iter())
400            .map(|(&a, &b)| (a - b).powi(2))
401            .sum::<f64>();
402
403        assert!(
404            error_aligned < error_original + 1e-6,
405            "aligned error ({error_aligned:.4}) should be <= original ({error_original:.4})"
406        );
407    }
408
409    #[test]
410    fn bayesian_align_rejects_bad_config() {
411        let m = 21;
412        let t = uniform_grid(m);
413        let f1: Vec<f64> = t.iter().map(|&ti| ti * ti).collect();
414        let f2 = f1.clone();
415
416        // n_samples = 0
417        let config = BayesianAlignConfig {
418            n_samples: 0,
419            ..BayesianAlignConfig::default()
420        };
421        assert!(
422            bayesian_align_pair(&f1, &f2, &t, &config).is_err(),
423            "should reject n_samples=0"
424        );
425
426        // step_size = 0
427        let config = BayesianAlignConfig {
428            step_size: 0.0,
429            ..BayesianAlignConfig::default()
430        };
431        assert!(
432            bayesian_align_pair(&f1, &f2, &t, &config).is_err(),
433            "should reject step_size=0"
434        );
435
436        // step_size = 1
437        let config = BayesianAlignConfig {
438            step_size: 1.0,
439            ..BayesianAlignConfig::default()
440        };
441        assert!(
442            bayesian_align_pair(&f1, &f2, &t, &config).is_err(),
443            "should reject step_size=1"
444        );
445    }
446}