1use faer::dyn_stack::{MemBuffer, MemStack};
23use faer::linalg::svd::{ComputeSvdVectors, svd, svd_scratch};
24use faer::{Mat, Par};
25
26use crate::error::FittingError;
27use crate::lm::{FitModel, FlatMatrix};
28use crate::parameters::{FitParameter, ParameterSet};
29
30#[derive(Debug, Clone)]
32pub struct PoissonConfig {
33 pub max_iter: usize,
35 pub tol_param: f64,
38 pub compute_covariance: bool,
40}
41
42impl Default for PoissonConfig {
43 fn default() -> Self {
44 Self {
45 max_iter: 200,
46 tol_param: 1e-8,
47 compute_covariance: true,
48 }
49 }
50}
51
52#[derive(Debug, Clone)]
54pub struct PoissonResult {
55 pub deviance: f64,
57 pub iterations: usize,
59 pub converged: bool,
63 pub params: Vec<f64>,
65 pub covariance: Option<FlatMatrix>,
69 pub uncertainties: Option<Vec<Option<f64>>>,
74 pub on_bound: Vec<bool>,
76}
77
78const NEWTON_DECREMENT_TOL: f64 = 1e-6;
79
80const DEGENERATE_EIGENVALUE: f64 = 1e-12;
81
82const MAX_REJECTIONS: usize = 60;
83
84const INITIAL_DAMPING: f64 = 1e-3;
85
86const DAMPING_FACTOR: f64 = 10.0;
87
88fn half_deviance(obs: f64, mean: f64) -> f64 {
92 let half_sum = obs / 2.0 + mean / 2.0;
93 let difference = obs - mean;
94 if difference.abs() < 0.2 * half_sum {
95 let v = difference / 2.0 / half_sum;
96 let mut sum = difference * v;
97 let mut term = obs * (2.0 * v);
98 let mut j = 1.0;
99 loop {
100 term *= v * v;
101 let next = sum + term / (2.0 * j + 1.0);
102 if next == sum {
103 return sum;
104 }
105 sum = next;
106 j += 1.0;
107 }
108 } else if obs == 0.0 {
109 mean
110 } else {
111 obs * (obs.ln() - mean.ln()) + mean - obs
112 }
113}
114
115fn deviance(y_obs: &[f64], y_model: &[f64]) -> f64 {
116 y_obs
117 .iter()
118 .zip(y_model)
119 .map(|(&obs, &mean)| {
120 if mean > 0.0 || (mean == 0.0 && obs == 0.0) {
121 half_deviance(obs, mean)
122 } else {
123 f64::INFINITY
124 }
125 })
126 .sum()
127}
128
129struct Linearization {
130 weighted: FlatMatrix,
131 residual: Vec<f64>,
132 zero_slope: Vec<f64>,
133 gradient: Vec<f64>,
134}
135
136fn linearize(
137 model: &dyn FitModel,
138 params: &ParameterSet,
139 free: &[usize],
140 y_obs: &[f64],
141 y_model: &[f64],
142) -> Result<Linearization, FittingError> {
143 let mut weighted = model
144 .analytical_jacobian(¶ms.all_values(), free, y_model)
145 .ok_or_else(|| {
146 FittingError::InvalidConfig(
147 "poisson_fit needs a model with an analytical Jacobian".into(),
148 )
149 })?;
150 for (expected, actual, field) in [
151 (y_obs.len(), weighted.nrows, "analytical Jacobian rows"),
152 (free.len(), weighted.ncols, "analytical Jacobian columns"),
153 ] {
154 if expected != actual {
155 return Err(FittingError::LengthMismatch {
156 expected,
157 actual,
158 field,
159 });
160 }
161 }
162 let root: Vec<f64> = y_model.iter().map(|mean| mean.sqrt()).collect();
163 let mut zero_slope = vec![0.0; weighted.ncols];
164 for (i, &r) in root.iter().enumerate() {
165 for (j, z) in zero_slope.iter_mut().enumerate() {
166 let slope = weighted.get(i, j);
167 if r > 0.0 {
168 *weighted.get_mut(i, j) = slope / r;
169 } else {
170 *z += slope;
171 *weighted.get_mut(i, j) = 0.0;
172 }
173 }
174 }
175 let residual: Vec<f64> = y_obs
176 .iter()
177 .zip(y_model)
178 .zip(&root)
179 .map(|((&obs, &mean), &r)| if r > 0.0 { (mean - obs) / r } else { 0.0 })
180 .collect();
181 let gradient = zero_slope
182 .iter()
183 .enumerate()
184 .map(|(j, z)| {
185 z + (0..weighted.nrows)
186 .map(|i| weighted.get(i, j) * residual[i])
187 .sum::<f64>()
188 })
189 .collect();
190 Ok(Linearization {
191 weighted,
192 residual,
193 zero_slope,
194 gradient,
195 })
196}
197
198fn column_scale(weighted: &FlatMatrix, col: usize) -> (f64, f64) {
199 let peak = (0..weighted.nrows).fold(0.0_f64, |m, i| m.max(weighted.get(i, col).abs()));
200 let length = (0..weighted.nrows)
201 .map(|i| (weighted.get(i, col) / peak).powi(2))
202 .sum::<f64>()
203 .sqrt();
204 (peak, length)
205}
206
207struct Decomposition {
208 columns: Vec<usize>,
209 largest: Vec<f64>,
210 length: Vec<f64>,
211 rows: usize,
212 singular: Vec<f64>,
213 left: Mat<f64>,
214 right: Vec<Vec<f64>>,
215}
216
217impl Decomposition {
218 fn new(weighted: &FlatMatrix, columns: &[usize]) -> Option<Self> {
219 let (mut kept, mut largest, mut length) = (vec![], vec![], vec![]);
220 for &col in columns {
221 let (peak, norm) = column_scale(weighted, col);
222 if peak > 0.0 {
223 kept.push(col);
224 largest.push(peak);
225 length.push(norm);
226 }
227 }
228 let (rows, k) = (weighted.nrows, kept.len());
229 let scaled = Mat::from_fn(rows, k, |row, i| {
230 weighted.get(row, kept[i]) / largest[i] / length[i]
231 });
232 let mut values = Mat::<f64>::zeros(rows.min(k), rows.min(k));
233 let mut left = Mat::<f64>::zeros(rows, rows.min(k));
234 let mut v = Mat::<f64>::zeros(k, k);
235 let params = Default::default();
236 svd(
237 scaled.as_ref(),
238 values.diagonal_mut(),
239 Some(left.as_mut()),
240 Some(v.as_mut()),
241 Par::Seq,
242 MemStack::new(&mut MemBuffer::new(svd_scratch::<f64>(
243 rows,
244 k,
245 ComputeSvdVectors::Thin,
246 ComputeSvdVectors::Full,
247 Par::Seq,
248 params,
249 ))),
250 params,
251 )
252 .ok()?;
253 Some(Self {
254 columns: kept,
255 largest,
256 length,
257 rows,
258 singular: (0..k)
259 .map(|j| if j < rows { values[(j, j)] } else { 0.0 })
260 .collect(),
261 left,
262 right: (0..k)
263 .map(|j| (0..k).map(|i| v[(i, j)]).collect())
264 .collect(),
265 })
266 }
267
268 fn unscale(&self, i: usize, value: f64) -> f64 {
269 value / self.length[i] / self.largest[i]
270 }
271
272 fn spanned(&self) -> impl Iterator<Item = usize> + '_ {
273 let largest = self.singular.iter().fold(0.0_f64, |m, &s| m.max(s));
274 let rank_floor = f64::EPSILON * self.rows.max(self.singular.len()) as f64 * largest;
275 (0..self.singular.len()).filter(move |&k| self.singular[k] > rank_floor)
276 }
277
278 fn along(&self, k: usize, linear: &Linearization) -> f64 {
279 let zero_part: f64 = self
280 .columns
281 .iter()
282 .enumerate()
283 .map(|(i, &col)| self.right[k][i] * self.unscale(i, linear.zero_slope[col]))
284 .sum();
285 let residual_part: f64 = (0..self.rows)
286 .map(|row| self.left[(row, k)] * linear.residual[row])
287 .sum();
288 residual_part + zero_part / self.singular[k]
289 }
290
291 fn newton_decrement(&self, linear: &Linearization) -> f64 {
292 0.5 * self
293 .spanned()
294 .map(|k| self.along(k, linear).powi(2))
295 .sum::<f64>()
296 }
297
298 fn step(&self, linear: &Linearization, n_free: usize, damping: f64) -> Vec<f64> {
299 let mut direction = vec![0.0; n_free];
300 for k in self.spanned() {
301 let s = self.singular[k];
302 let coefficient = self.along(k, linear) * s / (s * s + damping);
303 for (i, &col) in self.columns.iter().enumerate() {
304 direction[col] += self.unscale(i, self.right[k][i] * coefficient);
305 }
306 }
307 direction
308 }
309
310 fn error_bars(&self, n_free: usize) -> (FlatMatrix, Vec<Option<f64>>) {
311 let n = self.columns.len();
312 let determined: Vec<bool> = self
313 .singular
314 .iter()
315 .map(|s| s * s >= DEGENERATE_EIGENVALUE)
316 .collect();
317 let largest = self.singular.iter().fold(0.0_f64, |m, &s| m.max(s));
318 let resolved: Vec<bool> = (0..n)
319 .map(|i| {
320 let sensitivity: f64 = (0..n)
321 .filter(|&k| determined[k])
322 .map(|k| self.right[k][i].abs() / self.singular[k])
323 .sum();
324 let rounding = f64::EPSILON * self.rows.max(n) as f64 * largest * sensitivity;
325 (0..n)
326 .filter(|&k| !determined[k])
327 .map(|k| self.right[k][i].powi(2))
328 .sum::<f64>()
329 <= rounding.powi(2)
330 })
331 .collect();
332 let variance = |i: usize, j: usize| -> f64 {
333 (0..n)
334 .filter(|&k| determined[k])
335 .map(|k| self.right[k][i] * self.right[k][j] / self.singular[k].powi(2))
336 .sum()
337 };
338 let (mut covariance, mut errors) = withheld(n_free);
339 for i in (0..n).filter(|&i| resolved[i]) {
340 for j in (0..n).filter(|&j| resolved[j]) {
341 *covariance.get_mut(self.columns[i], self.columns[j]) =
342 self.unscale(j, self.unscale(i, variance(i, j)));
343 }
344 errors[self.columns[i]] = Some(self.unscale(i, variance(i, i).sqrt()));
345 }
346 (covariance, errors)
347 }
348}
349
350fn withheld(n_free: usize) -> (FlatMatrix, Vec<Option<f64>>) {
351 let mut covariance = FlatMatrix::zeros(n_free, n_free);
352 covariance.data.fill(f64::NAN);
353 (covariance, vec![None; n_free])
354}
355
356fn on_bound(param: &FitParameter) -> bool {
357 param.value == param.lower || param.value == param.upper
358}
359
360fn held_by_bound(param: &FitParameter, gradient: f64) -> bool {
361 (param.value == param.lower && gradient > 0.0) || (param.value == param.upper && gradient < 0.0)
362}
363
364pub fn poisson_fit(
408 model: &dyn FitModel,
409 y_obs: &[f64],
410 params: &mut ParameterSet,
411 config: &PoissonConfig,
412) -> Result<PoissonResult, FittingError> {
413 if let Some((bin, &obs)) = y_obs
414 .iter()
415 .enumerate()
416 .find(|(_, obs)| !(obs.is_finite() && **obs >= 0.0))
417 {
418 return Err(FittingError::InvalidConfig(format!(
419 "observed counts must be finite and non-negative, got {obs} in bin {bin}"
420 )));
421 }
422 if y_obs.is_empty() {
423 return Err(FittingError::EmptyData);
424 }
425 if let Some(p) = params.params.iter().find(|p| {
426 !p.value.is_finite()
427 || (!p.fixed
428 && (p.lower.is_nan()
429 || p.upper.is_nan()
430 || p.lower > p.upper
431 || p.lower == f64::INFINITY
432 || p.upper == f64::NEG_INFINITY))
433 }) {
434 return Err(FittingError::InvalidConfig(format!(
435 "parameter {} = {} with bounds [{}, {}]",
436 p.name, p.value, p.lower, p.upper
437 )));
438 }
439 params.set_free_values(¶ms.free_values());
440 let mut y_model = model.evaluate(¶ms.all_values())?;
441 if y_model.len() != y_obs.len() {
442 return Err(FittingError::LengthMismatch {
443 expected: y_model.len(),
444 actual: y_obs.len(),
445 field: "y_obs",
446 });
447 }
448 let free = params.free_indices();
449 let mut value = deviance(y_obs, &y_model);
450 let mut iterations = 0;
451 let mut at_minimum = None;
452 let mut damping = INITIAL_DAMPING;
453 while value.is_finite() {
454 let linear = linearize(model, params, &free, y_obs, &y_model)?;
455 if !linear
456 .weighted
457 .data
458 .iter()
459 .chain(&linear.zero_slope)
460 .all(|v| v.is_finite())
461 {
462 break;
463 }
464 let movable: Vec<usize> = (0..free.len())
465 .filter(|&j| !held_by_bound(¶ms.params[free[j]], linear.gradient[j]))
466 .collect();
467 let Some(decomposition) = Decomposition::new(&linear.weighted, &movable) else {
468 break;
469 };
470 if decomposition.newton_decrement(&linear) < NEWTON_DECREMENT_TOL {
471 at_minimum = Some(linear);
472 break;
473 }
474 if iterations == config.max_iter {
475 break;
476 }
477 let start = params.free_values();
478 let mut accepted = None;
479 for _ in 0..MAX_REJECTIONS {
480 let direction = decomposition.step(&linear, free.len(), damping);
481 let unprojected: Vec<f64> = start.iter().zip(&direction).map(|(x, d)| x - d).collect();
482 params.set_free_values(&unprojected);
483 if let Some(trial_model) = model
484 .evaluate(¶ms.all_values())
485 .ok()
486 .filter(|trial| trial.len() == y_obs.len())
487 {
488 let trial_value = deviance(y_obs, &trial_model);
489 if trial_value < value {
490 damping /= DAMPING_FACTOR;
491 accepted = Some((trial_model, trial_value));
492 break;
493 }
494 }
495 params.set_free_values(&start);
496 damping = (damping * DAMPING_FACTOR).max(INITIAL_DAMPING);
497 }
498 let Some((trial_model, trial_value)) = accepted else {
499 break;
500 };
501 y_model = trial_model;
502 value = trial_value;
503 iterations += 1;
504 }
505
506 let bounded: Vec<bool> = free
507 .iter()
508 .map(|&idx| on_bound(¶ms.params[idx]))
509 .collect();
510 let (covariance, uncertainties) = match &at_minimum {
511 Some(linear) if config.compute_covariance => {
512 let interior: Vec<usize> = (0..free.len()).filter(|&j| !bounded[j]).collect();
513 let (covariance, errors) = Decomposition::new(&linear.weighted, &interior).map_or_else(
514 || withheld(free.len()),
515 |decomposition| decomposition.error_bars(free.len()),
516 );
517 (Some(covariance), Some(errors))
518 }
519 _ => (None, None),
520 };
521 Ok(PoissonResult {
522 deviance: value,
523 iterations,
524 converged: at_minimum.is_some(),
525 params: params.all_values(),
526 covariance,
527 uncertainties,
528 on_bound: bounded,
529 })
530}
531
532pub struct CountsModel<'a> {
574 pub transmission_model: &'a dyn FitModel,
579 pub flux: &'a [f64],
581 pub background: &'a [f64],
583 pub n_params: usize,
585}
586
587impl<'a> FitModel for CountsModel<'a> {
588 fn evaluate(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
589 let transmission = self.transmission_model.evaluate(params)?;
590 debug_assert_eq!(
591 transmission.len(),
592 self.flux.len(),
593 "CountsModel: transmission length ({}) != flux length ({})",
594 transmission.len(),
595 self.flux.len(),
596 );
597 debug_assert_eq!(
598 self.flux.len(),
599 self.background.len(),
600 "CountsModel: flux length ({}) != background length ({})",
601 self.flux.len(),
602 self.background.len(),
603 );
604 Ok(transmission
605 .iter()
606 .zip(self.flux.iter())
607 .zip(self.background.iter())
608 .map(|((&t, &f), &b)| f * t + b)
609 .collect())
610 }
611
612 fn analytical_jacobian(
616 &self,
617 params: &[f64],
618 free_param_indices: &[usize],
619 y_current: &[f64],
620 ) -> Option<FlatMatrix> {
621 let n_e = y_current.len();
622 let t_inner: Vec<f64> = y_current
624 .iter()
625 .zip(self.flux.iter())
626 .zip(self.background.iter())
627 .map(|((&y, &f), &b)| if f.abs() > 1e-30 { (y - b) / f } else { 0.0 })
628 .collect();
629 let inner_jac =
630 self.transmission_model
631 .analytical_jacobian(params, free_param_indices, &t_inner)?;
632 let n_free = free_param_indices.len();
633 let mut jac = FlatMatrix::zeros(n_e, n_free);
634 for i in 0..n_e {
635 for j in 0..n_free {
636 *jac.get_mut(i, j) = self.flux[i] * inner_jac.get(i, j);
637 }
638 }
639 Some(jac)
640 }
641}
642
643impl<'a> crate::forward_model::ForwardModel for CountsModel<'a> {
646 fn predict(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
647 self.evaluate(params)
648 }
649
650 fn n_data(&self) -> usize {
653 self.flux.len()
654 }
655
656 fn n_params(&self) -> usize {
657 self.n_params
658 }
659}
660
661pub struct CountsBackgroundScaleModel<'a> {
703 pub transmission_model: &'a dyn FitModel,
708 pub flux: &'a [f64],
710 pub background: &'a [f64],
712 pub alpha1_index: usize,
715 pub alpha2_index: usize,
718 pub n_params: usize,
720}
721
722impl<'a> FitModel for CountsBackgroundScaleModel<'a> {
723 fn evaluate(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
724 let transmission = self.transmission_model.evaluate(params)?;
725 let alpha1 = params[self.alpha1_index];
726 let alpha2 = params[self.alpha2_index];
727 debug_assert_eq!(transmission.len(), self.flux.len());
728 debug_assert_eq!(self.flux.len(), self.background.len());
729 Ok(transmission
730 .iter()
731 .zip(self.flux.iter())
732 .zip(self.background.iter())
733 .map(|((&t, &f), &b)| alpha1 * f * t + alpha2 * b)
734 .collect())
735 }
736
737 fn analytical_jacobian(
738 &self,
739 params: &[f64],
740 free_param_indices: &[usize],
741 y_current: &[f64],
742 ) -> Option<FlatMatrix> {
743 let n_e = y_current.len();
744 let n_free = free_param_indices.len();
745 let alpha1 = params[self.alpha1_index];
746 let alpha1_col = free_param_indices
747 .iter()
748 .position(|&i| i == self.alpha1_index);
749 let alpha2_col = free_param_indices
750 .iter()
751 .position(|&i| i == self.alpha2_index);
752 let inner_free: Vec<usize> = free_param_indices
753 .iter()
754 .copied()
755 .filter(|&i| i != self.alpha1_index && i != self.alpha2_index)
756 .collect();
757
758 let t_inner = match self.transmission_model.evaluate(params) {
762 Ok(t) => t,
763 Err(_) => return None,
764 };
765
766 let inner_jac = if !inner_free.is_empty() {
767 self.transmission_model
768 .analytical_jacobian(params, &inner_free, &t_inner)
769 } else {
770 None
771 };
772
773 let mut jacobian = FlatMatrix::zeros(n_e, n_free);
774 if let Some(ref ij) = inner_jac {
775 let mut inner_col = 0;
776 for (col, &fp) in free_param_indices.iter().enumerate() {
777 if fp == self.alpha1_index || fp == self.alpha2_index {
778 continue;
779 }
780 for row in 0..n_e {
781 *jacobian.get_mut(row, col) = alpha1 * self.flux[row] * ij.get(row, inner_col);
782 }
783 inner_col += 1;
784 }
785 } else if !inner_free.is_empty() {
786 return None;
787 }
788
789 if let Some(col) = alpha1_col {
796 for (row, (&f, &t)) in self.flux.iter().zip(t_inner.iter()).enumerate() {
797 *jacobian.get_mut(row, col) += f * t;
798 }
799 }
800 if let Some(col) = alpha2_col {
801 for (row, &bg) in self.background.iter().enumerate() {
802 *jacobian.get_mut(row, col) += bg;
803 }
804 }
805
806 Some(jacobian)
807 }
808}
809
810impl<'a> crate::forward_model::ForwardModel for CountsBackgroundScaleModel<'a> {
811 fn predict(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
812 self.evaluate(params)
813 }
814
815 fn n_data(&self) -> usize {
816 self.flux.len()
817 }
818
819 fn n_params(&self) -> usize {
820 self.n_params
821 }
822}
823
824pub struct TransmissionKLBackgroundModel<'a> {
859 pub inner: &'a dyn FitModel,
861 pub inv_sqrt_energies: Vec<f64>,
863 pub b0_index: usize,
866 pub b1_index: usize,
869 pub n_params: usize,
871}
872
873impl<'a> FitModel for TransmissionKLBackgroundModel<'a> {
874 fn evaluate(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
875 let t_inner = self.inner.evaluate(params)?;
876 let b0 = params[self.b0_index];
877 let b1 = params[self.b1_index];
878 Ok(t_inner
879 .iter()
880 .zip(self.inv_sqrt_energies.iter())
881 .map(|(&t, &inv_sqrt_e)| t + b0 + b1 * inv_sqrt_e)
882 .collect())
883 }
884
885 fn analytical_jacobian(
886 &self,
887 params: &[f64],
888 free_param_indices: &[usize],
889 y_current: &[f64],
890 ) -> Option<FlatMatrix> {
891 let n_e = y_current.len();
892 let n_free = free_param_indices.len();
893
894 let b0_col = free_param_indices.iter().position(|&i| i == self.b0_index);
896 let b1_col = free_param_indices.iter().position(|&i| i == self.b1_index);
897
898 let inner_free: Vec<usize> = free_param_indices
900 .iter()
901 .copied()
902 .filter(|&i| i != self.b0_index && i != self.b1_index)
903 .collect();
904
905 let inner_jac = if !inner_free.is_empty() {
907 let t_inner = self.inner.evaluate(params).ok()?;
909 self.inner
910 .analytical_jacobian(params, &inner_free, &t_inner)
911 } else {
912 None
913 };
914
915 let mut jacobian = FlatMatrix::zeros(n_e, n_free);
916
917 if let Some(ij) = inner_jac.as_ref() {
921 let mut inner_col = 0;
922 for (col, &fp) in free_param_indices.iter().enumerate() {
923 if fp == self.b0_index || fp == self.b1_index {
924 continue;
925 }
926 for row in 0..n_e {
927 *jacobian.get_mut(row, col) = ij.get(row, inner_col);
928 }
929 inner_col += 1;
930 }
931 } else if !inner_free.is_empty() {
932 return None;
935 }
936
937 if let Some(col) = b0_col {
944 for row in 0..n_e {
945 *jacobian.get_mut(row, col) += 1.0; }
947 }
948 if let Some(col) = b1_col {
949 for row in 0..n_e {
950 *jacobian.get_mut(row, col) += self.inv_sqrt_energies[row]; }
952 }
953
954 Some(jacobian)
955 }
956}
957
958impl<'a> crate::forward_model::ForwardModel for TransmissionKLBackgroundModel<'a> {
959 fn predict(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
960 self.evaluate(params)
961 }
962
963 fn n_data(&self) -> usize {
964 self.inv_sqrt_energies.len()
965 }
966
967 fn n_params(&self) -> usize {
968 self.n_params
969 }
970}
971
972#[cfg(test)]
973mod tests {
974 use super::*;
975
976 struct ExponentialModel {
979 x: Vec<f64>,
980 flux: Vec<f64>,
981 }
982
983 impl FitModel for ExponentialModel {
984 fn evaluate(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
985 let b = params[0]; Ok(self
987 .x
988 .iter()
989 .zip(self.flux.iter())
990 .map(|(&xi, &fi)| fi * (-b * xi).exp())
991 .collect())
992 }
993 }
994
995 #[test]
996 fn test_counts_model() {
997 struct ConstTransmission;
998 impl FitModel for ConstTransmission {
999 fn evaluate(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
1000 Ok(vec![params[0]; 3])
1001 }
1002 }
1003
1004 let t_model = ConstTransmission;
1005 let flux = [100.0, 200.0, 300.0];
1006 let background = [5.0, 10.0, 15.0];
1007 let counts_model = CountsModel {
1008 transmission_model: &t_model,
1009 flux: &flux,
1010 background: &background,
1011 n_params: 1,
1012 };
1013
1014 let result = counts_model.evaluate(&[0.5]).unwrap();
1016 assert!((result[0] - 55.0).abs() < 1e-10);
1017 assert!((result[1] - 110.0).abs() < 1e-10);
1018 assert!((result[2] - 165.0).abs() < 1e-10);
1019 assert_eq!(
1020 crate::forward_model::ForwardModel::n_params(&counts_model),
1021 1
1022 );
1023 }
1024
1025 #[test]
1026 fn test_counts_background_scale_model() {
1027 struct ConstTransmission;
1028 impl FitModel for ConstTransmission {
1029 fn evaluate(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
1030 Ok(vec![params[0]; 3])
1031 }
1032
1033 fn analytical_jacobian(
1034 &self,
1035 _params: &[f64],
1036 free_param_indices: &[usize],
1037 _y_current: &[f64],
1038 ) -> Option<FlatMatrix> {
1039 let mut jac = FlatMatrix::zeros(3, free_param_indices.len());
1040 for (col, &fp) in free_param_indices.iter().enumerate() {
1041 if fp == 0 {
1042 for row in 0..3 {
1043 *jac.get_mut(row, col) = 1.0;
1044 }
1045 }
1046 }
1047 Some(jac)
1048 }
1049 }
1050
1051 let t_model = ConstTransmission;
1052 let flux = [100.0, 200.0, 300.0];
1053 let background = [5.0, 10.0, 15.0];
1054 let counts_model = CountsBackgroundScaleModel {
1055 transmission_model: &t_model,
1056 flux: &flux,
1057 background: &background,
1058 alpha1_index: 1,
1059 alpha2_index: 2,
1060 n_params: 3,
1061 };
1062
1063 let params = [0.5, 0.8, 1.5];
1064 let result = counts_model.evaluate(¶ms).unwrap();
1065 assert!((result[0] - 47.5).abs() < 1e-10);
1066 assert!((result[1] - 95.0).abs() < 1e-10);
1067 assert!((result[2] - 142.5).abs() < 1e-10);
1068 assert_eq!(
1069 crate::forward_model::ForwardModel::n_params(&counts_model),
1070 3
1071 );
1072 }
1073
1074 #[test]
1075 fn test_transmission_kl_background_has_no_jacobian_when_inner_lacks_one() {
1076 let x: Vec<f64> = (0..40).map(|i| 1.0 + 0.25 * i as f64).collect();
1077 let inner = ExponentialModel {
1078 x: x.clone(),
1079 flux: vec![1000.0; x.len()],
1080 };
1081 let inv_sqrt_energies: Vec<f64> = x.iter().map(|&e| 1.0 / e.sqrt()).collect();
1082 let wrapped = TransmissionKLBackgroundModel {
1083 inner: &inner,
1084 inv_sqrt_energies,
1085 b0_index: 1,
1086 b1_index: 2,
1087 n_params: 3,
1088 };
1089
1090 let true_params = vec![0.4, 20.0, 10.0];
1091 let y_obs = wrapped.evaluate(&true_params).unwrap();
1092
1093 assert!(
1094 wrapped
1095 .analytical_jacobian(&true_params, &[0, 1, 2], &y_obs)
1096 .is_none(),
1097 "inner param free without inner analytical_jacobian must give None"
1098 );
1099 assert!(
1100 wrapped
1101 .analytical_jacobian(&true_params, &[0], &y_obs)
1102 .is_none(),
1103 "inner-only free set without inner analytical_jacobian must give None"
1104 );
1105 }
1106
1107 #[test]
1108 fn test_transmission_kl_background_background_only_analytic_jacobian() {
1109 let x: Vec<f64> = (0..40).map(|i| 1.0 + 0.25 * i as f64).collect();
1110 let inner = ExponentialModel {
1111 x: x.clone(),
1112 flux: vec![1000.0; x.len()],
1113 };
1114 let inv_sqrt_energies: Vec<f64> = x.iter().map(|&e| 1.0 / e.sqrt()).collect();
1115 let wrapped = TransmissionKLBackgroundModel {
1116 inner: &inner,
1117 inv_sqrt_energies: inv_sqrt_energies.clone(),
1118 b0_index: 1,
1119 b1_index: 2,
1120 n_params: 3,
1121 };
1122
1123 let true_params = vec![0.4, 20.0, 10.0];
1124 let y_obs = wrapped.evaluate(&true_params).unwrap();
1125
1126 let jac = wrapped
1127 .analytical_jacobian(&true_params, &[1, 2], &y_obs)
1128 .expect("background-only free set must stay on the analytic path");
1129 for (row, &inv_sqrt_e) in inv_sqrt_energies.iter().enumerate() {
1130 assert!(
1131 (jac.get(row, 0) - 1.0).abs() < 1e-15,
1132 "∂T/∂b₀ at row {row} = {}, expected 1.0",
1133 jac.get(row, 0),
1134 );
1135 assert!(
1136 (jac.get(row, 1) - inv_sqrt_e).abs() < 1e-15,
1137 "∂T/∂b₁ at row {row} = {}, expected {inv_sqrt_e}",
1138 jac.get(row, 1),
1139 );
1140 }
1141 }
1142
1143 fn fd_column(model: &dyn FitModel, params: &[f64], param_index: usize, h: f64) -> Vec<f64> {
1147 let mut plus = params.to_vec();
1148 plus[param_index] += h;
1149 let mut minus = params.to_vec();
1150 minus[param_index] -= h;
1151 let y_plus = model.evaluate(&plus).unwrap();
1152 let y_minus = model.evaluate(&minus).unwrap();
1153 y_plus
1154 .iter()
1155 .zip(y_minus.iter())
1156 .map(|(&p, &m)| (p - m) / (2.0 * h))
1157 .collect()
1158 }
1159
1160 #[test]
1164 fn test_transmission_kl_background_aliased_indices_jacobian_matches_fd() {
1165 let x: Vec<f64> = (0..10).map(|i| 1.0 + 0.5 * i as f64).collect();
1166 let inner = ExponentialModel {
1167 x: x.clone(),
1168 flux: vec![1000.0; x.len()],
1169 };
1170 let inv_sqrt_energies: Vec<f64> = x.iter().map(|&e| 1.0 / e.sqrt()).collect();
1171 let wrapped = TransmissionKLBackgroundModel {
1172 inner: &inner,
1173 inv_sqrt_energies: inv_sqrt_energies.clone(),
1174 b0_index: 1,
1175 b1_index: 1, n_params: 2,
1177 };
1178
1179 let params = vec![0.4, 15.0];
1180 let y = wrapped.evaluate(¶ms).unwrap();
1181 let jac = wrapped
1184 .analytical_jacobian(¶ms, &[1], &y)
1185 .expect("background-only free set must stay on the analytic path");
1186
1187 let fd = fd_column(&wrapped, ¶ms, 1, 1e-6);
1188 for (row, (&fd_val, &inv_sqrt_e)) in fd.iter().zip(inv_sqrt_energies.iter()).enumerate() {
1189 let expected = 1.0 + inv_sqrt_e;
1190 assert!(
1191 (jac.get(row, 0) - expected).abs() < 1e-12,
1192 "aliased ∂/∂b at row {row}: analytic {}, expected {expected}",
1193 jac.get(row, 0),
1194 );
1195 assert!(
1196 (jac.get(row, 0) - fd_val).abs() < 1e-5,
1197 "aliased ∂/∂b at row {row}: analytic {}, FD {fd_val}",
1198 jac.get(row, 0),
1199 );
1200 }
1201 }
1202
1203 #[test]
1207 fn test_counts_background_scale_aliased_indices_jacobian_matches_fd() {
1208 let x: Vec<f64> = (0..10).map(|i| 1.0 + 0.5 * i as f64).collect();
1209 let inner = ExponentialModel {
1210 x: x.clone(),
1211 flux: vec![1.0; x.len()], };
1213 let flux: Vec<f64> = vec![1000.0; x.len()];
1214 let background: Vec<f64> = x.iter().map(|&e| 5.0 + e).collect();
1215 let wrapped = CountsBackgroundScaleModel {
1216 transmission_model: &inner,
1217 flux: &flux,
1218 background: &background,
1219 alpha1_index: 1,
1220 alpha2_index: 1, n_params: 2,
1222 };
1223
1224 let params = vec![0.4, 1.2];
1225 let y = wrapped.evaluate(¶ms).unwrap();
1226 let t_inner = inner.evaluate(¶ms).unwrap();
1227 let jac = wrapped
1230 .analytical_jacobian(¶ms, &[1], &y)
1231 .expect("scale-only free set must stay on the analytic path");
1232
1233 let fd = fd_column(&wrapped, ¶ms, 1, 1e-6);
1234 for (row, &fd_val) in fd.iter().enumerate() {
1235 let expected = flux[row] * t_inner[row] + background[row];
1236 assert!(
1237 (jac.get(row, 0) - expected).abs() < 1e-9,
1238 "aliased ∂/∂α at row {row}: analytic {}, expected {expected}",
1239 jac.get(row, 0),
1240 );
1241 assert!(
1242 (jac.get(row, 0) - fd_val).abs() < 1e-3,
1243 "aliased ∂/∂α at row {row}: analytic {}, FD {fd_val}",
1244 jac.get(row, 0),
1245 );
1246 }
1247 }
1248
1249 struct Decay {
1250 t: Vec<f64>,
1251 jacobian_factor: f64,
1252 }
1253
1254 impl FitModel for Decay {
1255 fn evaluate(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
1256 Ok(self
1257 .t
1258 .iter()
1259 .map(|&t| params[0] * (-params[1] * t).exp())
1260 .collect())
1261 }
1262
1263 fn analytical_jacobian(
1264 &self,
1265 params: &[f64],
1266 free_param_indices: &[usize],
1267 _y_current: &[f64],
1268 ) -> Option<FlatMatrix> {
1269 let mut jacobian = FlatMatrix::zeros(self.t.len(), free_param_indices.len());
1270 for (row, &t) in self.t.iter().enumerate() {
1271 let e = (-params[1] * t).exp();
1272 for (col, &index) in free_param_indices.iter().enumerate() {
1273 let slope = [e, -params[0] * t * e][index];
1274 *jacobian.get_mut(row, col) = slope * self.jacobian_factor;
1275 }
1276 }
1277 Some(jacobian)
1278 }
1279 }
1280
1281 fn decay(jacobian_factor: f64) -> Decay {
1282 Decay {
1283 t: (0..25).map(|i| 0.2 * f64::from(i)).collect(),
1284 jacobian_factor,
1285 }
1286 }
1287
1288 fn decay_params(a: f64, b: f64, a_upper: f64) -> ParameterSet {
1289 ParameterSet::new(vec![
1290 FitParameter {
1291 name: "a".into(),
1292 value: a,
1293 lower: 0.0,
1294 upper: a_upper,
1295 fixed: false,
1296 },
1297 FitParameter::non_negative("b", b),
1298 ])
1299 }
1300
1301 fn fit_decay(
1302 model: &Decay,
1303 observed: &[f64],
1304 start: (f64, f64),
1305 max_iter: usize,
1306 ) -> PoissonResult {
1307 let mut params = decay_params(start.0, start.1, f64::INFINITY);
1308 let config = PoissonConfig {
1309 max_iter,
1310 ..PoissonConfig::default()
1311 };
1312 poisson_fit(model, observed, &mut params, &config).unwrap()
1313 }
1314
1315 #[test]
1316 fn half_deviance_matches_high_precision_values() {
1317 for (obs, mean, exact) in [
1318 (1_000_001.0, 1_000_000.0, 4.999_998_333_334_167e-7),
1319 (3.0, 2.5, 0.046_964_670_381_863_88),
1320 (0.0, 2.5, 2.5),
1321 (1.0e308, 9.0e307, 5.360_515_657_826_301e305),
1322 (1.7e308, 1.65e308, 7.500_373_544_579_622e304),
1323 (1.0, 1.0e-310, 712.801_378_828_154_2),
1324 ] {
1325 let got = half_deviance(obs, mean);
1326 assert!(
1327 ((got - exact) / exact).abs() < 1e-12,
1328 "{obs}, {mean}: {got}"
1329 );
1330 }
1331 }
1332
1333 #[test]
1334 fn inputs_that_are_not_counts_for_this_model_are_refused() {
1335 let model = decay(1.0);
1336 let observed = model.evaluate(&[100.0, 1.5]).unwrap();
1337 let fit = |model: &dyn FitModel, observed: &[f64]| {
1338 poisson_fit(
1339 model,
1340 observed,
1341 &mut decay_params(50.0, 1.0, f64::INFINITY),
1342 &PoissonConfig::default(),
1343 )
1344 };
1345 let mut bad = observed.clone();
1346 bad[3] = f64::NAN;
1347 assert!(fit(&model, &bad).is_err());
1348 bad[3] = -1.0;
1349 assert!(fit(&model, &bad).is_err());
1350 assert!(fit(&model, &observed[1..]).is_err());
1351 let empty = Decay {
1352 t: vec![],
1353 jacobian_factor: 1.0,
1354 };
1355 assert!(fit(&empty, &[]).is_err());
1356 let no_jacobian = ExponentialModel {
1357 x: model.t.clone(),
1358 flux: vec![100.0; model.t.len()],
1359 };
1360 assert!(fit(&no_jacobian, &observed).is_err());
1361 }
1362
1363 #[test]
1364 fn a_start_with_a_zero_prediction_is_not_converged() {
1365 let model = decay(1.0);
1366 let observed = model.evaluate(&[100.0, 1.5]).unwrap();
1367 let result = fit_decay(&model, &observed, (0.0, 1.0), 200);
1368 assert!(!result.converged && result.iterations == 0, "{result:?}");
1369 }
1370
1371 #[test]
1372 fn a_non_finite_slope_ends_the_fit_unconverged() {
1373 let model = decay(f64::NAN);
1374 let observed = decay(1.0).evaluate(&[100.0, 1.5]).unwrap();
1375 let result = fit_decay(&model, &observed, (50.0, 1.0), 200);
1376 assert!(
1377 !result.converged && result.uncertainties.is_none(),
1378 "{result:?}"
1379 );
1380 }
1381
1382 #[test]
1383 fn a_fit_whose_step_cannot_lower_the_deviance_is_not_converged() {
1384 let model = decay(-1.0);
1385 let observed = decay(1.0).evaluate(&[100.0, 1.5]).unwrap();
1386 let result = fit_decay(&model, &observed, (50.0, 1.0), 200);
1387 assert!(
1388 !result.converged && result.iterations == 0 && result.uncertainties.is_none(),
1389 "{result:?}"
1390 );
1391 }
1392
1393 #[test]
1394 fn a_start_outside_the_bounds_ends_inside_them() {
1395 let model = decay(1.0);
1396 let observed = model.evaluate(&[100.0, 1.5]).unwrap();
1397 let mut params = decay_params(100.0, 1.5, 80.0);
1398 let result =
1399 poisson_fit(&model, &observed, &mut params, &PoissonConfig::default()).unwrap();
1400 assert!(result.converged && result.params[0] == 80.0, "{result:?}");
1401 assert_eq!(result.on_bound, vec![true, false]);
1402 }
1403
1404 #[test]
1405 fn a_fit_that_reaches_the_minimum_on_its_last_allowed_step_has_converged() {
1406 let model = decay(1.0);
1407 let observed = model.evaluate(&[100.0, 1.5]).unwrap();
1408 let steps = fit_decay(&model, &observed, (20.0, 0.3), 200).iterations;
1409 assert!(steps > 1);
1410 assert!(fit_decay(&model, &observed, (20.0, 0.3), steps).converged);
1411 assert!(!fit_decay(&model, &observed, (20.0, 0.3), steps - 1).converged);
1412 }
1413
1414 struct Scaled {
1415 x: Vec<f64>,
1416 slope: f64,
1417 tiny: f64,
1418 }
1419
1420 impl FitModel for Scaled {
1421 fn evaluate(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
1422 Ok(self
1423 .x
1424 .iter()
1425 .map(|&x| 100.0 * (self.slope * params[0] * x).exp() + self.tiny * params[1] * x)
1426 .collect())
1427 }
1428
1429 fn analytical_jacobian(
1430 &self,
1431 params: &[f64],
1432 free_param_indices: &[usize],
1433 _y_current: &[f64],
1434 ) -> Option<FlatMatrix> {
1435 let mut jacobian = FlatMatrix::zeros(self.x.len(), free_param_indices.len());
1436 for (row, &x) in self.x.iter().enumerate() {
1437 let slopes = [
1438 100.0 * self.slope * x * (self.slope * params[0] * x).exp(),
1439 self.tiny * x,
1440 ];
1441 for (col, &index) in free_param_indices.iter().enumerate() {
1442 *jacobian.get_mut(row, col) = slopes[index];
1443 }
1444 }
1445 Some(jacobian)
1446 }
1447 }
1448
1449 fn fit_scaled(slope: f64, tiny: f64) -> PoissonResult {
1450 let model = Scaled {
1451 x: vec![1.0, 2.0, 3.0],
1452 slope,
1453 tiny,
1454 };
1455 let observed = model.evaluate(&[0.3 / slope, 0.0]).unwrap();
1456 let mut params = ParameterSet::new(vec![
1457 FitParameter::unbounded("theta", 0.0),
1458 FitParameter::unbounded("weak", 0.0),
1459 ]);
1460 poisson_fit(&model, &observed, &mut params, &PoissonConfig::default()).unwrap()
1461 }
1462
1463 #[test]
1464 fn a_slope_too_large_to_square_is_fitted() {
1465 let result = fit_scaled(1.0e160, 1.0);
1466 assert!(
1467 result.converged && result.deviance < NEWTON_DECREMENT_TOL,
1468 "{result:?}"
1469 );
1470 }
1471
1472 #[test]
1473 fn a_slope_too_small_to_square_does_not_fake_convergence() {
1474 let result = fit_scaled(1.0, 1.0e-310);
1475 assert!(
1476 !result.converged || result.deviance < NEWTON_DECREMENT_TOL,
1477 "{result:?}"
1478 );
1479 }
1480
1481 struct Line {
1482 x: Vec<f64>,
1483 }
1484
1485 impl FitModel for Line {
1486 fn evaluate(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
1487 Ok(self.x.iter().map(|&x| 1.0 + params[0] * x).collect())
1488 }
1489
1490 fn analytical_jacobian(
1491 &self,
1492 _params: &[f64],
1493 free_param_indices: &[usize],
1494 _y_current: &[f64],
1495 ) -> Option<FlatMatrix> {
1496 let mut jacobian = FlatMatrix::zeros(self.x.len(), free_param_indices.len());
1497 jacobian.data.copy_from_slice(&self.x);
1498 Some(jacobian)
1499 }
1500 }
1501
1502 #[test]
1503 fn predictions_stay_positive_where_nothing_was_counted() {
1504 let model = Line {
1505 x: vec![1.0, 2.0, 3.0],
1506 };
1507 let mut params = ParameterSet::new(vec![FitParameter::unbounded("a", 0.0)]);
1508 let result =
1509 poisson_fit(&model, &[0.0; 3], &mut params, &PoissonConfig::default()).unwrap();
1510 let predicted = model.evaluate(&result.params).unwrap();
1511 assert!(predicted.iter().all(|&mean| mean > 0.0), "{result:?}");
1512 }
1513
1514 struct WrongShape;
1515
1516 impl FitModel for WrongShape {
1517 fn evaluate(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
1518 Ok(vec![params[0]; 3])
1519 }
1520
1521 fn analytical_jacobian(
1522 &self,
1523 _params: &[f64],
1524 free_param_indices: &[usize],
1525 _y_current: &[f64],
1526 ) -> Option<FlatMatrix> {
1527 Some(FlatMatrix::zeros(2, free_param_indices.len()))
1528 }
1529 }
1530
1531 #[test]
1532 fn a_jacobian_of_the_wrong_shape_is_refused() {
1533 let mut params = ParameterSet::new(vec![FitParameter::non_negative("a", 1.0)]);
1534 let result = poisson_fit(
1535 &WrongShape,
1536 &[1.0; 3],
1537 &mut params,
1538 &PoissonConfig::default(),
1539 );
1540 assert!(
1541 matches!(result, Err(FittingError::LengthMismatch { .. })),
1542 "{result:?}"
1543 );
1544 }
1545
1546 #[test]
1547 fn inverted_or_nan_bounds_are_refused() {
1548 let model = decay(1.0);
1549 let observed = model.evaluate(&[100.0, 1.5]).unwrap();
1550 for (lower, upper) in [
1551 (1.0, 0.0),
1552 (f64::NAN, 1.0),
1553 (0.0, f64::NAN),
1554 (f64::INFINITY, f64::INFINITY),
1555 (f64::NEG_INFINITY, f64::NEG_INFINITY),
1556 ] {
1557 let mut params = ParameterSet::new(vec![
1558 FitParameter {
1559 name: "a".into(),
1560 value: 50.0,
1561 lower,
1562 upper,
1563 fixed: false,
1564 },
1565 FitParameter::non_negative("b", 1.0),
1566 ]);
1567 let result = poisson_fit(&model, &observed, &mut params, &PoissonConfig::default());
1568 assert!(
1569 matches!(result, Err(FittingError::InvalidConfig(_))),
1570 "{lower}, {upper}"
1571 );
1572 }
1573 }
1574
1575 struct Additive;
1576
1577 impl FitModel for Additive {
1578 fn evaluate(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
1579 Ok(vec![2.0 * params[0], 1.0 - params[0]])
1580 }
1581
1582 fn analytical_jacobian(
1583 &self,
1584 _params: &[f64],
1585 free_param_indices: &[usize],
1586 _y_current: &[f64],
1587 ) -> Option<FlatMatrix> {
1588 let mut jacobian = FlatMatrix::zeros(2, free_param_indices.len());
1589 jacobian.data.copy_from_slice(&[2.0, -1.0]);
1590 Some(jacobian)
1591 }
1592 }
1593
1594 #[test]
1595 fn a_bin_predicted_zero_keeps_its_slope_in_the_gradient() {
1596 let mut params = ParameterSet::new(vec![FitParameter {
1597 name: "theta".into(),
1598 value: 0.0,
1599 lower: 0.0,
1600 upper: 1.0,
1601 fixed: false,
1602 }]);
1603 let result = poisson_fit(
1604 &Additive,
1605 &[0.0, 0.0],
1606 &mut params,
1607 &PoissonConfig::default(),
1608 )
1609 .unwrap();
1610 assert!(
1611 result.converged && result.params[0] == 0.0 && result.on_bound == vec![true],
1612 "{result:?}"
1613 );
1614 }
1615
1616 struct Line2 {
1617 offset: [f64; 2],
1618 slope: [f64; 2],
1619 }
1620
1621 impl FitModel for Line2 {
1622 fn evaluate(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
1623 Ok((0..2)
1624 .map(|i| self.offset[i] + self.slope[i] * params[0])
1625 .collect())
1626 }
1627
1628 fn analytical_jacobian(
1629 &self,
1630 _params: &[f64],
1631 free_param_indices: &[usize],
1632 _y_current: &[f64],
1633 ) -> Option<FlatMatrix> {
1634 let mut jacobian = FlatMatrix::zeros(2, free_param_indices.len());
1635 jacobian.data.copy_from_slice(&self.slope);
1636 Some(jacobian)
1637 }
1638 }
1639
1640 fn bounded(value: f64, upper: f64) -> ParameterSet {
1641 ParameterSet::new(vec![FitParameter {
1642 name: "theta".into(),
1643 value,
1644 lower: 0.0,
1645 upper,
1646 fixed: false,
1647 }])
1648 }
1649
1650 #[test]
1651 fn a_zero_bin_slope_enters_the_convergence_test() {
1652 let model = Line2 {
1653 offset: [0.0, 4.0],
1654 slope: [1.0, -4.0],
1655 };
1656 let result = poisson_fit(
1657 &model,
1658 &[0.0, 3.0],
1659 &mut bounded(0.0, 1.5),
1660 &PoissonConfig::default(),
1661 )
1662 .unwrap();
1663 assert!(
1664 result.converged && result.iterations == 0 && result.params[0] == 0.0,
1665 "{result:?}"
1666 );
1667 }
1668
1669 struct Affine {
1670 offset: Vec<f64>,
1671 jacobian: Vec<Vec<f64>>,
1672 }
1673
1674 impl FitModel for Affine {
1675 fn evaluate(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
1676 Ok(self
1677 .offset
1678 .iter()
1679 .zip(&self.jacobian)
1680 .map(|(offset, row)| {
1681 offset + row.iter().zip(params).map(|(j, p)| j * p).sum::<f64>()
1682 })
1683 .collect())
1684 }
1685
1686 fn analytical_jacobian(
1687 &self,
1688 _params: &[f64],
1689 free_param_indices: &[usize],
1690 _y_current: &[f64],
1691 ) -> Option<FlatMatrix> {
1692 let mut jacobian = FlatMatrix::zeros(self.offset.len(), free_param_indices.len());
1693 jacobian.data.copy_from_slice(&self.jacobian.concat());
1694 Some(jacobian)
1695 }
1696 }
1697
1698 fn two_free() -> ParameterSet {
1699 ParameterSet::new(vec![
1700 FitParameter::unbounded("a", 0.0),
1701 FitParameter::unbounded("b", 0.0),
1702 ])
1703 }
1704
1705 fn at_the_start() -> PoissonConfig {
1706 PoissonConfig {
1707 max_iter: 0,
1708 ..PoissonConfig::default()
1709 }
1710 }
1711
1712 #[test]
1713 fn a_weak_direction_keeps_its_share_of_the_newton_decrement() {
1714 let model = Affine {
1715 offset: vec![1.0; 2],
1716 jacobian: vec![vec![1.0, 1.0], vec![1.0e-14, -1.0e-14]],
1717 };
1718 for observed in [[0.999, 0.998999], [0.999, 0.999001]] {
1719 let decrement = 0.5
1720 * observed
1721 .iter()
1722 .map(|y: &f64| (1.0 - y).powi(2))
1723 .sum::<f64>();
1724 let result = poisson_fit(&model, &observed, &mut two_free(), &at_the_start()).unwrap();
1725 assert_eq!(
1726 result.converged,
1727 decrement < NEWTON_DECREMENT_TOL,
1728 "{decrement:e}: {result:?}"
1729 );
1730 }
1731 }
1732
1733 #[test]
1734 fn a_zero_bin_slope_enters_every_direction_of_the_decrement() {
1735 let jacobian = vec![vec![1.0, 2.0], vec![1.0, 3.0], vec![2.0, -1.0]];
1736 let offset: Vec<f64> = vec![0.0, 4.0, 9.0];
1737 let root = [offset[1].sqrt(), offset[2].sqrt()];
1738 let w = [
1739 [jacobian[1][0] / root[0], jacobian[1][1] / root[0]],
1740 [jacobian[2][0] / root[1], jacobian[2][1] / root[1]],
1741 ];
1742 let information = |a: usize, b: usize| w[0][a] * w[0][b] + w[1][a] * w[1][b];
1743 let inverse_00 =
1744 information(1, 1) / (information(0, 0) * information(1, 1) - information(0, 1).powi(2));
1745 let transpose_det = w[0][0] * w[1][1] - w[1][0] * w[0][1];
1746 for decrement in [0.99e-6_f64, 1.01e-6] {
1747 let gradient = [(2.0 * decrement / inverse_00).sqrt(), 0.0];
1748 let rhs = [gradient[0] - jacobian[0][0], gradient[1] - jacobian[0][1]];
1749 let residual = [
1750 (rhs[0] * w[1][1] - w[1][0] * rhs[1]) / transpose_det,
1751 (w[0][0] * rhs[1] - w[0][1] * rhs[0]) / transpose_det,
1752 ];
1753 let observed = [
1754 0.0,
1755 offset[1] - residual[0] * root[0],
1756 offset[2] - residual[1] * root[1],
1757 ];
1758 let model = Affine {
1759 offset: offset.clone(),
1760 jacobian: jacobian.clone(),
1761 };
1762 let result = poisson_fit(&model, &observed, &mut two_free(), &at_the_start()).unwrap();
1763 assert_eq!(
1764 result.converged,
1765 decrement < NEWTON_DECREMENT_TOL,
1766 "{decrement:e}: {result:?}"
1767 );
1768 }
1769 }
1770
1771 #[test]
1772 fn a_fit_with_hundreds_of_parameters_has_the_error_bars_of_its_inverse_information() {
1773 use rand::SeedableRng;
1774 use rand_chacha::ChaCha12Rng;
1775 use rand_distr::{Distribution, StandardNormal};
1776 let (bins, count, level) = (300, 150, 1.0e4);
1777 let mut rng = ChaCha12Rng::seed_from_u64(806);
1778 let jacobian: Vec<Vec<f64>> = (0..bins)
1779 .map(|_| {
1780 (0..count)
1781 .map(|_| StandardNormal.sample(&mut rng))
1782 .collect()
1783 })
1784 .collect();
1785 let model = Affine {
1786 offset: vec![level; bins],
1787 jacobian: jacobian.clone(),
1788 };
1789 let mut params = ParameterSet::new(
1790 (0..count)
1791 .map(|j| FitParameter::unbounded(format!("c{j}"), 0.0))
1792 .collect(),
1793 );
1794 let result = poisson_fit(
1795 &model,
1796 &vec![level; bins],
1797 &mut params,
1798 &PoissonConfig::default(),
1799 )
1800 .unwrap();
1801 assert!(result.converged && result.iterations == 0, "{result:?}");
1802 let mut cholesky = vec![vec![0.0; count]; count];
1803 for a in 0..count {
1804 for b in 0..=a {
1805 let information: f64 = jacobian.iter().map(|row| row[a] * row[b] / level).sum();
1806 let rest =
1807 information - (0..b).map(|c| cholesky[a][c] * cholesky[b][c]).sum::<f64>();
1808 cholesky[a][b] = if a == b {
1809 rest.sqrt()
1810 } else {
1811 rest / cholesky[b][b]
1812 };
1813 }
1814 }
1815 for (j, error) in result.uncertainties.unwrap().into_iter().enumerate() {
1816 let mut solved = vec![0.0; count];
1817 for a in 0..count {
1818 let unit = if a == j { 1.0 } else { 0.0 };
1819 solved[a] = (unit - (0..a).map(|c| cholesky[a][c] * solved[c]).sum::<f64>())
1820 / cholesky[a][a];
1821 }
1822 let expected = solved.iter().map(|v| v * v).sum::<f64>().sqrt();
1823 let error = error.unwrap();
1824 assert!(
1825 (error / expected - 1.0).abs() < 1e-10,
1826 "{j}: {error} vs {expected}"
1827 );
1828 }
1829 }
1830
1831 struct SilentZeroBin;
1832
1833 impl FitModel for SilentZeroBin {
1834 fn evaluate(&self, _params: &[f64]) -> Result<Vec<f64>, FittingError> {
1835 Ok(vec![0.0])
1836 }
1837
1838 fn analytical_jacobian(
1839 &self,
1840 _params: &[f64],
1841 free_param_indices: &[usize],
1842 _y_current: &[f64],
1843 ) -> Option<FlatMatrix> {
1844 let mut jacobian = FlatMatrix::zeros(1, free_param_indices.len());
1845 jacobian.data[0] = f64::NAN;
1846 Some(jacobian)
1847 }
1848 }
1849
1850 #[test]
1851 fn a_non_finite_slope_in_a_zero_bin_ends_the_fit_unconverged() {
1852 let mut params = ParameterSet::new(vec![FitParameter::unbounded("theta", 0.0)]);
1853 let result = poisson_fit(
1854 &SilentZeroBin,
1855 &[0.0],
1856 &mut params,
1857 &PoissonConfig::default(),
1858 )
1859 .unwrap();
1860 assert!(!result.converged, "{result:?}");
1861 }
1862
1863 struct Wall;
1864
1865 impl FitModel for Wall {
1866 fn evaluate(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
1867 let x = params[0];
1868 Ok(vec![(100.0 - x + (x - 80.0).max(0.0).powi(2)).exp()])
1869 }
1870
1871 fn analytical_jacobian(
1872 &self,
1873 params: &[f64],
1874 free_param_indices: &[usize],
1875 _y_current: &[f64],
1876 ) -> Option<FlatMatrix> {
1877 let x = params[0];
1878 let mean = (100.0 - x + (x - 80.0).max(0.0).powi(2)).exp();
1879 let mut jacobian = FlatMatrix::zeros(1, free_param_indices.len());
1880 jacobian.data[0] = mean * (2.0 * (x - 80.0).max(0.0) - 1.0);
1881 Some(jacobian)
1882 }
1883 }
1884
1885 #[test]
1886 fn damping_recovers_after_many_accepted_steps_to_reach_the_minimum() {
1887 let mut params = ParameterSet::new(vec![FitParameter::unbounded("x", 0.0)]);
1888 let result = poisson_fit(&Wall, &[0.0], &mut params, &PoissonConfig::default()).unwrap();
1889 assert!((result.params[0] - 80.5).abs() < 1e-3, "{result:?}");
1890 }
1891
1892 #[test]
1893 fn non_finite_parameter_values_are_refused() {
1894 let model = decay(1.0);
1895 let observed = model.evaluate(&[100.0, 1.5]).unwrap();
1896 for (a, b) in [(f64::NAN, 1.0), (50.0, f64::INFINITY)] {
1897 let mut params = ParameterSet::new(vec![
1898 FitParameter::non_negative("a", a),
1899 FitParameter::fixed("b", b),
1900 ]);
1901 let result = poisson_fit(&model, &observed, &mut params, &PoissonConfig::default());
1902 assert!(
1903 matches!(result, Err(FittingError::InvalidConfig(_))),
1904 "{a}, {b}"
1905 );
1906 }
1907 }
1908
1909 struct Shrinking;
1910
1911 impl FitModel for Shrinking {
1912 fn evaluate(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
1913 let bins = if params[0] < 0.5 { 3 } else { 2 };
1914 Ok(vec![1.0 + params[0]; bins])
1915 }
1916
1917 fn analytical_jacobian(
1918 &self,
1919 params: &[f64],
1920 free_param_indices: &[usize],
1921 _y_current: &[f64],
1922 ) -> Option<FlatMatrix> {
1923 let bins = if params[0] < 0.5 { 3 } else { 2 };
1924 let mut jacobian = FlatMatrix::zeros(bins, free_param_indices.len());
1925 jacobian.data.fill(1.0);
1926 Some(jacobian)
1927 }
1928 }
1929
1930 #[test]
1931 fn a_trial_prediction_of_the_wrong_length_is_rejected() {
1932 let mut params = ParameterSet::new(vec![FitParameter::non_negative("theta", 0.0)]);
1933 let result = poisson_fit(
1934 &Shrinking,
1935 &[3.0; 3],
1936 &mut params,
1937 &PoissonConfig::default(),
1938 )
1939 .unwrap();
1940 assert_eq!(
1941 Shrinking.evaluate(&result.params).unwrap().len(),
1942 3,
1943 "{result:?}"
1944 );
1945 }
1946}