Skip to main content

fdars_core/alignment/
transfer.rs

1//! Transfer alignment: align curves across populations using a shared reference.
2
3use super::karcher::karcher_mean;
4use super::pairwise::{elastic_align_pair, elastic_distance};
5use super::srsf::{compose_warps, reparameterize_curve};
6use crate::error::FdarError;
7use crate::iter_maybe_parallel;
8use crate::matrix::FdMatrix;
9#[cfg(feature = "parallel")]
10use rayon::iter::ParallelIterator;
11
12// ─── Types ──────────────────────────────────────────────────────────────────
13
14/// Configuration for transfer alignment.
15///
16/// Construct via `TransferAlignConfig::default()`, then assign the fields you need (e.g. `let mut c = TransferAlignConfig::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.
17#[non_exhaustive]
18#[derive(Debug, Clone, PartialEq)]
19pub struct TransferAlignConfig {
20    /// Roughness penalty for elastic alignment.
21    pub lambda: f64,
22    /// Maximum Karcher mean iterations.
23    pub max_iter: usize,
24    /// Convergence tolerance for the Karcher mean.
25    pub tol: f64,
26}
27
28impl Default for TransferAlignConfig {
29    fn default() -> Self {
30        Self {
31            lambda: 0.0,
32            max_iter: 15,
33            tol: 1e-3,
34        }
35    }
36}
37
38/// Result of transfer alignment.
39#[derive(Debug, Clone, PartialEq)]
40#[non_exhaustive]
41pub struct TransferAlignResult {
42    /// Source Karcher mean (population A's reference).
43    pub source_mean: Vec<f64>,
44    /// Target curves aligned to source coordinate system (n_target x m).
45    pub aligned_data: FdMatrix,
46    /// Warping functions mapping target curves to source frame (n_target x m).
47    pub gammas: FdMatrix,
48    /// Bridging warp from target mean to source mean.
49    pub bridging_gamma: Vec<f64>,
50    /// Per-curve elastic distances after alignment.
51    pub distances: Vec<f64>,
52}
53
54// ─── Public API ─────────────────────────────────────────────────────────────
55
56/// Align curves from a target population to a source population's coordinate system.
57///
58/// Computes Karcher means for both populations, finds the bridging warp that
59/// aligns the target mean to the source mean, then composes this bridge with
60/// each target curve's within-population warp to produce curves aligned in
61/// the source coordinate frame.
62///
63/// # Arguments
64/// * `source_data` - Source population (n_source x m).
65/// * `target_data` - Target population to align (n_target x m).
66/// * `argvals`     - Evaluation points (length m).
67/// * `config`      - Transfer alignment configuration.
68///
69/// # Errors
70/// Returns [`FdarError::InvalidDimension`] if matrices have different `ncols`,
71/// `argvals` length does not match, or either matrix has 0 rows.
72#[must_use = "expensive computation whose result should not be discarded"]
73pub fn transfer_alignment(
74    source_data: &FdMatrix,
75    target_data: &FdMatrix,
76    argvals: &[f64],
77    config: &TransferAlignConfig,
78) -> Result<TransferAlignResult, FdarError> {
79    let (n_source, m_source) = source_data.shape();
80    let (n_target, m_target) = target_data.shape();
81
82    // ── Validation ──
83    if m_source != m_target {
84        return Err(FdarError::InvalidDimension {
85            parameter: "target_data",
86            expected: format!("{m_source} columns (matching source_data)"),
87            actual: format!("{m_target} columns"),
88        });
89    }
90    let m = m_source;
91    if argvals.len() != m {
92        return Err(FdarError::InvalidDimension {
93            parameter: "argvals",
94            expected: format!("{m}"),
95            actual: format!("{}", argvals.len()),
96        });
97    }
98    if n_source < 1 {
99        return Err(FdarError::InvalidDimension {
100            parameter: "source_data",
101            expected: "at least 1 row".to_string(),
102            actual: format!("{n_source} rows"),
103        });
104    }
105    if n_target < 1 {
106        return Err(FdarError::InvalidDimension {
107            parameter: "target_data",
108            expected: "at least 1 row".to_string(),
109            actual: format!("{n_target} rows"),
110        });
111    }
112
113    // ── Compute source reference ──
114    let source_karcher = karcher_mean(
115        source_data,
116        argvals,
117        config.max_iter,
118        config.tol,
119        config.lambda,
120    );
121
122    // ── Compute target reference ──
123    let target_karcher = karcher_mean(
124        target_data,
125        argvals,
126        config.max_iter,
127        config.tol,
128        config.lambda,
129    );
130
131    // ── Bridging alignment: align target mean to source mean ──
132    let bridge_result = elastic_align_pair(
133        &source_karcher.mean,
134        &target_karcher.mean,
135        argvals,
136        config.lambda,
137    );
138
139    // ── Align target curves ──
140    // For each target curve: compose bridging warp with within-population warp,
141    // then apply to original target curve.
142    let results: Vec<(Vec<f64>, Vec<f64>, f64)> = iter_maybe_parallel!(0..n_target)
143        .map(|i| {
144            // Within-population warp for curve i (from target Karcher computation)
145            let within_gamma = target_karcher.gammas.row(i);
146
147            // Compose: bridge_gamma(within_gamma(t))
148            let gamma_total = compose_warps(&bridge_result.gamma, &within_gamma, argvals);
149
150            // Apply to original target curve
151            let aligned_i = reparameterize_curve(&target_data.row(i), argvals, &gamma_total);
152
153            // Compute distance to source mean
154            let dist_i = elastic_distance(&source_karcher.mean, &aligned_i, argvals, config.lambda);
155
156            (gamma_total, aligned_i, dist_i)
157        })
158        .collect();
159
160    // ── Assemble result ──
161    let mut gammas = FdMatrix::zeros(n_target, m);
162    let mut aligned_data = FdMatrix::zeros(n_target, m);
163    let mut distances = Vec::with_capacity(n_target);
164
165    for (i, (gamma, aligned, dist)) in results.into_iter().enumerate() {
166        for j in 0..m {
167            gammas[(i, j)] = gamma[j];
168            aligned_data[(i, j)] = aligned[j];
169        }
170        distances.push(dist);
171    }
172
173    Ok(TransferAlignResult {
174        source_mean: source_karcher.mean,
175        aligned_data,
176        gammas,
177        bridging_gamma: bridge_result.gamma,
178        distances,
179    })
180}
181
182// ─── Tests ──────────────────────────────────────────────────────────────────
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187    use crate::simulation::{sim_fundata, EFunType, EValType};
188    use crate::test_helpers::uniform_grid;
189
190    fn make_data(n: usize, m: usize, seed: u64) -> (FdMatrix, Vec<f64>) {
191        let t = uniform_grid(m);
192        let data = sim_fundata(
193            n,
194            &t,
195            3,
196            EFunType::Fourier,
197            EValType::Exponential,
198            Some(seed),
199        );
200        (data, t)
201    }
202
203    #[test]
204    fn transfer_same_population() {
205        let (data, t) = make_data(8, 20, 42);
206        let config = TransferAlignConfig {
207            max_iter: 5,
208            tol: 1e-2,
209            ..Default::default()
210        };
211        let result = transfer_alignment(&data, &data, &t, &config).unwrap();
212
213        // Bridging warp should be close to identity
214        let max_dev: f64 = result
215            .bridging_gamma
216            .iter()
217            .zip(t.iter())
218            .map(|(&g, &ti)| (g - ti).abs())
219            .fold(0.0_f64, f64::max);
220        assert!(
221            max_dev < 0.3,
222            "bridging warp should be near identity for same population, max_dev={max_dev}"
223        );
224
225        // Distances should be small
226        for (i, &d) in result.distances.iter().enumerate() {
227            assert!(
228                d < 5.0,
229                "distance[{i}]={d} should be small for same-population transfer"
230            );
231        }
232    }
233
234    #[test]
235    fn transfer_shifted_population() {
236        let (source, t) = make_data(8, 20, 42);
237        let m = t.len();
238        let n = source.nrows();
239
240        // Create a shifted version of source
241        let mut target = FdMatrix::zeros(n, m);
242        for i in 0..n {
243            for j in 0..m {
244                target[(i, j)] = source[(i, j)] + 2.0;
245            }
246        }
247
248        let config = TransferAlignConfig {
249            max_iter: 5,
250            tol: 1e-2,
251            ..Default::default()
252        };
253        let result = transfer_alignment(&source, &target, &t, &config).unwrap();
254
255        // After alignment, the aligned target curves should be closer to the
256        // source mean than the raw target curves
257        let source_mean = &result.source_mean;
258        let raw_mean_dist: f64 = (0..m)
259            .map(|j| {
260                let diff = target[(0, j)] - source_mean[j];
261                diff * diff
262            })
263            .sum::<f64>()
264            .sqrt();
265
266        let aligned_mean_dist: f64 = (0..m)
267            .map(|j| {
268                let diff = result.aligned_data[(0, j)] - source_mean[j];
269                diff * diff
270            })
271            .sum::<f64>()
272            .sqrt();
273
274        // The aligned version should not be worse than raw (with some tolerance
275        // since the shift is in amplitude and alignment is mainly phase)
276        assert!(
277            aligned_mean_dist < raw_mean_dist + 1.0,
278            "aligned dist ({aligned_mean_dist:.2}) should not be much worse than raw dist ({raw_mean_dist:.2})"
279        );
280    }
281
282    #[test]
283    fn transfer_output_dimensions() {
284        let (source, t) = make_data(6, 20, 42);
285        let (target, _) = make_data(10, 20, 99);
286        let config = TransferAlignConfig {
287            max_iter: 3,
288            tol: 1e-2,
289            ..Default::default()
290        };
291        let result = transfer_alignment(&source, &target, &t, &config).unwrap();
292
293        assert_eq!(result.aligned_data.shape(), (10, 20));
294        assert_eq!(result.gammas.shape(), (10, 20));
295        assert_eq!(result.distances.len(), 10);
296        assert_eq!(result.source_mean.len(), 20);
297        assert_eq!(result.bridging_gamma.len(), 20);
298    }
299
300    #[test]
301    fn transfer_config_default() {
302        let config = TransferAlignConfig::default();
303        assert!((config.lambda - 0.0).abs() < f64::EPSILON);
304        assert_eq!(config.max_iter, 15);
305        assert!((config.tol - 1e-3).abs() < f64::EPSILON);
306    }
307}