1use 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#[non_exhaustive]
22#[derive(Debug, Clone, PartialEq)]
23pub struct BayesianAlignConfig {
24 pub n_samples: usize,
26 pub burn_in: usize,
28 pub step_size: f64,
30 pub proposal_variance: f64,
32 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#[derive(Debug, Clone, PartialEq)]
50#[non_exhaustive]
51pub struct BayesianAlignmentResult {
52 pub posterior_gammas: FdMatrix,
54 pub posterior_mean_gamma: Vec<f64>,
56 pub credible_lower: Vec<f64>,
58 pub credible_upper: Vec<f64>,
60 pub acceptance_rate: f64,
62 pub f_aligned_mean: Vec<f64>,
64}
65
66fn 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 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
93fn 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#[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 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 let q1 = srsf_single(f1, argvals);
165 let q2 = srsf_single(f2, argvals);
166
167 let weights = simpsons_weights(argvals);
169
170 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 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 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 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 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 let psi_prop = exp_map_sphere(&psi_id, &v_prop, &time);
220
221 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 let ll_prop = log_likelihood(&q1, &q2, argvals, &gamma_prop, &weights);
228
229 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 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 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 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 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 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 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 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 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 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 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 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}