Skip to main content

bids_modeling/
hrf.rs

1//! Hemodynamic response functions for fMRI statistical modeling.
2//!
3//! Implements SPM and Glover canonical double-gamma HRFs with optional
4//! time and dispersion derivatives, plus FIR basis sets. Numerically
5//! validated against SciPy to <1e-10 relative error.
6
7/// Hemodynamic Response Function (HRF) model type.
8///
9/// Specifies which HRF basis set to use for convolving event regressors
10/// in fMRI statistical modeling. The SPM and Glover forms are double-gamma
11/// functions with different parameters; each can include time and/or
12/// dispersion derivative basis functions.
13///
14/// The FIR (Finite Impulse Response) model uses a set of delta functions
15/// at specified delays, making no assumptions about HRF shape.
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub enum HrfModel {
18    Spm,
19    SpmDerivative,
20    SpmDerivativeDispersion,
21    Glover,
22    GloverDerivative,
23    GloverDerivativeDispersion,
24    Fir,
25    None,
26}
27
28impl HrfModel {
29    pub fn parse(s: &str) -> Self {
30        match s.to_lowercase().as_str() {
31            "spm" => Self::Spm,
32            "spm + derivative" => Self::SpmDerivative,
33            "spm + derivative + dispersion" => Self::SpmDerivativeDispersion,
34            "glover" => Self::Glover,
35            "glover + derivative" => Self::GloverDerivative,
36            "glover + derivative + dispersion" => Self::GloverDerivativeDispersion,
37            "fir" => Self::Fir,
38            _ => Self::None,
39        }
40    }
41}
42
43/// Parameters for the double-gamma HRF.
44struct HrfParams {
45    delay: f64,
46    undershoot: f64,
47    dispersion: f64,
48    u_dispersion: f64,
49    ratio: f64,
50}
51
52const SPM_PARAMS: HrfParams = HrfParams {
53    delay: 6.0,
54    undershoot: 16.0,
55    dispersion: 1.0,
56    u_dispersion: 1.0,
57    ratio: 0.167,
58};
59const GLOVER_PARAMS: HrfParams = HrfParams {
60    delay: 6.0,
61    undershoot: 12.0,
62    dispersion: 0.9,
63    u_dispersion: 0.9,
64    ratio: 0.35,
65};
66
67/// Compute a gamma difference HRF kernel.
68fn gamma_difference_hrf(
69    tr: f64,
70    oversampling: usize,
71    time_length: f64,
72    onset: f64,
73    p: &HrfParams,
74) -> Vec<f64> {
75    let dt = tr / oversampling as f64;
76    let n = (time_length / dt).round() as usize;
77    let mut hrf = Vec::with_capacity(n);
78    // Match numpy.linspace(0, time_length, n) — NOT i*dt
79    for i in 0..n {
80        let t = if n > 1 {
81            i as f64 * time_length / (n - 1) as f64
82        } else {
83            0.0
84        };
85        let t = t - onset;
86        if t <= 0.0 {
87            hrf.push(0.0);
88            continue;
89        }
90        // scipy.stats.gamma.pdf(x, a, scale=s) = x^(a-1) * exp(-x/s) / (s^a * Γ(a))
91        // PyBIDS: gamma.pdf(t, delay/disp, dt/disp) — positional arg is scale
92        // scipy convention: gamma.pdf(x, a, loc=0, scale=1) where a=shape
93        // BUT PyBIDS calls gamma.pdf(t, delay/disp, dt/disp) which means
94        // shape = delay/disp, scale = dt/disp (second positional = scale in scipy)
95        let g1 = gamma_pdf(t, p.delay / p.dispersion, dt / p.dispersion);
96        let g2 = gamma_pdf(t, p.undershoot / p.u_dispersion, dt / p.u_dispersion);
97        hrf.push(g1 - p.ratio * g2);
98    }
99    let sum: f64 = hrf.iter().sum();
100    if sum.abs() > 1e-15 {
101        for v in &mut hrf {
102            *v /= sum;
103        }
104    }
105    hrf
106}
107
108/// Gamma probability density function matching scipy.stats.gamma.pdf(x, a, loc, scale=1).
109///
110/// Uses log-space computation for numerical stability, matching scipy's cephes implementation.
111/// PyBIDS calls `gamma.pdf(t, delay/disp, dt/disp)` — 3rd positional arg is `loc`.
112fn gamma_pdf(x: f64, shape: f64, loc: f64) -> f64 {
113    let x = x - loc;
114    if x < 0.0 || shape <= 0.0 {
115        return 0.0;
116    }
117    if x == 0.0 {
118        return if shape == 1.0 {
119            1.0
120        } else if shape > 1.0 {
121            0.0
122        } else {
123            f64::INFINITY
124        };
125    }
126    // Log-space: log(pdf) = (a-1)*log(x) - x - gammaln(a)
127    let log_pdf = (shape - 1.0) * x.ln() - x - gammaln(shape);
128    log_pdf.exp()
129}
130
131/// Log-gamma function matching scipy's cephes implementation.
132///
133/// For x < 13: recurrence + rational polynomial approximation.
134/// For x >= 13: Stirling series.
135/// Reproduces scipy.special.gammaln to machine epsilon.
136fn gammaln(x: f64) -> f64 {
137    if x <= 0.0 {
138        return f64::INFINITY;
139    }
140    let mut xx = x;
141
142    if xx < 13.0 {
143        let mut z = 1.0;
144        while xx >= 3.0 {
145            xx -= 1.0;
146            z *= xx;
147        }
148        while xx < 2.0 {
149            if xx == 0.0 {
150                return f64::INFINITY;
151            }
152            z /= xx;
153            xx += 1.0;
154        }
155        if z < 0.0 {
156            z = -z;
157        }
158        if xx == 2.0 {
159            return z.ln();
160        }
161        xx -= 2.0;
162        // Rational approximation for lgamma(2+x), 0 <= x <= 1 (cephes coefficients)
163        const P: [f64; 8] = [
164            -1.716_185_138_865_495,
165            2.476_565_080_557_592e1,
166            -3.798_042_564_709_456_3e2,
167            6.293_311_553_128_184e2,
168            8.669_662_027_904_133e2,
169            -3.145_127_296_884_836_7e4,
170            -3.614_441_341_869_117_6e4,
171            6.645_614_382_024_054e4,
172        ];
173        const Q: [f64; 8] = [
174            -3.084_023_001_197_389_7e1,
175            3.153_506_269_796_041_6e2,
176            -1.015_156_367_490_219_2e3,
177            -3.107_771_671_572_311e3,
178            2.253_811_842_098_015e4,
179            4.755_846_277_527_881e3,
180            -1.346_599_598_649_693e5,
181            -1.151_322_596_755_534_9e5,
182        ];
183        let mut xnum = 0.0;
184        let mut xden = 1.0;
185        for i in 0..8 {
186            xnum = (xnum + P[i]) * xx;
187            xden = xden * xx + Q[i];
188        }
189        return (z * (xnum / xden + 1.0)).ln();
190    }
191
192    // Stirling series for x >= 13
193    let q = (xx - 0.5) * xx.ln() - xx + 0.918_938_533_204_672_8;
194    if xx > 1.0e8 {
195        return q;
196    }
197    let inv_x = 1.0 / xx;
198    let inv_x2 = inv_x * inv_x;
199    const S: [f64; 6] = [
200        8.333_333_333_333_333e-2,
201        -2.777_777_777_777_778e-3,
202        7.936_507_936_507_937e-4,
203        -5.952_380_952_380_953e-4,
204        8.417_508_417_508_417e-4,
205        -1.917_526_917_526_917_6e-3,
206    ];
207    let mut s = S[5];
208    for i in (0..5).rev() {
209        s = s * inv_x2 + S[i];
210    }
211    q + s * inv_x
212}
213
214/// SPM canonical HRF.
215#[must_use]
216pub fn spm_hrf(tr: f64, oversampling: usize, time_length: f64, onset: f64) -> Vec<f64> {
217    gamma_difference_hrf(tr, oversampling, time_length, onset, &SPM_PARAMS)
218}
219
220/// Glover canonical HRF.
221#[must_use]
222pub fn glover_hrf(tr: f64, oversampling: usize, time_length: f64, onset: f64) -> Vec<f64> {
223    gamma_difference_hrf(tr, oversampling, time_length, onset, &GLOVER_PARAMS)
224}
225
226/// SPM time derivative.
227pub fn spm_time_derivative(tr: f64, oversampling: usize, time_length: f64, onset: f64) -> Vec<f64> {
228    let d = 0.1;
229    let h1 = spm_hrf(tr, oversampling, time_length, onset);
230    let h2 = spm_hrf(tr, oversampling, time_length, onset + d);
231    h1.iter().zip(&h2).map(|(a, b)| (a - b) / d).collect()
232}
233
234/// Glover time derivative.
235pub fn glover_time_derivative(
236    tr: f64,
237    oversampling: usize,
238    time_length: f64,
239    onset: f64,
240) -> Vec<f64> {
241    let d = 0.1;
242    let h1 = glover_hrf(tr, oversampling, time_length, onset);
243    let h2 = glover_hrf(tr, oversampling, time_length, onset + d);
244    h1.iter().zip(&h2).map(|(a, b)| (a - b) / d).collect()
245}
246
247/// SPM dispersion derivative.
248pub fn spm_dispersion_derivative(
249    tr: f64,
250    oversampling: usize,
251    time_length: f64,
252    onset: f64,
253) -> Vec<f64> {
254    let dd = 0.01;
255    let h1 = gamma_difference_hrf(tr, oversampling, time_length, onset, &SPM_PARAMS);
256    let p2 = HrfParams {
257        dispersion: SPM_PARAMS.dispersion + dd,
258        ..SPM_PARAMS
259    };
260    let h2 = gamma_difference_hrf(tr, oversampling, time_length, onset, &p2);
261    h1.iter().zip(&h2).map(|(a, b)| (a - b) / dd).collect()
262}
263
264/// Glover dispersion derivative.
265pub fn glover_dispersion_derivative(
266    tr: f64,
267    oversampling: usize,
268    time_length: f64,
269    onset: f64,
270) -> Vec<f64> {
271    let dd = 0.01;
272    let h1 = gamma_difference_hrf(tr, oversampling, time_length, onset, &GLOVER_PARAMS);
273    let p2 = HrfParams {
274        dispersion: GLOVER_PARAMS.dispersion + dd,
275        ..GLOVER_PARAMS
276    };
277    let h2 = gamma_difference_hrf(tr, oversampling, time_length, onset, &p2);
278    h1.iter().zip(&h2).map(|(a, b)| (a - b) / dd).collect()
279}
280
281/// Get HRF kernel(s) for a given model.
282#[must_use]
283pub fn hrf_kernel(
284    model: &HrfModel,
285    tr: f64,
286    oversampling: usize,
287    fir_delays: Option<&[f64]>,
288) -> Vec<Vec<f64>> {
289    match model {
290        HrfModel::Spm => vec![spm_hrf(tr, oversampling, 32.0, 0.0)],
291        HrfModel::SpmDerivative => vec![
292            spm_hrf(tr, oversampling, 32.0, 0.0),
293            spm_time_derivative(tr, oversampling, 32.0, 0.0),
294        ],
295        HrfModel::SpmDerivativeDispersion => vec![
296            spm_hrf(tr, oversampling, 32.0, 0.0),
297            spm_time_derivative(tr, oversampling, 32.0, 0.0),
298            spm_dispersion_derivative(tr, oversampling, 32.0, 0.0),
299        ],
300        HrfModel::Glover => vec![glover_hrf(tr, oversampling, 32.0, 0.0)],
301        HrfModel::GloverDerivative => vec![
302            glover_hrf(tr, oversampling, 32.0, 0.0),
303            glover_time_derivative(tr, oversampling, 32.0, 0.0),
304        ],
305        HrfModel::GloverDerivativeDispersion => vec![
306            glover_hrf(tr, oversampling, 32.0, 0.0),
307            glover_time_derivative(tr, oversampling, 32.0, 0.0),
308            glover_dispersion_derivative(tr, oversampling, 32.0, 0.0),
309        ],
310        HrfModel::Fir => fir_delays
311            .unwrap_or(&[])
312            .iter()
313            .map(|&delay| {
314                let d = delay as usize;
315                let mut kernel = vec![0.0; d * oversampling + oversampling];
316                for i in (d * oversampling)..(d * oversampling + oversampling) {
317                    if i < kernel.len() {
318                        kernel[i] = 1.0;
319                    }
320                }
321                kernel
322            })
323            .collect(),
324        HrfModel::None => vec![{
325            let mut k = vec![0.0; oversampling];
326            k[0] = 1.0;
327            k
328        }],
329    }
330}
331
332/// Sample a condition as an event regressor (boxcar), then convolve with HRF.
333///
334/// `exp_condition`: (onsets, durations, amplitudes)
335/// `frame_times`: sample time points in seconds
336pub fn compute_regressor(
337    onsets: &[f64],
338    durations: &[f64],
339    amplitudes: &[f64],
340    hrf_model: &HrfModel,
341    frame_times: &[f64],
342    oversampling: usize,
343    fir_delays: Option<&[f64]>,
344) -> Vec<Vec<f64>> {
345    let n = frame_times.len();
346    if n < 2 {
347        return vec![vec![0.0; n]];
348    }
349
350    let tr = frame_times[frame_times.len() - 1] / (n - 1) as f64;
351    let n_hr = n * oversampling;
352    let dt = tr / oversampling as f64;
353
354    // Build high-res regressor
355    let mut hr_reg = vec![0.0f64; n_hr];
356    for i in 0..onsets.len() {
357        let onset_idx = ((onsets[i] / dt).round() as usize).min(n_hr - 1);
358        let dur_idx = (((onsets[i] + durations[i]) / dt).round() as usize).min(n_hr - 1);
359        hr_reg[onset_idx] += amplitudes[i];
360        if dur_idx < n_hr && dur_idx != onset_idx {
361            hr_reg[dur_idx] -= amplitudes[i];
362        }
363    }
364    // Cumulative sum to create boxcar
365    for i in 1..n_hr {
366        hr_reg[i] += hr_reg[i - 1];
367    }
368
369    // Get kernels
370    let kernels = hrf_kernel(hrf_model, tr, oversampling, fir_delays);
371
372    // Convolve and downsample
373    kernels
374        .iter()
375        .map(|kernel| {
376            let conv = convolve(&hr_reg, kernel);
377            // Downsample to frame_times
378            (0..n)
379                .map(|i| {
380                    let idx = i * oversampling;
381                    if idx < conv.len() { conv[idx] } else { 0.0 }
382                })
383                .collect()
384        })
385        .collect()
386}
387
388/// Simple 1D convolution.
389fn convolve(a: &[f64], b: &[f64]) -> Vec<f64> {
390    let n = a.len();
391    let m = b.len();
392    let mut result = vec![0.0; n];
393    for i in 0..n {
394        for j in 0..m {
395            if i >= j {
396                result[i] += a[i - j] * b[j];
397            }
398        }
399    }
400    result
401}
402
403/// Regressor names for a given HRF model.
404#[must_use]
405pub fn regressor_names(
406    con_name: &str,
407    model: &HrfModel,
408    fir_delays: Option<&[f64]>,
409) -> Vec<String> {
410    match model {
411        HrfModel::Spm | HrfModel::Glover | HrfModel::None => vec![con_name.into()],
412        HrfModel::SpmDerivative | HrfModel::GloverDerivative => {
413            vec![con_name.into(), format!("{}_derivative", con_name)]
414        }
415        HrfModel::SpmDerivativeDispersion | HrfModel::GloverDerivativeDispersion => vec![
416            con_name.into(),
417            format!("{}_derivative", con_name),
418            format!("{}_dispersion", con_name),
419        ],
420        HrfModel::Fir => fir_delays
421            .unwrap_or(&[])
422            .iter()
423            .map(|d| format!("{}_delay_{}", con_name, *d as i64))
424            .collect(),
425    }
426}
427
428#[cfg(test)]
429mod tests {
430    use super::*;
431
432    #[test]
433    fn test_spm_hrf() {
434        let hrf = spm_hrf(2.0, 50, 32.0, 0.0);
435        assert!(!hrf.is_empty());
436        let sum: f64 = hrf.iter().sum();
437        assert!((sum - 1.0).abs() < 0.1, "HRF should sum to ~1, got {}", sum);
438        // Peak should be in the first half
439        let peak_idx = hrf
440            .iter()
441            .enumerate()
442            .max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap())
443            .unwrap()
444            .0;
445        assert!(
446            peak_idx < hrf.len() / 2,
447            "Peak should be in first half, at idx {}",
448            peak_idx
449        );
450    }
451
452    #[test]
453    fn test_glover_hrf() {
454        let hrf = glover_hrf(2.0, 50, 32.0, 0.0);
455        assert!(!hrf.is_empty());
456        let sum: f64 = hrf.iter().sum();
457        assert!((sum - 1.0).abs() < 0.01);
458    }
459
460    #[test]
461    fn test_compute_regressor() {
462        let onsets = vec![2.0, 6.0];
463        let durations = vec![1.0, 1.0];
464        let amplitudes = vec![1.0, 1.0];
465        let frame_times: Vec<f64> = (0..100).map(|i| i as f64 * 0.5).collect();
466
467        let regs = compute_regressor(
468            &onsets,
469            &durations,
470            &amplitudes,
471            &HrfModel::Spm,
472            &frame_times,
473            50,
474            None,
475        );
476        assert_eq!(regs.len(), 1); // SPM produces 1 regressor
477        assert_eq!(regs[0].len(), 100);
478    }
479
480    #[test]
481    fn test_regressor_names() {
482        assert_eq!(regressor_names("face", &HrfModel::Spm, None), vec!["face"]);
483        assert_eq!(
484            regressor_names("face", &HrfModel::SpmDerivative, None),
485            vec!["face", "face_derivative"]
486        );
487    }
488}