Skip to main content

nereids_fitting/
poisson.rs

1//! Poisson-likelihood fitting of counts.
2//!
3//! Minimizes half the Poisson deviance plus a Gaussian prior on each block
4//! `θₚ` of measured parameters,
5//!
6//! ```text
7//! D(θ) = Σᵢ [μᵢ(θ) − yᵢ + yᵢ ln(yᵢ / μᵢ(θ))] + ½ Σₚ (θₚ − mₚ)ᵀ Cₚ⁻¹ (θₚ − mₚ)
8//! ```
9//!
10//! within box bounds, by projected Levenberg–Marquardt steps in the Fisher
11//! metric, for models with an analytical Jacobian.  A prior adds `Cₚ⁻¹` to the
12//! information, as SAMMY's Bayes update `M′⁻¹ = M⁻¹ + GᵀV⁻¹G` adds the data to
13//! a prior covariance `M` (SAMMY manual, `docs/tex/fitting-procedure.tex`,
14//! eq. `bayes-eq-m-prime`).
15
16use faer::dyn_stack::{MemBuffer, MemStack};
17use faer::linalg::svd::{ComputeSvdVectors, svd, svd_scratch};
18use faer::{Mat, Par, Side};
19
20use crate::error::FittingError;
21use crate::lm::{FitModel, FlatMatrix};
22use crate::parameters::{FitParameter, ParameterSet};
23
24/// Configuration for the Poisson solvers.
25#[derive(Debug, Clone)]
26pub struct PoissonConfig {
27    /// Maximum number of steps.
28    pub max_iter: usize,
29    /// Whether to compute the covariance and error bars after convergence.
30    pub compute_covariance: bool,
31}
32
33impl Default for PoissonConfig {
34    fn default() -> Self {
35        Self {
36            max_iter: 200,
37            compute_covariance: true,
38        }
39    }
40}
41
42/// A Gaussian prior on free parameters: a measurement of them made
43/// elsewhere, with mean `m` and covariance `C = LLᵀ`.
44#[derive(Debug, Clone, PartialEq)]
45pub struct Prior {
46    pub(crate) parameters: Vec<usize>,
47    pub(crate) mean: Vec<f64>,
48    pub(crate) factor: Vec<f64>,
49}
50
51impl Prior {
52    /// A measurement `mean ± sd` of the parameter at index `parameter` in the
53    /// [`ParameterSet`]; [`poisson_fit`] refuses a mean that is not finite or
54    /// an sd that is not finite and positive.
55    pub fn measured(parameter: usize, mean: f64, sd: f64) -> Self {
56        Self {
57            parameters: vec![parameter],
58            mean: vec![mean],
59            factor: vec![sd],
60        }
61    }
62
63    /// A joint measurement of the parameters at `parameters` in the
64    /// [`ParameterSet`], with `mean` and `covariance` in that order.
65    ///
66    /// # Errors
67    /// `FittingError::LengthMismatch` if `mean` or `covariance` does not
68    /// match `parameters`; `FittingError::InvalidConfig` if `parameters` is
69    /// empty or lists a parameter twice, `mean` is not finite, or `covariance`
70    /// is not finite, not symmetric to 1e-12 of `√(Cᵢᵢ Cⱼⱼ)`, or not positive
71    /// definite with the condition number of its correlation matrix at most
72    /// 1e10; `FittingError::EvaluationFailed` if the eigendecomposition of
73    /// that matrix fails.
74    pub fn correlated(
75        parameters: &[usize],
76        mean: &[f64],
77        covariance: &FlatMatrix,
78    ) -> Result<Self, FittingError> {
79        let k = parameters.len();
80        if k == 0 {
81            return Err(FittingError::InvalidConfig(
82                "a prior needs at least one parameter".into(),
83            ));
84        }
85        for (expected, actual, field) in [
86            (k, mean.len(), "prior mean"),
87            (k, covariance.nrows, "prior covariance rows"),
88            (k, covariance.ncols, "prior covariance columns"),
89            (k * k, covariance.data.len(), "prior covariance entries"),
90        ] {
91            if actual != expected {
92                return Err(FittingError::LengthMismatch {
93                    expected,
94                    actual,
95                    field,
96                });
97            }
98        }
99        if !mean.iter().all(|m| m.is_finite())
100            || (0..k).any(|i| parameters[..i].contains(&parameters[i]))
101        {
102            return Err(FittingError::InvalidConfig(format!(
103                "a prior needs distinct parameters and a finite mean; got {parameters:?} and \
104                 {mean:?}"
105            )));
106        }
107        let sd: Vec<f64> = (0..k).map(|i| covariance.get(i, i).sqrt()).collect();
108        if !(covariance.data.iter().all(|c| c.is_finite())
109            && sd.iter().all(|&s| s > 0.0)
110            && symmetric(k, |i, j| covariance.get(i, j)))
111        {
112            return Err(FittingError::InvalidConfig(format!(
113                "a prior covariance must be finite and symmetric with a positive diagonal; \
114                 got {covariance:?}"
115            )));
116        }
117        let correlation = Mat::from_fn(k, k, |i, j| {
118            if i == j {
119                1.0
120            } else {
121                covariance.get(i, j) / sd[i] / sd[j]
122            }
123        });
124        let eigenvalues = correlation
125            .self_adjoint_eigenvalues(Side::Lower)
126            .map_err(|e| FittingError::EvaluationFailed(format!("{e:?}")))?;
127        let (smallest, largest) = (eigenvalues[0], eigenvalues[k - 1]);
128        if !(smallest > 0.0 && largest <= MAX_PRIOR_CONDITION * smallest) {
129            return Err(FittingError::InvalidConfig(format!(
130                "a prior covariance must be positive definite with a correlation condition \
131                 number within {MAX_PRIOR_CONDITION:e}; its correlation eigenvalues are \
132                 {eigenvalues:?}"
133            )));
134        }
135        Ok(Self::factored(parameters, mean, covariance))
136    }
137
138    pub(crate) fn factored(parameters: &[usize], mean: &[f64], covariance: &FlatMatrix) -> Self {
139        let k = parameters.len();
140        let sd: Vec<f64> = (0..k).map(|i| covariance.get(i, i).sqrt()).collect();
141        let correlation = |i: usize, j: usize| {
142            if i == j {
143                1.0
144            } else {
145                covariance.get(i, j) / sd[i] / sd[j]
146            }
147        };
148        let mut factor = vec![0.0; k * k];
149        for i in 0..k {
150            for j in 0..=i {
151                let dot: f64 = (0..j).map(|l| factor[i * k + l] * factor[j * k + l]).sum();
152                factor[i * k + j] = if i == j {
153                    (correlation(i, i) - dot).sqrt()
154                } else {
155                    (correlation(i, j) - dot) / factor[j * k + j]
156                };
157            }
158        }
159        for i in 0..k {
160            for j in 0..=i {
161                factor[i * k + j] *= sd[i];
162            }
163        }
164        Self {
165            parameters: parameters.to_vec(),
166            mean: mean.to_vec(),
167            factor,
168        }
169    }
170
171    pub(crate) fn covariance(&self, i: usize, j: usize) -> f64 {
172        let k = self.parameters.len();
173        (0..=i.min(j))
174            .map(|l| self.factor[i * k + l] * self.factor[j * k + l])
175            .sum()
176    }
177
178    pub(crate) fn is_valid(&self) -> bool {
179        let k = self.parameters.len();
180        self.mean.iter().all(|m| m.is_finite())
181            && self.factor.iter().all(|f| f.is_finite())
182            && (0..k).all(|j| self.factor[j * k + j] > 0.0)
183    }
184
185    pub(crate) fn whiten(&self, v: &mut [f64]) {
186        let k = self.parameters.len();
187        for i in 0..k {
188            let dot: f64 = (0..i).map(|j| self.factor[i * k + j] * v[j]).sum();
189            v[i] = (v[i] - dot) / self.factor[i * k + i];
190        }
191    }
192
193    fn residual(&self, values: &[f64]) -> Vec<f64> {
194        let mut difference: Vec<f64> = self
195            .parameters
196            .iter()
197            .zip(&self.mean)
198            .map(|(&p, m)| values[p] - m)
199            .collect();
200        self.whiten(&mut difference);
201        difference
202    }
203
204    fn inverse_factor(&self) -> Vec<f64> {
205        let k = self.parameters.len();
206        let mut inverse = vec![0.0; k * k];
207        for j in 0..k {
208            let mut column = vec![0.0; k];
209            column[j] = 1.0;
210            self.whiten(&mut column);
211            for (i, c) in column.iter().enumerate() {
212                inverse[i * k + j] = *c;
213            }
214        }
215        inverse
216    }
217}
218
219pub(crate) fn symmetric(k: usize, entry: impl Fn(usize, usize) -> f64) -> bool {
220    (0..k).all(|i| {
221        (0..i).all(|j| {
222            let scale = (entry(i, i) * entry(j, j)).abs().sqrt();
223            (entry(j, i) - entry(i, j)).abs() <= 1e-12 * scale
224        })
225    })
226}
227
228/// Result of [`poisson_fit`].
229#[derive(Debug, Clone)]
230pub struct PoissonResult {
231    /// Half the Poisson deviance of the counts at `params`, without the
232    /// priors.
233    pub deviance: f64,
234    /// Number of steps taken.
235    pub iterations: usize,
236    /// Whether the expected-information Newton decrement fell below 1e-6
237    /// inside the bounds: within about 0.0014 standard errors of a minimum
238    /// where the deviance is quadratic there; see [`poisson_fit`].
239    pub converged: bool,
240    /// Final parameter values (all parameters, including fixed).
241    pub params: Vec<f64>,
242    /// Covariance of the free parameters; the rows and columns of a
243    /// parameter without an error bar are NaN.  `None` when the fit did not
244    /// converge or covariance computation is disabled.
245    pub covariance: Option<FlatMatrix>,
246    /// Standard error of each free parameter; `None` for a parameter on a
247    /// bound or with a component along a direction the data do not
248    /// determine, larger than the SVD's rounding for that parameter.  `None`
249    /// overall when `covariance` is.
250    pub uncertainties: Option<Vec<Option<f64>>>,
251    /// Whether each free parameter ended on one of its bounds.
252    pub on_bound: Vec<bool>,
253    /// Each count's leverage: the diagonal of the hat matrix of the free
254    /// parameters off their bounds.  The leverages sum to the number of
255    /// directions in those parameters the counts and priors determine, less
256    /// the share the priors take.  `None` when `covariance` is `None` or its
257    /// decomposition fails.
258    pub leverage: Option<Vec<f64>>,
259    /// The free parameters' Gaussian without their bounds; see [`Unbounded`].
260    /// `None` when `covariance` is, when the decomposition over every free
261    /// parameter fails, or when a bin predicted zero adds slope to the
262    /// gradient of a parameter on a bound.
263    pub unbounded: Option<Unbounded>,
264}
265
266/// The expected-information (Fisher scoring) expansion of the objective at
267/// the fit over every free parameter, bounds left out: covariance `F⁻¹`, `F`
268/// the expected information of all free parameters, and mean `θ̂ − F⁻¹g`, `g`
269/// the objective's gradient at the fit, which the convergence test makes
270/// negligible off the bounds.  Both are taken over the directions the counts
271/// and priors determine; the mean and the rows and columns of a parameter
272/// with a component along a direction they do not determine are NaN.  With
273/// none on a bound the covariance is [`PoissonResult::covariance`].
274#[derive(Debug, Clone)]
275pub struct Unbounded {
276    pub mean: Vec<f64>,
277    pub covariance: FlatMatrix,
278}
279
280/// The expected-information Newton decrement below which [`poisson_fit`]
281/// reports a minimum.
282pub const NEWTON_DECREMENT_TOL: f64 = 1e-6;
283
284const DEGENERATE_EIGENVALUE: f64 = 1e-12;
285
286const MAX_PRIOR_CONDITION: f64 = 1e-2 / DEGENERATE_EIGENVALUE;
287
288const MAX_REJECTIONS: usize = 60;
289
290const INITIAL_DAMPING: f64 = 1e-3;
291
292const DAMPING_FACTOR: f64 = 10.0;
293
294/// `obs·ln(obs/mean) + mean − obs`, by C. Loader's `bd0` ("Fast and
295/// accurate computation of binomial probabilities", 2000): a series in
296/// `v = (obs − mean)/(obs + mean)` when `|obs − mean| < 0.1 (obs + mean)`.
297/// For `obs ≥ 0` and `mean > 0`; 0 when both are 0, and +∞ when only `mean`
298/// is.
299pub fn half_deviance(obs: f64, mean: f64) -> f64 {
300    let half_sum = obs / 2.0 + mean / 2.0;
301    let difference = obs - mean;
302    if difference.abs() < 0.2 * half_sum {
303        let v = difference / 2.0 / half_sum;
304        let mut sum = difference * v;
305        let mut term = obs * (2.0 * v);
306        let mut j = 1.0;
307        loop {
308            term *= v * v;
309            let next = sum + term / (2.0 * j + 1.0);
310            if next == sum {
311                return sum;
312            }
313            sum = next;
314            j += 1.0;
315        }
316    } else if obs == 0.0 {
317        mean
318    } else {
319        obs * (obs.ln() - mean.ln()) + mean - obs
320    }
321}
322
323fn deviance(y_obs: &[f64], y_model: &[f64]) -> f64 {
324    y_obs
325        .iter()
326        .zip(y_model)
327        .map(|(&obs, &mean)| {
328            if mean > 0.0 || (mean == 0.0 && obs == 0.0) {
329                half_deviance(obs, mean)
330            } else {
331                f64::INFINITY
332            }
333        })
334        .sum()
335}
336
337fn objective(y_obs: &[f64], y_model: &[f64], priors: &[Prior], params: &ParameterSet) -> f64 {
338    let values = params.all_values();
339    deviance(y_obs, y_model)
340        + priors
341            .iter()
342            .map(|p| 0.5 * p.residual(&values).iter().map(|w| w.powi(2)).sum::<f64>())
343            .sum::<f64>()
344}
345
346struct Linearization {
347    weighted: FlatMatrix,
348    residual: Vec<f64>,
349    zero_slope: Vec<f64>,
350    gradient: Vec<f64>,
351}
352
353fn linearize(
354    model: &dyn FitModel,
355    params: &ParameterSet,
356    free: &[usize],
357    priors: &[Prior],
358    y_obs: &[f64],
359    y_model: &[f64],
360) -> Result<Linearization, FittingError> {
361    let mut weighted = model
362        .analytical_jacobian(&params.all_values(), free, y_model)
363        .ok_or_else(|| {
364            FittingError::InvalidConfig(
365                "poisson_fit needs a model with an analytical Jacobian".into(),
366            )
367        })?;
368    for (expected, actual, field) in [
369        (y_obs.len(), weighted.nrows, "analytical Jacobian rows"),
370        (free.len(), weighted.ncols, "analytical Jacobian columns"),
371    ] {
372        if expected != actual {
373            return Err(FittingError::LengthMismatch {
374                expected,
375                actual,
376                field,
377            });
378        }
379    }
380    let root: Vec<f64> = y_model.iter().map(|mean| mean.sqrt()).collect();
381    let mut zero_slope = vec![0.0; weighted.ncols];
382    for (i, &r) in root.iter().enumerate() {
383        for (j, z) in zero_slope.iter_mut().enumerate() {
384            let slope = weighted.get(i, j);
385            if r > 0.0 {
386                *weighted.get_mut(i, j) = slope / r;
387            } else {
388                *z += slope;
389                *weighted.get_mut(i, j) = 0.0;
390            }
391        }
392    }
393    let mut residual: Vec<f64> = y_obs
394        .iter()
395        .zip(y_model)
396        .zip(&root)
397        .map(|((&obs, &mean), &r)| if r > 0.0 { (mean - obs) / r } else { 0.0 })
398        .collect();
399    let mut row = weighted.nrows;
400    weighted.nrows += priors.iter().map(|p| p.parameters.len()).sum::<usize>();
401    weighted.data.resize(weighted.nrows * weighted.ncols, 0.0);
402    let values = params.all_values();
403    for prior in priors {
404        let k = prior.parameters.len();
405        let inverse = prior.inverse_factor();
406        for (i, w) in prior.residual(&values).into_iter().enumerate() {
407            for (j, parameter) in prior.parameters.iter().enumerate().take(i + 1) {
408                let col = free
409                    .iter()
410                    .position(|index| index == parameter)
411                    .expect("priors are on free parameters");
412                *weighted.get_mut(row, col) = inverse[i * k + j];
413            }
414            residual.push(w);
415            row += 1;
416        }
417    }
418    let gradient = zero_slope
419        .iter()
420        .enumerate()
421        .map(|(j, z)| {
422            z + (0..weighted.nrows)
423                .map(|i| weighted.get(i, j) * residual[i])
424                .sum::<f64>()
425        })
426        .collect();
427    Ok(Linearization {
428        weighted,
429        residual,
430        zero_slope,
431        gradient,
432    })
433}
434
435fn column_scale(weighted: &FlatMatrix, col: usize) -> (f64, f64) {
436    let peak = (0..weighted.nrows).fold(0.0_f64, |m, i| m.max(weighted.get(i, col).abs()));
437    let length = (0..weighted.nrows)
438        .map(|i| (weighted.get(i, col) / peak).powi(2))
439        .sum::<f64>()
440        .sqrt();
441    (peak, length)
442}
443
444struct Decomposition {
445    columns: Vec<usize>,
446    largest: Vec<f64>,
447    length: Vec<f64>,
448    rows: usize,
449    singular: Vec<f64>,
450    left: Mat<f64>,
451    right: Vec<Vec<f64>>,
452}
453
454impl Decomposition {
455    fn new(weighted: &FlatMatrix, columns: &[usize]) -> Option<Self> {
456        let (mut kept, mut largest, mut length) = (vec![], vec![], vec![]);
457        for &col in columns {
458            let (peak, norm) = column_scale(weighted, col);
459            if peak > 0.0 {
460                kept.push(col);
461                largest.push(peak);
462                length.push(norm);
463            }
464        }
465        let (rows, k) = (weighted.nrows, kept.len());
466        let scaled = Mat::from_fn(rows, k, |row, i| {
467            weighted.get(row, kept[i]) / largest[i] / length[i]
468        });
469        let mut values = Mat::<f64>::zeros(rows.min(k), rows.min(k));
470        let mut left = Mat::<f64>::zeros(rows, rows.min(k));
471        let mut v = Mat::<f64>::zeros(k, k);
472        let params = Default::default();
473        svd(
474            scaled.as_ref(),
475            values.diagonal_mut(),
476            Some(left.as_mut()),
477            Some(v.as_mut()),
478            Par::Seq,
479            MemStack::new(&mut MemBuffer::new(svd_scratch::<f64>(
480                rows,
481                k,
482                ComputeSvdVectors::Thin,
483                ComputeSvdVectors::Full,
484                Par::Seq,
485                params,
486            ))),
487            params,
488        )
489        .ok()?;
490        Some(Self {
491            columns: kept,
492            largest,
493            length,
494            rows,
495            singular: (0..k)
496                .map(|j| if j < rows { values[(j, j)] } else { 0.0 })
497                .collect(),
498            left,
499            right: (0..k)
500                .map(|j| (0..k).map(|i| v[(i, j)]).collect())
501                .collect(),
502        })
503    }
504
505    fn unscale(&self, i: usize, value: f64) -> f64 {
506        value / self.length[i] / self.largest[i]
507    }
508
509    fn spanned(&self) -> impl Iterator<Item = usize> + '_ {
510        let largest = self.singular.iter().fold(0.0_f64, |m, &s| m.max(s));
511        let rank_floor = f64::EPSILON * self.rows.max(self.singular.len()) as f64 * largest;
512        (0..self.singular.len()).filter(move |&k| self.singular[k] > rank_floor)
513    }
514
515    fn along(&self, k: usize, linear: &Linearization) -> f64 {
516        let zero_part: f64 = self
517            .columns
518            .iter()
519            .enumerate()
520            .map(|(i, &col)| self.right[k][i] * self.unscale(i, linear.zero_slope[col]))
521            .sum();
522        let residual_part: f64 = (0..self.rows)
523            .map(|row| self.left[(row, k)] * linear.residual[row])
524            .sum();
525        residual_part + zero_part / self.singular[k]
526    }
527
528    fn newton_decrement(&self, linear: &Linearization) -> f64 {
529        0.5 * self
530            .spanned()
531            .map(|k| self.along(k, linear).powi(2))
532            .sum::<f64>()
533    }
534
535    fn is_determined(&self, k: usize) -> bool {
536        self.singular[k].powi(2) >= DEGENERATE_EIGENVALUE
537    }
538
539    fn determined(&self) -> impl Iterator<Item = usize> + '_ {
540        (0..self.singular.len()).filter(|&k| self.is_determined(k))
541    }
542
543    fn step(
544        &self,
545        directions: impl Iterator<Item = usize>,
546        linear: &Linearization,
547        n_free: usize,
548        damping: f64,
549    ) -> Vec<f64> {
550        let mut direction = vec![0.0; n_free];
551        for k in directions {
552            let s = self.singular[k];
553            let coefficient = self.along(k, linear) * s / (s * s + damping);
554            for (i, &col) in self.columns.iter().enumerate() {
555                direction[col] += self.unscale(i, self.right[k][i] * coefficient);
556            }
557        }
558        direction
559    }
560
561    fn leverage(&self, counts: usize) -> Vec<f64> {
562        (0..counts)
563            .map(|row| self.spanned().map(|k| self.left[(row, k)].powi(2)).sum())
564            .collect()
565    }
566
567    fn error_bars(&self, n_free: usize) -> (FlatMatrix, Vec<Option<f64>>) {
568        let n = self.columns.len();
569        let determined: Vec<bool> = (0..n).map(|k| self.is_determined(k)).collect();
570        let largest = self.singular.iter().fold(0.0_f64, |m, &s| m.max(s));
571        let resolved: Vec<bool> = (0..n)
572            .map(|i| {
573                let sensitivity: f64 = (0..n)
574                    .filter(|&k| determined[k])
575                    .map(|k| self.right[k][i].abs() / self.singular[k])
576                    .sum();
577                let rounding = f64::EPSILON * self.rows.max(n) as f64 * largest * sensitivity;
578                (0..n)
579                    .filter(|&k| !determined[k])
580                    .map(|k| self.right[k][i].powi(2))
581                    .sum::<f64>()
582                    <= rounding.powi(2)
583            })
584            .collect();
585        let variance = |i: usize, j: usize| -> f64 {
586            (0..n)
587                .filter(|&k| determined[k])
588                .map(|k| self.right[k][i] * self.right[k][j] / self.singular[k].powi(2))
589                .sum()
590        };
591        let (mut covariance, mut errors) = withheld(n_free);
592        for i in (0..n).filter(|&i| resolved[i]) {
593            for j in (0..n).filter(|&j| resolved[j]) {
594                *covariance.get_mut(self.columns[i], self.columns[j]) =
595                    self.unscale(j, self.unscale(i, variance(i, j)));
596            }
597            errors[self.columns[i]] = Some(self.unscale(i, variance(i, i).sqrt()));
598        }
599        (covariance, errors)
600    }
601}
602
603/// The inverses of the expected information `information` of `rows` counts,
604/// split as [`poisson_fit`] splits its directions into those its steps take
605/// and those its covariance keeps.  In the information scaled to a unit
606/// `diagonal`, its own or, for an information with other parameters profiled
607/// out, the one it had before, `spanned` keeps every eigen-direction whose
608/// eigenvalue is above the eigendecomposition's rounding, `ε·max(rows, k)`
609/// times the largest, `k` the number of parameters with information, and `determined` keeps those of them whose eigenvalue is
610/// also at least 1e-12; where that rounding exceeds 1e-12, it is the cutoff
611/// of both.  A parameter with no information has a zero row and
612/// column in both.  `resolved[i]` is whether parameter `i` has information
613/// and, beyond rounding, no component along a direction `determined` leaves
614/// out; `undetermined` holds those directions, in the parameters' units.
615#[derive(Debug, Clone)]
616pub struct InformationInverse {
617    pub spanned: FlatMatrix,
618    pub determined: FlatMatrix,
619    pub resolved: Vec<bool>,
620    pub undetermined: Vec<Vec<f64>>,
621}
622
623/// [`InformationInverse`] of `information`, the expected information of
624/// `rows` counts, scaled to `diagonal`.
625///
626/// # Errors
627/// [`FittingError::InvalidConfig`] if `information` is not square, does not
628/// hold its entries, has an entry that is not finite, or is not symmetric to
629/// 1e-12 of `√(dᵢdⱼ)`, or if `diagonal` is not one finite non-negative entry
630/// per row, zero only on a row of zeros; [`FittingError::EvaluationFailed`] if its eigendecomposition
631/// fails.
632pub fn information_inverse(
633    information: &FlatMatrix,
634    diagonal: &[f64],
635    rows: usize,
636) -> Result<InformationInverse, FittingError> {
637    let n = information.nrows;
638    if information.ncols != n
639        || information.data.len() != n * n
640        || !information.data.iter().all(|x| x.is_finite())
641        || diagonal.len() != n
642        || !diagonal.iter().all(|d| d.is_finite() && *d >= 0.0)
643        || (0..n).any(|i| diagonal[i] == 0.0 && (0..n).any(|j| information.get(i, j) != 0.0))
644        || !(0..n).all(|i| {
645            (0..i).all(|j| {
646                (information.get(i, j) - information.get(j, i)).abs()
647                    <= 1e-12 * (diagonal[i] * diagonal[j]).sqrt()
648            })
649        })
650    {
651        return Err(FittingError::InvalidConfig(format!(
652            "an expected information is a finite symmetric square matrix with a finite \
653             non-negative diagonal to scale it by; got {} by {} with {} entries and {} \
654             diagonal entries",
655            information.nrows,
656            information.ncols,
657            information.data.len(),
658            diagonal.len()
659        )));
660    }
661    let kept: Vec<usize> = (0..n).filter(|&i| diagonal[i] > 0.0).collect();
662    let k = kept.len();
663    if k == 0 {
664        return Ok(InformationInverse {
665            spanned: FlatMatrix::zeros(n, n),
666            determined: FlatMatrix::zeros(n, n),
667            resolved: vec![false; n],
668            undetermined: Vec::new(),
669        });
670    }
671    let scale: Vec<f64> = kept.iter().map(|&i| diagonal[i].sqrt()).collect();
672    let eigen = Mat::from_fn(k, k, |a, b| {
673        information.get(kept[a], kept[b]) / (scale[a] * scale[b])
674    })
675    .self_adjoint_eigen(Side::Lower)
676    .map_err(|e| FittingError::EvaluationFailed(format!("{e:?}")))?;
677    let (values, vectors) = (eigen.S().column_vector(), eigen.U());
678    let singular: Vec<f64> = (0..k).map(|d| values[d].max(0.0).sqrt()).collect();
679    let largest = singular.iter().fold(0.0_f64, |m, &s| m.max(s));
680    let rank_floor = f64::EPSILON * rows.max(k) as f64 * largest.powi(2);
681    let inverse = |keep: &dyn Fn(usize) -> bool| {
682        let mut inverse = FlatMatrix::zeros(n, n);
683        for a in 0..k {
684            for b in 0..k {
685                *inverse.get_mut(kept[a], kept[b]) = (0..k)
686                    .filter(|&d| keep(d))
687                    .map(|d| vectors[(a, d)] * vectors[(b, d)] / values[d])
688                    .sum::<f64>()
689                    / (scale[a] * scale[b]);
690            }
691        }
692        inverse
693    };
694    let is_spanned = |d: usize| values[d] > rank_floor;
695    let is_determined = |d: usize| is_spanned(d) && values[d] >= DEGENERATE_EIGENVALUE;
696    let mut resolved = vec![false; n];
697    for a in 0..k {
698        let sensitivity: f64 = (0..k)
699            .filter(|&d| is_determined(d))
700            .map(|d| vectors[(a, d)].abs() / singular[d])
701            .sum();
702        let rounding = f64::EPSILON * rows.max(k) as f64 * largest * sensitivity;
703        resolved[kept[a]] = (0..k)
704            .filter(|&d| !is_determined(d))
705            .map(|d| vectors[(a, d)].powi(2))
706            .sum::<f64>()
707            <= rounding.powi(2);
708    }
709    let undetermined = (0..k)
710        .filter(|&d| !is_determined(d))
711        .map(|d| {
712            let mut direction = vec![0.0; n];
713            for a in 0..k {
714                direction[kept[a]] = vectors[(a, d)] / scale[a];
715            }
716            direction
717        })
718        .collect();
719    Ok(InformationInverse {
720        spanned: inverse(&is_spanned),
721        determined: inverse(&is_determined),
722        resolved,
723        undetermined,
724    })
725}
726
727fn withheld(n_free: usize) -> (FlatMatrix, Vec<Option<f64>>) {
728    let mut covariance = FlatMatrix::zeros(n_free, n_free);
729    covariance.data.fill(f64::NAN);
730    (covariance, vec![None; n_free])
731}
732
733fn on_bound(param: &FitParameter) -> bool {
734    param.value == param.lower || param.value == param.upper
735}
736
737fn held_by_bound(param: &FitParameter, gradient: f64) -> bool {
738    (param.value == param.lower && gradient > 0.0) || (param.value == param.upper && gradient < 0.0)
739}
740
741/// Fit `params` to the counts `y_obs` by minimizing half the Poisson
742/// deviance plus `½(θₚ − mₚ)ᵀCₚ⁻¹(θₚ − mₚ)` for each of the `priors`, within
743/// the parameter bounds.
744///
745/// The model must provide an analytical Jacobian at every point the fit
746/// visits.  A trial predicting a negative count, or zero where something was
747/// counted, is rejected; a bin predicted zero with no counts adds its slope
748/// to the gradient and nothing to the information.  Each step is the
749/// Levenberg–Marquardt step `(F + λI)⁻¹g` in coordinates scaled to unit
750/// Fisher information, projected onto the box;
751/// `λ` is divided by 10 after a step that lowers the objective, and
752/// multiplied by 10, to at least its starting 1e-3, before retrying one that
753/// does not (D. W. Marquardt, J. Soc. Indust. Appl. Math. 11, 431–441,
754/// 1963).  A free parameter on its bound whose gradient points out of the
755/// box is held there for the step and left out of the convergence test.
756///
757/// The fit has converged when the Newton decrement `½ gᵀF⁺g` over the
758/// parameters not held by a bound (on it, gradient pointing out),
759/// `F = Jᵀ diag(1/μ) J + Σₚ Cₚ⁻¹` the expected information, each `Cₚ⁻¹` on
760/// its prior's parameters, is below 1e-6: the
761/// quadratic model built from `F` predicts less than 1e-6 of further
762/// decrease.  Where the deviance is quadratic near the minimum this puts the
763/// fit within about 0.0014 standard errors of it, and within 0.01 where the
764/// expected information overstates the curvature, as at one count per bin.
765/// Where it is not quadratic — weakly determined parameters on a curved or
766/// flat likelihood ridge, a minimum at infinity, or a parameter that adds
767/// counts to a bin predicted near zero — the fit can stop short of the
768/// minimum, or at another local minimum, and the error bars are not standard
769/// errors.  A minimum where a bin's prediction reaches zero inside the box is
770/// reported unconverged.
771///
772/// It stops unconverged when no step lowers the objective, when the Jacobian
773/// is not finite, when the start predicts a negative count or zero where
774/// something was counted, or after `config.max_iter` steps.  A trial whose
775/// prediction has a different length from `y_obs` is rejected.
776///
777/// # Errors
778/// `FittingError::EmptyData` if `y_obs` is empty;
779/// `FittingError::InvalidConfig` if an observation is negative or not
780/// finite, a parameter value is not finite, a free parameter's bounds are
781/// inverted, NaN or admit no finite value, a prior is not on free
782/// parameters, two priors are on one parameter, a prior's mean is not finite
783/// or a measured sd not finite and positive, or the model returns no
784/// analytical Jacobian;
785/// `FittingError::LengthMismatch` if the prediction or the Jacobian does not
786/// match `y_obs` and the free parameters; the model's error if it fails at
787/// the start.
788pub fn poisson_fit(
789    model: &dyn FitModel,
790    y_obs: &[f64],
791    priors: &[Prior],
792    params: &mut ParameterSet,
793    config: &PoissonConfig,
794) -> Result<PoissonResult, FittingError> {
795    if let Some((bin, &obs)) = y_obs
796        .iter()
797        .enumerate()
798        .find(|(_, obs)| !(obs.is_finite() && **obs >= 0.0))
799    {
800        return Err(FittingError::InvalidConfig(format!(
801            "observed counts must be finite and non-negative, got {obs} in bin {bin}"
802        )));
803    }
804    if y_obs.is_empty() {
805        return Err(FittingError::EmptyData);
806    }
807    if let Some(p) = params.params.iter().find(|p| {
808        !p.value.is_finite()
809            || (!p.fixed
810                && (p.lower.is_nan()
811                    || p.upper.is_nan()
812                    || p.lower > p.upper
813                    || p.lower == f64::INFINITY
814                    || p.upper == f64::NEG_INFINITY))
815    }) {
816        return Err(FittingError::InvalidConfig(format!(
817            "parameter {} = {} with bounds [{}, {}]",
818            p.name, p.value, p.lower, p.upper
819        )));
820    }
821    let covered: Vec<usize> = priors.iter().flat_map(|p| p.parameters.clone()).collect();
822    if let Some((i, prior)) = priors.iter().enumerate().find(|(_, p)| {
823        p.parameters.iter().any(|&parameter| {
824            params.params.get(parameter).is_none_or(|param| param.fixed)
825                || covered.iter().filter(|&&c| c == parameter).count() > 1
826        }) || !p.is_valid()
827    }) {
828        return Err(FittingError::InvalidConfig(format!(
829            "prior {i} must be on free parameters without another prior, with a finite mean \
830             and finite positive sds; got parameters {:?} with mean {:?}",
831            prior.parameters, prior.mean
832        )));
833    }
834    params.set_free_values(&params.free_values());
835    let mut y_model = model.evaluate(&params.all_values())?;
836    if y_model.len() != y_obs.len() {
837        return Err(FittingError::LengthMismatch {
838            expected: y_model.len(),
839            actual: y_obs.len(),
840            field: "y_obs",
841        });
842    }
843    let free = params.free_indices();
844    let mut value = objective(y_obs, &y_model, priors, params);
845    let mut iterations = 0;
846    let mut at_minimum = None;
847    let mut damping = INITIAL_DAMPING;
848    while value.is_finite() {
849        let linear = linearize(model, params, &free, priors, y_obs, &y_model)?;
850        if !linear
851            .weighted
852            .data
853            .iter()
854            .chain(&linear.zero_slope)
855            .all(|v| v.is_finite())
856        {
857            break;
858        }
859        let movable: Vec<usize> = (0..free.len())
860            .filter(|&j| !held_by_bound(&params.params[free[j]], linear.gradient[j]))
861            .collect();
862        let Some(decomposition) = Decomposition::new(&linear.weighted, &movable) else {
863            break;
864        };
865        if decomposition.newton_decrement(&linear) < NEWTON_DECREMENT_TOL {
866            at_minimum = Some(linear);
867            break;
868        }
869        if iterations == config.max_iter {
870            break;
871        }
872        let start = params.free_values();
873        let mut accepted = None;
874        for _ in 0..MAX_REJECTIONS {
875            let direction =
876                decomposition.step(decomposition.spanned(), &linear, free.len(), damping);
877            let unprojected: Vec<f64> = start.iter().zip(&direction).map(|(x, d)| x - d).collect();
878            params.set_free_values(&unprojected);
879            if let Some(trial_model) = model
880                .evaluate(&params.all_values())
881                .ok()
882                .filter(|trial| trial.len() == y_obs.len())
883            {
884                let trial_value = objective(y_obs, &trial_model, priors, params);
885                if trial_value < value {
886                    damping /= DAMPING_FACTOR;
887                    accepted = Some((trial_model, trial_value));
888                    break;
889                }
890            }
891            params.set_free_values(&start);
892            damping = (damping * DAMPING_FACTOR).max(INITIAL_DAMPING);
893        }
894        let Some((trial_model, trial_value)) = accepted else {
895            break;
896        };
897        y_model = trial_model;
898        value = trial_value;
899        iterations += 1;
900    }
901
902    let bounded: Vec<bool> = free
903        .iter()
904        .map(|&idx| on_bound(&params.params[idx]))
905        .collect();
906    let (covariance, uncertainties, leverage, unbounded) = match &at_minimum {
907        Some(linear) if config.compute_covariance => {
908            let interior: Vec<usize> = (0..free.len()).filter(|&j| !bounded[j]).collect();
909            let decomposition = Decomposition::new(&linear.weighted, &interior);
910            let (covariance, errors) = decomposition.as_ref().map_or_else(
911                || withheld(free.len()),
912                |decomposition| decomposition.error_bars(free.len()),
913            );
914            let leverage = decomposition
915                .as_ref()
916                .map(|decomposition| decomposition.leverage(y_obs.len()));
917            let unbounded = unbounded(linear, &params.free_values(), &bounded);
918            (Some(covariance), Some(errors), leverage, unbounded)
919        }
920        _ => (None, None, None, None),
921    };
922    Ok(PoissonResult {
923        deviance: deviance(y_obs, &y_model),
924        iterations,
925        converged: at_minimum.is_some(),
926        params: params.all_values(),
927        covariance,
928        uncertainties,
929        on_bound: bounded,
930        leverage,
931        unbounded,
932    })
933}
934
935fn unbounded(linear: &Linearization, fitted: &[f64], bounded: &[bool]) -> Option<Unbounded> {
936    if (0..fitted.len()).any(|j| bounded[j] && linear.zero_slope[j] != 0.0) {
937        return None;
938    }
939    let columns: Vec<usize> = (0..fitted.len()).collect();
940    let decomposition = Decomposition::new(&linear.weighted, &columns)?;
941    let step = decomposition.step(decomposition.determined(), linear, fitted.len(), 0.0);
942    let covariance = decomposition.error_bars(fitted.len()).0;
943    let mean = (0..fitted.len())
944        .map(|i| {
945            if covariance.get(i, i).is_nan() {
946                f64::NAN
947            } else {
948                fitted[i] - step[i]
949            }
950        })
951        .collect();
952    Some(Unbounded { mean, covariance })
953}
954
955#[cfg(test)]
956mod tests {
957    use super::*;
958
959    /// Simple model: y = a * exp(-b * x)
960    /// This mimics transmission: counts = flux * exp(-density * sigma)
961    struct ExponentialModel {
962        x: Vec<f64>,
963        flux: Vec<f64>,
964    }
965
966    impl FitModel for ExponentialModel {
967        fn evaluate(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
968            let b = params[0]; // "density"
969            Ok(self
970                .x
971                .iter()
972                .zip(self.flux.iter())
973                .map(|(&xi, &fi)| fi * (-b * xi).exp())
974                .collect())
975        }
976    }
977
978    struct Decay {
979        t: Vec<f64>,
980        jacobian_factor: f64,
981    }
982
983    impl FitModel for Decay {
984        fn evaluate(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
985            Ok(self
986                .t
987                .iter()
988                .map(|&t| params[0] * (-params[1] * t).exp())
989                .collect())
990        }
991
992        fn analytical_jacobian(
993            &self,
994            params: &[f64],
995            free_param_indices: &[usize],
996            _y_current: &[f64],
997        ) -> Option<FlatMatrix> {
998            let mut jacobian = FlatMatrix::zeros(self.t.len(), free_param_indices.len());
999            for (row, &t) in self.t.iter().enumerate() {
1000                let e = (-params[1] * t).exp();
1001                for (col, &index) in free_param_indices.iter().enumerate() {
1002                    let slope = [e, -params[0] * t * e][index];
1003                    *jacobian.get_mut(row, col) = slope * self.jacobian_factor;
1004                }
1005            }
1006            Some(jacobian)
1007        }
1008    }
1009
1010    fn decay(jacobian_factor: f64) -> Decay {
1011        Decay {
1012            t: (0..25).map(|i| 0.2 * f64::from(i)).collect(),
1013            jacobian_factor,
1014        }
1015    }
1016
1017    fn decay_params(a: f64, b: f64, a_upper: f64) -> ParameterSet {
1018        ParameterSet::new(vec![
1019            FitParameter {
1020                name: "a".into(),
1021                value: a,
1022                lower: 0.0,
1023                upper: a_upper,
1024                fixed: false,
1025            },
1026            FitParameter::non_negative("b", b),
1027        ])
1028    }
1029
1030    fn fit_decay(
1031        model: &Decay,
1032        observed: &[f64],
1033        start: (f64, f64),
1034        max_iter: usize,
1035    ) -> PoissonResult {
1036        let mut params = decay_params(start.0, start.1, f64::INFINITY);
1037        let config = PoissonConfig {
1038            max_iter,
1039            ..PoissonConfig::default()
1040        };
1041        poisson_fit(model, observed, &[], &mut params, &config).unwrap()
1042    }
1043
1044    #[test]
1045    fn half_deviance_matches_high_precision_values() {
1046        for (obs, mean, exact) in [
1047            (1_000_001.0, 1_000_000.0, 4.999_998_333_334_167e-7),
1048            (3.0, 2.5, 0.046_964_670_381_863_88),
1049            (0.0, 2.5, 2.5),
1050            (1.0e308, 9.0e307, 5.360_515_657_826_301e305),
1051            (1.7e308, 1.65e308, 7.500_373_544_579_622e304),
1052            (1.0, 1.0e-310, 712.801_378_828_154_2),
1053        ] {
1054            let got = half_deviance(obs, mean);
1055            assert!(
1056                ((got - exact) / exact).abs() < 1e-12,
1057                "{obs}, {mean}: {got}"
1058            );
1059        }
1060    }
1061
1062    #[test]
1063    fn inputs_that_are_not_counts_for_this_model_are_refused() {
1064        let model = decay(1.0);
1065        let observed = model.evaluate(&[100.0, 1.5]).unwrap();
1066        let fit = |model: &dyn FitModel, observed: &[f64]| {
1067            poisson_fit(
1068                model,
1069                observed,
1070                &[],
1071                &mut decay_params(50.0, 1.0, f64::INFINITY),
1072                &PoissonConfig::default(),
1073            )
1074        };
1075        let mut bad = observed.clone();
1076        bad[3] = f64::NAN;
1077        assert!(fit(&model, &bad).is_err());
1078        bad[3] = -1.0;
1079        assert!(fit(&model, &bad).is_err());
1080        assert!(fit(&model, &observed[1..]).is_err());
1081        let empty = Decay {
1082            t: vec![],
1083            jacobian_factor: 1.0,
1084        };
1085        assert!(fit(&empty, &[]).is_err());
1086        let no_jacobian = ExponentialModel {
1087            x: model.t.clone(),
1088            flux: vec![100.0; model.t.len()],
1089        };
1090        assert!(fit(&no_jacobian, &observed).is_err());
1091    }
1092
1093    #[test]
1094    fn a_start_with_a_zero_prediction_is_not_converged() {
1095        let model = decay(1.0);
1096        let observed = model.evaluate(&[100.0, 1.5]).unwrap();
1097        let result = fit_decay(&model, &observed, (0.0, 1.0), 200);
1098        assert!(!result.converged && result.iterations == 0, "{result:?}");
1099    }
1100
1101    #[test]
1102    fn a_non_finite_slope_ends_the_fit_unconverged() {
1103        let model = decay(f64::NAN);
1104        let observed = decay(1.0).evaluate(&[100.0, 1.5]).unwrap();
1105        let result = fit_decay(&model, &observed, (50.0, 1.0), 200);
1106        assert!(
1107            !result.converged && result.uncertainties.is_none(),
1108            "{result:?}"
1109        );
1110    }
1111
1112    #[test]
1113    fn a_fit_whose_step_cannot_lower_the_deviance_is_not_converged() {
1114        let model = decay(-1.0);
1115        let observed = decay(1.0).evaluate(&[100.0, 1.5]).unwrap();
1116        let result = fit_decay(&model, &observed, (50.0, 1.0), 200);
1117        assert!(
1118            !result.converged && result.iterations == 0 && result.uncertainties.is_none(),
1119            "{result:?}"
1120        );
1121    }
1122
1123    #[test]
1124    fn a_start_outside_the_bounds_ends_inside_them() {
1125        let model = decay(1.0);
1126        let observed = model.evaluate(&[100.0, 1.5]).unwrap();
1127        let mut params = decay_params(100.0, 1.5, 80.0);
1128        let result = poisson_fit(
1129            &model,
1130            &observed,
1131            &[],
1132            &mut params,
1133            &PoissonConfig::default(),
1134        )
1135        .unwrap();
1136        assert!(result.converged && result.params[0] == 80.0, "{result:?}");
1137        assert_eq!(result.on_bound, vec![true, false]);
1138    }
1139
1140    #[test]
1141    fn a_fit_that_reaches_the_minimum_on_its_last_allowed_step_has_converged() {
1142        let model = decay(1.0);
1143        let observed = model.evaluate(&[100.0, 1.5]).unwrap();
1144        let steps = fit_decay(&model, &observed, (20.0, 0.3), 200).iterations;
1145        assert!(steps > 1);
1146        assert!(fit_decay(&model, &observed, (20.0, 0.3), steps).converged);
1147        assert!(!fit_decay(&model, &observed, (20.0, 0.3), steps - 1).converged);
1148    }
1149
1150    struct Scaled {
1151        x: Vec<f64>,
1152        slope: f64,
1153        tiny: f64,
1154    }
1155
1156    impl FitModel for Scaled {
1157        fn evaluate(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
1158            Ok(self
1159                .x
1160                .iter()
1161                .map(|&x| 100.0 * (self.slope * params[0] * x).exp() + self.tiny * params[1] * x)
1162                .collect())
1163        }
1164
1165        fn analytical_jacobian(
1166            &self,
1167            params: &[f64],
1168            free_param_indices: &[usize],
1169            _y_current: &[f64],
1170        ) -> Option<FlatMatrix> {
1171            let mut jacobian = FlatMatrix::zeros(self.x.len(), free_param_indices.len());
1172            for (row, &x) in self.x.iter().enumerate() {
1173                let slopes = [
1174                    100.0 * self.slope * x * (self.slope * params[0] * x).exp(),
1175                    self.tiny * x,
1176                ];
1177                for (col, &index) in free_param_indices.iter().enumerate() {
1178                    *jacobian.get_mut(row, col) = slopes[index];
1179                }
1180            }
1181            Some(jacobian)
1182        }
1183    }
1184
1185    fn fit_scaled(slope: f64, tiny: f64) -> PoissonResult {
1186        let model = Scaled {
1187            x: vec![1.0, 2.0, 3.0],
1188            slope,
1189            tiny,
1190        };
1191        let observed = model.evaluate(&[0.3 / slope, 0.0]).unwrap();
1192        let mut params = ParameterSet::new(vec![
1193            FitParameter::unbounded("theta", 0.0),
1194            FitParameter::unbounded("weak", 0.0),
1195        ]);
1196        poisson_fit(
1197            &model,
1198            &observed,
1199            &[],
1200            &mut params,
1201            &PoissonConfig::default(),
1202        )
1203        .unwrap()
1204    }
1205
1206    #[test]
1207    fn a_slope_too_large_to_square_is_fitted() {
1208        let result = fit_scaled(1.0e160, 1.0);
1209        assert!(
1210            result.converged && result.deviance < NEWTON_DECREMENT_TOL,
1211            "{result:?}"
1212        );
1213    }
1214
1215    #[test]
1216    fn a_slope_too_small_to_square_does_not_fake_convergence() {
1217        let result = fit_scaled(1.0, 1.0e-310);
1218        assert!(
1219            !result.converged || result.deviance < NEWTON_DECREMENT_TOL,
1220            "{result:?}"
1221        );
1222    }
1223
1224    struct Line {
1225        x: Vec<f64>,
1226    }
1227
1228    impl FitModel for Line {
1229        fn evaluate(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
1230            Ok(self.x.iter().map(|&x| 1.0 + params[0] * x).collect())
1231        }
1232
1233        fn analytical_jacobian(
1234            &self,
1235            _params: &[f64],
1236            free_param_indices: &[usize],
1237            _y_current: &[f64],
1238        ) -> Option<FlatMatrix> {
1239            let mut jacobian = FlatMatrix::zeros(self.x.len(), free_param_indices.len());
1240            jacobian.data.copy_from_slice(&self.x);
1241            Some(jacobian)
1242        }
1243    }
1244
1245    struct Proportional {
1246        a: Vec<f64>,
1247    }
1248
1249    impl FitModel for Proportional {
1250        fn evaluate(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
1251            Ok(self.a.iter().map(|a| a * params[0]).collect())
1252        }
1253
1254        fn analytical_jacobian(
1255            &self,
1256            _params: &[f64],
1257            free_param_indices: &[usize],
1258            _y_current: &[f64],
1259        ) -> Option<FlatMatrix> {
1260            let mut jacobian = FlatMatrix::zeros(self.a.len(), free_param_indices.len());
1261            jacobian.data.copy_from_slice(&self.a);
1262            Some(jacobian)
1263        }
1264    }
1265
1266    #[test]
1267    fn a_prior_moves_the_fit_to_the_maximum_of_the_posterior() {
1268        let model = Proportional {
1269            a: vec![1.0, 2.0, 3.0, 4.0],
1270        };
1271        let observed = [3.0, 5.0, 9.0, 12.0];
1272        let (a_sum, y_sum): (f64, f64) = (10.0, 29.0);
1273        let (mean, sd) = (2.0, 0.3);
1274        let fit = |priors: &[Prior]| {
1275            let mut params = ParameterSet::new(vec![FitParameter::non_negative("theta", 1.0)]);
1276            poisson_fit(
1277                &model,
1278                &observed,
1279                priors,
1280                &mut params,
1281                &PoissonConfig::default(),
1282            )
1283            .unwrap()
1284        };
1285        let result = fit(&[Prior::measured(0, mean, sd)]);
1286        assert!(result.converged);
1287        let theta = result.params[0];
1288        let b = a_sum - mean / sd.powi(2);
1289        let root = sd.powi(2) * 0.5 * (-b + (b * b + 4.0 * y_sum / sd.powi(2)).sqrt());
1290        let variance = 1.0 / (a_sum / theta + 1.0 / sd.powi(2));
1291        assert!(
1292            (theta - root).abs() <= 2e-3 * variance.sqrt(),
1293            "{theta} vs {root}"
1294        );
1295        let covariance = result.covariance.expect("covariance").get(0, 0);
1296        assert!((covariance / variance - 1.0).abs() <= 1e-12, "{covariance}");
1297        let deviance: f64 = observed
1298            .iter()
1299            .zip(&model.a)
1300            .map(|(y, a)| a * theta - y + y * (y / (a * theta)).ln())
1301            .sum();
1302        assert!((result.deviance / deviance - 1.0).abs() <= 1e-12);
1303        let leverage = result.leverage.expect("leverage");
1304        for (h, a) in leverage.iter().zip(&model.a) {
1305            assert!((h / (a / theta * variance) - 1.0).abs() <= 1e-12, "{h}");
1306        }
1307        let unmeasured = fit(&[]).leverage.expect("leverage");
1308        assert!((unmeasured.iter().sum::<f64>() - 1.0).abs() <= 1e-12);
1309    }
1310
1311    #[test]
1312    fn priors_that_are_not_measurements_of_one_free_parameter_are_refused() {
1313        let model = decay(1.0);
1314        let observed = model.evaluate(&[100.0, 1.5]).unwrap();
1315        let fit = |priors: &[Prior], fixed: bool| {
1316            let mut params = decay_params(50.0, 1.0, f64::INFINITY);
1317            params.params[1].fixed = fixed;
1318            poisson_fit(
1319                &model,
1320                &observed,
1321                priors,
1322                &mut params,
1323                &PoissonConfig::default(),
1324            )
1325        };
1326        let prior = Prior::measured;
1327        assert!(fit(&[prior(1, 1.5, 0.1)], false).is_ok());
1328        for priors in [
1329            vec![prior(2, 1.5, 0.1)],
1330            vec![prior(1, 1.5, 0.1), prior(1, 1.4, 0.1)],
1331            vec![prior(1, f64::NAN, 0.1)],
1332            vec![prior(1, 1.5, 0.0)],
1333            vec![prior(1, 1.5, f64::INFINITY)],
1334            vec![block(&[0, 1]), prior(1, 1.5, 0.1)],
1335        ] {
1336            assert!(fit(&priors, false).is_err(), "{priors:?}");
1337        }
1338        assert!(fit(&[prior(1, 1.5, 0.1)], true).is_err());
1339        assert!(fit(&[block(&[0, 1])], false).is_ok());
1340    }
1341
1342    fn two_by_two(data: [f64; 4]) -> FlatMatrix {
1343        FlatMatrix {
1344            data: data.to_vec(),
1345            nrows: 2,
1346            ncols: 2,
1347        }
1348    }
1349
1350    fn block(parameters: &[usize]) -> Prior {
1351        Prior::correlated(
1352            parameters,
1353            &[100.0, 1.5],
1354            &two_by_two([1.0, 0.0, 0.0, 0.01]),
1355        )
1356        .unwrap()
1357    }
1358
1359    #[test]
1360    fn covariances_that_are_not_symmetric_and_well_conditioned_are_refused() {
1361        let correlated = |data| Prior::correlated(&[0, 1], &[0.0, 0.0], &two_by_two(data));
1362        let scaled = |rho: f64| correlated([1.0, rho * 1e3, rho * 1e3, 1e6]);
1363        assert!(scaled(1.0 - 1e-9).is_ok());
1364        assert!(correlated([1.0, 1e-6, 1e-6 + 1e-17, 1.0]).is_ok());
1365        for result in [
1366            scaled(1.0 - 1e-11),
1367            scaled(1.0 + 1e-9),
1368            correlated([1.0, 0.5, 0.5 + 1e-10, 1.0]),
1369            correlated([1.0, 1e-6, 1e-6 + 1e-11, 1.0]),
1370            correlated([1.0, f64::NAN, f64::NAN, 1.0]),
1371            correlated([0.0, 0.0, 0.0, 1.0]),
1372            Prior::correlated(&[0, 1], &[0.0], &two_by_two([1.0, 0.0, 0.0, 1.0])),
1373            Prior::correlated(
1374                &[0, 1],
1375                &[0.0, 0.0],
1376                &FlatMatrix {
1377                    data: vec![1.0, 0.0, 0.0],
1378                    nrows: 2,
1379                    ncols: 2,
1380                },
1381            ),
1382            Prior::correlated(&[], &[], &FlatMatrix::zeros(0, 0)),
1383            Prior::correlated(&[0, 1], &[f64::NAN, 0.0], &two_by_two([1.0, 0.0, 0.0, 1.0])),
1384            Prior::correlated(&[1, 1], &[0.0, 0.0], &two_by_two([1.0, 0.0, 0.0, 1.0])),
1385        ] {
1386            assert!(result.is_err(), "{result:?}");
1387        }
1388    }
1389
1390    #[test]
1391    fn a_measured_prior_divides_by_its_sd() {
1392        for (value, mean, sd) in [
1393            (1.234_567, 0.987_654_3, 0.031_4),
1394            (1e-7, -3.3e5, 7.0e3),
1395            (2.0, 2.0, 0.3),
1396        ] {
1397            let prior = Prior::measured(0, mean, sd);
1398            assert_eq!(
1399                prior.residual(&[value])[0].to_bits(),
1400                ((value - mean) / sd).to_bits()
1401            );
1402            assert_eq!(prior.inverse_factor()[0].to_bits(), (1.0 / sd).to_bits());
1403        }
1404    }
1405
1406    struct Mixture;
1407
1408    const MIXTURE: [[f64; 2]; 5] = [[1.0, 5.0], [2.0, 3.0], [3.0, 2.0], [4.0, 1.0], [5.0, 0.5]];
1409
1410    const MIXTURE_COUNTS: [f64; 5] = [21.0, 20.0, 15.0, 18.0, 16.0];
1411
1412    impl FitModel for Mixture {
1413        fn evaluate(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
1414            Ok(MIXTURE
1415                .iter()
1416                .map(|a| a[0] * params[0] + a[1] * params[1])
1417                .collect())
1418        }
1419
1420        fn analytical_jacobian(
1421            &self,
1422            _params: &[f64],
1423            free_param_indices: &[usize],
1424            _y_current: &[f64],
1425        ) -> Option<FlatMatrix> {
1426            let mut jacobian = FlatMatrix::zeros(MIXTURE.len(), free_param_indices.len());
1427            for (row, a) in MIXTURE.iter().enumerate() {
1428                for (col, &index) in free_param_indices.iter().enumerate() {
1429                    *jacobian.get_mut(row, col) = a[index];
1430                }
1431            }
1432            Some(jacobian)
1433        }
1434    }
1435
1436    fn fit_mixture(priors: &[Prior]) -> PoissonResult {
1437        let mut params = ParameterSet::new(vec![
1438            FitParameter::non_negative("x", 1.0),
1439            FitParameter::non_negative("y", 1.0),
1440        ]);
1441        poisson_fit(
1442            &Mixture,
1443            &MIXTURE_COUNTS,
1444            priors,
1445            &mut params,
1446            &PoissonConfig::default(),
1447        )
1448        .unwrap()
1449    }
1450
1451    fn inverse(m: [[f64; 2]; 2]) -> [[f64; 2]; 2] {
1452        let det = m[0][0] * m[1][1] - m[0][1] * m[1][0];
1453        [
1454            [m[1][1] / det, -m[0][1] / det],
1455            [-m[1][0] / det, m[0][0] / det],
1456        ]
1457    }
1458
1459    #[test]
1460    fn a_correlated_prior_adds_its_inverse_covariance_to_the_information() {
1461        let (mean, c) = ([3.0, 4.0], [[0.25, 0.15], [0.15, 0.36]]);
1462        let prior = Prior::correlated(&[0, 1], &mean, &two_by_two(c.concat().try_into().unwrap()));
1463        let result = fit_mixture(&[prior.unwrap()]);
1464        assert!(result.converged);
1465        let precision = inverse(c);
1466        let information = |theta: [f64; 2], observed: bool| {
1467            let mut information = precision;
1468            for (a, y) in MIXTURE.iter().zip(MIXTURE_COUNTS) {
1469                let mu = a[0] * theta[0] + a[1] * theta[1];
1470                let weight = if observed { y / mu.powi(2) } else { 1.0 / mu };
1471                for k in 0..2 {
1472                    for l in 0..2 {
1473                        information[k][l] += weight * a[k] * a[l];
1474                    }
1475                }
1476            }
1477            information
1478        };
1479        let mut mode = mean;
1480        for _ in 0..50 {
1481            let gradient: Vec<f64> = (0..2)
1482                .map(|k| {
1483                    (0..2)
1484                        .map(|l| precision[k][l] * (mode[l] - mean[l]))
1485                        .sum::<f64>()
1486                        + MIXTURE
1487                            .iter()
1488                            .zip(MIXTURE_COUNTS)
1489                            .map(|(a, y)| a[k] * (1.0 - y / (a[0] * mode[0] + a[1] * mode[1])))
1490                            .sum::<f64>()
1491                })
1492                .collect();
1493            let step = inverse(information(mode, true));
1494            for k in 0..2 {
1495                mode[k] -= (0..2).map(|l| step[k][l] * gradient[l]).sum::<f64>();
1496            }
1497        }
1498        let fitted = [result.params[0], result.params[1]];
1499        let expected = information(fitted, false);
1500        let shift: Vec<f64> = (0..2).map(|k| fitted[k] - mode[k]).collect();
1501        let distance = (0..2)
1502            .flat_map(|k| (0..2).map(move |l| (k, l)))
1503            .map(|(k, l)| shift[k] * expected[k][l] * shift[l])
1504            .sum::<f64>()
1505            .sqrt();
1506        assert!(distance <= 2e-3, "{fitted:?} vs {mode:?}");
1507        let covariance = inverse(expected);
1508        let reported = result.covariance.expect("covariance");
1509        for k in 0..2 {
1510            for l in 0..2 {
1511                let scale = (covariance[k][k] * covariance[l][l]).sqrt();
1512                assert!(
1513                    (reported.get(k, l) - covariance[k][l]).abs() <= 1e-12 * scale,
1514                    "{k}{l}: {} vs {}",
1515                    reported.get(k, l),
1516                    covariance[k][l]
1517                );
1518            }
1519        }
1520        let unbounded = result.unbounded.expect("unbounded");
1521        assert_eq!(unbounded.covariance.data, reported.data);
1522        for k in 0..2 {
1523            assert!((unbounded.mean[k] - fitted[k]).abs() <= 2e-3 * covariance[k][k].sqrt());
1524        }
1525    }
1526
1527    const FAR_COUNTS: [f64; 5] = [9743.0, 20046.0, 30110.0, 39769.0, 49761.0];
1528
1529    fn fit_far(params: Vec<FitParameter>, priors: &[Prior]) -> PoissonResult {
1530        let mut params = ParameterSet::new(params);
1531        poisson_fit(
1532            &Mixture,
1533            &FAR_COUNTS,
1534            priors,
1535            &mut params,
1536            &PoissonConfig::default(),
1537        )
1538        .unwrap()
1539    }
1540
1541    #[test]
1542    fn the_unbounded_gaussian_is_one_newton_step_past_the_bounds() {
1543        let (x, y) = (
1544            FitParameter::non_negative("x", 9000.0),
1545            FitParameter::non_negative("y", 1.0),
1546        );
1547        let capped = FitParameter {
1548            upper: 9000.0,
1549            ..x.clone()
1550        };
1551        let cases = [
1552            (vec![x.clone(), y.clone()], None, 1),
1553            (vec![capped, FitParameter::unbounded("y", 1.0)], None, 0),
1554            (vec![x, y], Some((10.0, 20.0)), 1),
1555        ];
1556        for (params, prior, held) in cases {
1557            let priors: Vec<Prior> = prior
1558                .iter()
1559                .map(|&(mean, sd)| Prior::measured(1, mean, sd))
1560                .collect();
1561            let result = fit_far(params, &priors);
1562            assert!(result.converged && result.on_bound[held] && !result.on_bound[1 - held]);
1563            let theta = [result.params[0], result.params[1]];
1564            let (mut information, mut gradient) = ([[0.0; 2]; 2], [0.0; 2]);
1565            for (a, n) in MIXTURE.iter().zip(FAR_COUNTS) {
1566                let mu = a[0] * theta[0] + a[1] * theta[1];
1567                for k in 0..2 {
1568                    gradient[k] += a[k] * (1.0 - n / mu);
1569                    for l in 0..2 {
1570                        information[k][l] += a[k] * a[l] / mu;
1571                    }
1572                }
1573            }
1574            if let Some((mean, sd)) = prior {
1575                information[1][1] += sd.powi(-2);
1576                gradient[1] += (theta[1] - mean) / sd.powi(2);
1577            }
1578            let covariance = inverse(information);
1579            let unbounded = result.unbounded.expect("unbounded");
1580            for k in 0..2 {
1581                let mean = theta[k] - (0..2).map(|l| covariance[k][l] * gradient[l]).sum::<f64>();
1582                let sd = covariance[k][k].sqrt();
1583                assert!(
1584                    (unbounded.mean[k] - mean).abs() <= 1e-12 * sd,
1585                    "{held} {k}: {} vs {mean}",
1586                    unbounded.mean[k]
1587                );
1588                for (l, expected) in covariance[k].iter().enumerate() {
1589                    let scale = sd * covariance[l][l].sqrt();
1590                    assert!((unbounded.covariance.get(k, l) - expected).abs() <= 1e-12 * scale);
1591                }
1592            }
1593        }
1594    }
1595
1596    struct Unused;
1597
1598    impl FitModel for Unused {
1599        fn evaluate(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
1600            Ok(vec![params[0]])
1601        }
1602
1603        fn analytical_jacobian(
1604            &self,
1605            _params: &[f64],
1606            free_param_indices: &[usize],
1607            _y_current: &[f64],
1608        ) -> Option<FlatMatrix> {
1609            let mut jacobian = FlatMatrix::zeros(1, free_param_indices.len());
1610            for (col, &index) in free_param_indices.iter().enumerate() {
1611                *jacobian.get_mut(0, col) = f64::from(u8::from(index == 0));
1612            }
1613            Some(jacobian)
1614        }
1615    }
1616
1617    #[test]
1618    fn a_parameter_on_a_bound_the_counts_do_not_determine_leaves_the_others_their_gaussian() {
1619        let mut params = ParameterSet::new(vec![
1620            FitParameter::unbounded("x", 90.0),
1621            FitParameter::non_negative("y", 0.0),
1622        ]);
1623        let result = poisson_fit(
1624            &Unused,
1625            &[100.0],
1626            &[],
1627            &mut params,
1628            &PoissonConfig::default(),
1629        )
1630        .unwrap();
1631        assert!(result.converged && result.on_bound == [false, true]);
1632        let unbounded = result.unbounded.expect("unbounded");
1633        let variance = unbounded.covariance.get(0, 0);
1634        assert!((variance / result.params[0] - 1.0).abs() <= 1e-12);
1635        assert!((unbounded.mean[0] - 100.0).abs() <= 2e-3 * variance.sqrt());
1636        assert!(unbounded.covariance.get(1, 1).is_nan() && unbounded.mean[1].is_nan());
1637    }
1638
1639    #[test]
1640    fn the_unbounded_gaussian_is_the_fit_without_its_bound_to_second_order() {
1641        let x = FitParameter::non_negative("x", 9000.0);
1642        let bounded = fit_far(vec![x.clone(), FitParameter::non_negative("y", 1.0)], &[]);
1643        let free = fit_far(vec![x, FitParameter::unbounded("y", 1.0)], &[]);
1644        let covariance = free.covariance.expect("covariance");
1645        assert!(bounded.on_bound[1] && !free.on_bound[1]);
1646        assert!(free.params[1] <= -1.5 * covariance.get(1, 1).sqrt());
1647        let unbounded = bounded.unbounded.expect("unbounded");
1648        for k in 0..2 {
1649            let variance = covariance.get(k, k);
1650            assert!((unbounded.mean[k] - free.params[k]).abs() <= 0.05 * variance.sqrt());
1651            assert!((unbounded.covariance.get(k, k) / variance - 1.0).abs() <= 0.05);
1652        }
1653    }
1654
1655    #[test]
1656    fn a_correlated_prior_with_a_diagonal_covariance_is_two_measured_priors() {
1657        let (mean, sd) = ([3.0, 4.0], [0.5, 0.6]);
1658        let diagonal = two_by_two([sd[0] * sd[0], 0.0, 0.0, sd[1] * sd[1]]);
1659        let bits = |result: PoissonResult| -> Vec<u64> {
1660            result
1661                .params
1662                .iter()
1663                .chain(&result.covariance.expect("covariance").data)
1664                .chain([&result.deviance])
1665                .map(|v| v.to_bits())
1666                .collect()
1667        };
1668        assert_eq!(
1669            bits(fit_mixture(&[
1670                Prior::correlated(&[0, 1], &mean, &diagonal).unwrap()
1671            ])),
1672            bits(fit_mixture(&[
1673                Prior::measured(0, mean[0], sd[0]),
1674                Prior::measured(1, mean[1], sd[1]),
1675            ]))
1676        );
1677    }
1678
1679    #[test]
1680    fn predictions_stay_positive_where_nothing_was_counted() {
1681        let model = Line {
1682            x: vec![1.0, 2.0, 3.0],
1683        };
1684        let mut params = ParameterSet::new(vec![FitParameter::unbounded("a", 0.0)]);
1685        let result = poisson_fit(
1686            &model,
1687            &[0.0; 3],
1688            &[],
1689            &mut params,
1690            &PoissonConfig::default(),
1691        )
1692        .unwrap();
1693        let predicted = model.evaluate(&result.params).unwrap();
1694        assert!(predicted.iter().all(|&mean| mean > 0.0), "{result:?}");
1695    }
1696
1697    struct WrongShape;
1698
1699    impl FitModel for WrongShape {
1700        fn evaluate(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
1701            Ok(vec![params[0]; 3])
1702        }
1703
1704        fn analytical_jacobian(
1705            &self,
1706            _params: &[f64],
1707            free_param_indices: &[usize],
1708            _y_current: &[f64],
1709        ) -> Option<FlatMatrix> {
1710            Some(FlatMatrix::zeros(2, free_param_indices.len()))
1711        }
1712    }
1713
1714    #[test]
1715    fn a_jacobian_of_the_wrong_shape_is_refused() {
1716        let mut params = ParameterSet::new(vec![FitParameter::non_negative("a", 1.0)]);
1717        let result = poisson_fit(
1718            &WrongShape,
1719            &[1.0; 3],
1720            &[],
1721            &mut params,
1722            &PoissonConfig::default(),
1723        );
1724        assert!(
1725            matches!(result, Err(FittingError::LengthMismatch { .. })),
1726            "{result:?}"
1727        );
1728    }
1729
1730    #[test]
1731    fn inverted_or_nan_bounds_are_refused() {
1732        let model = decay(1.0);
1733        let observed = model.evaluate(&[100.0, 1.5]).unwrap();
1734        for (lower, upper) in [
1735            (1.0, 0.0),
1736            (f64::NAN, 1.0),
1737            (0.0, f64::NAN),
1738            (f64::INFINITY, f64::INFINITY),
1739            (f64::NEG_INFINITY, f64::NEG_INFINITY),
1740        ] {
1741            let mut params = ParameterSet::new(vec![
1742                FitParameter {
1743                    name: "a".into(),
1744                    value: 50.0,
1745                    lower,
1746                    upper,
1747                    fixed: false,
1748                },
1749                FitParameter::non_negative("b", 1.0),
1750            ]);
1751            let result = poisson_fit(
1752                &model,
1753                &observed,
1754                &[],
1755                &mut params,
1756                &PoissonConfig::default(),
1757            );
1758            assert!(
1759                matches!(result, Err(FittingError::InvalidConfig(_))),
1760                "{lower}, {upper}"
1761            );
1762        }
1763    }
1764
1765    struct Additive;
1766
1767    impl FitModel for Additive {
1768        fn evaluate(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
1769            Ok(vec![2.0 * params[0], 1.0 - params[0]])
1770        }
1771
1772        fn analytical_jacobian(
1773            &self,
1774            _params: &[f64],
1775            free_param_indices: &[usize],
1776            _y_current: &[f64],
1777        ) -> Option<FlatMatrix> {
1778            let mut jacobian = FlatMatrix::zeros(2, free_param_indices.len());
1779            jacobian.data.copy_from_slice(&[2.0, -1.0]);
1780            Some(jacobian)
1781        }
1782    }
1783
1784    #[test]
1785    fn a_bin_predicted_zero_keeps_its_slope_in_the_gradient() {
1786        let mut params = ParameterSet::new(vec![FitParameter {
1787            name: "theta".into(),
1788            value: 0.0,
1789            lower: 0.0,
1790            upper: 1.0,
1791            fixed: false,
1792        }]);
1793        let result = poisson_fit(
1794            &Additive,
1795            &[0.0, 0.0],
1796            &[],
1797            &mut params,
1798            &PoissonConfig::default(),
1799        )
1800        .unwrap();
1801        assert!(
1802            result.converged
1803                && result.params[0] == 0.0
1804                && result.on_bound == vec![true]
1805                && result.unbounded.is_none(),
1806            "{result:?}"
1807        );
1808    }
1809
1810    struct Line2 {
1811        offset: [f64; 2],
1812        slope: [f64; 2],
1813    }
1814
1815    impl FitModel for Line2 {
1816        fn evaluate(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
1817            Ok((0..2)
1818                .map(|i| self.offset[i] + self.slope[i] * params[0])
1819                .collect())
1820        }
1821
1822        fn analytical_jacobian(
1823            &self,
1824            _params: &[f64],
1825            free_param_indices: &[usize],
1826            _y_current: &[f64],
1827        ) -> Option<FlatMatrix> {
1828            let mut jacobian = FlatMatrix::zeros(2, free_param_indices.len());
1829            jacobian.data.copy_from_slice(&self.slope);
1830            Some(jacobian)
1831        }
1832    }
1833
1834    fn bounded(value: f64, upper: f64) -> ParameterSet {
1835        ParameterSet::new(vec![FitParameter {
1836            name: "theta".into(),
1837            value,
1838            lower: 0.0,
1839            upper,
1840            fixed: false,
1841        }])
1842    }
1843
1844    #[test]
1845    fn a_zero_bin_slope_enters_the_convergence_test() {
1846        let model = Line2 {
1847            offset: [0.0, 4.0],
1848            slope: [1.0, -4.0],
1849        };
1850        let result = poisson_fit(
1851            &model,
1852            &[0.0, 3.0],
1853            &[],
1854            &mut bounded(0.0, 1.5),
1855            &PoissonConfig::default(),
1856        )
1857        .unwrap();
1858        assert!(
1859            result.converged && result.iterations == 0 && result.params[0] == 0.0,
1860            "{result:?}"
1861        );
1862    }
1863
1864    struct Affine {
1865        offset: Vec<f64>,
1866        jacobian: Vec<Vec<f64>>,
1867    }
1868
1869    impl FitModel for Affine {
1870        fn evaluate(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
1871            Ok(self
1872                .offset
1873                .iter()
1874                .zip(&self.jacobian)
1875                .map(|(offset, row)| {
1876                    offset + row.iter().zip(params).map(|(j, p)| j * p).sum::<f64>()
1877                })
1878                .collect())
1879        }
1880
1881        fn analytical_jacobian(
1882            &self,
1883            _params: &[f64],
1884            free_param_indices: &[usize],
1885            _y_current: &[f64],
1886        ) -> Option<FlatMatrix> {
1887            let mut jacobian = FlatMatrix::zeros(self.offset.len(), free_param_indices.len());
1888            jacobian.data.copy_from_slice(&self.jacobian.concat());
1889            Some(jacobian)
1890        }
1891    }
1892
1893    fn two_free() -> ParameterSet {
1894        ParameterSet::new(vec![
1895            FitParameter::unbounded("a", 0.0),
1896            FitParameter::unbounded("b", 0.0),
1897        ])
1898    }
1899
1900    fn at_the_start() -> PoissonConfig {
1901        PoissonConfig {
1902            max_iter: 0,
1903            ..PoissonConfig::default()
1904        }
1905    }
1906
1907    #[test]
1908    fn a_weak_direction_keeps_its_share_of_the_newton_decrement() {
1909        let model = Affine {
1910            offset: vec![1.0; 2],
1911            jacobian: vec![vec![1.0, 1.0], vec![1.0e-14, -1.0e-14]],
1912        };
1913        for observed in [[0.999, 0.998999], [0.999, 0.999001]] {
1914            let decrement = 0.5
1915                * observed
1916                    .iter()
1917                    .map(|y: &f64| (1.0 - y).powi(2))
1918                    .sum::<f64>();
1919            let result =
1920                poisson_fit(&model, &observed, &[], &mut two_free(), &at_the_start()).unwrap();
1921            assert_eq!(
1922                result.converged,
1923                decrement < NEWTON_DECREMENT_TOL,
1924                "{decrement:e}: {result:?}"
1925            );
1926        }
1927    }
1928
1929    #[test]
1930    fn the_information_inverse_is_poisson_fits_covariance() {
1931        for weak in [0.0, 1.0e-6] {
1932            let jacobian = vec![
1933                vec![1.0, 1.0 + weak, 0.0],
1934                vec![2.0, 2.0, 1.0],
1935                vec![1.0, 1.0, 3.0],
1936                vec![3.0, 3.0 - weak, 1.0],
1937            ];
1938            let model = Affine {
1939                offset: vec![5.0; 4],
1940                jacobian: jacobian.clone(),
1941            };
1942            let mut parameters = ParameterSet::new(
1943                ["a", "b", "c"]
1944                    .map(|name| FitParameter::unbounded(name, 0.0))
1945                    .to_vec(),
1946            );
1947            let result =
1948                poisson_fit(&model, &[5.0; 4], &[], &mut parameters, &at_the_start()).unwrap();
1949            let covariance = result.covariance.expect("covariance");
1950            let information = FlatMatrix {
1951                data: (0..9)
1952                    .map(|e| {
1953                        jacobian
1954                            .iter()
1955                            .map(|row| row[e / 3] * row[e % 3] / 5.0)
1956                            .sum()
1957                    })
1958                    .collect(),
1959                nrows: 3,
1960                ncols: 3,
1961            };
1962            let diagonal: Vec<f64> = (0..3).map(|i| information.get(i, i)).collect();
1963            let inverse = information_inverse(&information, &diagonal, 4).unwrap();
1964            for i in 0..3 {
1965                assert_eq!(
1966                    inverse.resolved[i],
1967                    !covariance.get(i, i).is_nan(),
1968                    "{weak}: {i}"
1969                );
1970                for j in (0..3).filter(|&j| inverse.resolved[i] && inverse.resolved[j]) {
1971                    let (got, expected) = (inverse.determined.get(i, j), covariance.get(i, j));
1972                    assert!(
1973                        (got - expected).abs() <= 1e-9 * expected.abs().max(1.0),
1974                        "{weak}: {got} vs {expected}"
1975                    );
1976                }
1977            }
1978            assert_eq!(inverse.spanned.get(0, 0) > 1e12, weak > 0.0, "{weak}");
1979        }
1980    }
1981
1982    #[test]
1983    fn an_information_with_nothing_determined_inverts_to_zero() {
1984        let inverse = information_inverse(&FlatMatrix::zeros(2, 2), &[0.0; 2], 4).unwrap();
1985        assert!(inverse.spanned.data.iter().all(|&x| x == 0.0));
1986        assert!(inverse.determined.data.iter().all(|&x| x == 0.0));
1987        assert_eq!(inverse.resolved, [false; 2]);
1988        let empty = information_inverse(&FlatMatrix::zeros(0, 0), &[], 4).unwrap();
1989        assert!(empty.resolved.is_empty());
1990    }
1991
1992    #[test]
1993    fn a_direction_the_steps_leave_out_has_no_error_bar() {
1994        let weak = 2.0e-12;
1995        let information = FlatMatrix {
1996            data: vec![1.0, 1.0 - weak, 1.0 - weak, 1.0],
1997            nrows: 2,
1998            ncols: 2,
1999        };
2000        for (rows, determined) in [(4, true), (100_000, false)] {
2001            let inverse = information_inverse(&information, &[1.0; 2], rows).unwrap();
2002            assert_eq!(inverse.resolved, [determined; 2], "{rows}");
2003            assert_eq!(
2004                inverse.undetermined.len(),
2005                usize::from(!determined),
2006                "{rows}"
2007            );
2008        }
2009    }
2010
2011    #[test]
2012    fn a_zero_bin_slope_enters_every_direction_of_the_decrement() {
2013        let jacobian = vec![vec![1.0, 2.0], vec![1.0, 3.0], vec![2.0, -1.0]];
2014        let offset: Vec<f64> = vec![0.0, 4.0, 9.0];
2015        let root = [offset[1].sqrt(), offset[2].sqrt()];
2016        let w = [
2017            [jacobian[1][0] / root[0], jacobian[1][1] / root[0]],
2018            [jacobian[2][0] / root[1], jacobian[2][1] / root[1]],
2019        ];
2020        let information = |a: usize, b: usize| w[0][a] * w[0][b] + w[1][a] * w[1][b];
2021        let inverse_00 =
2022            information(1, 1) / (information(0, 0) * information(1, 1) - information(0, 1).powi(2));
2023        let transpose_det = w[0][0] * w[1][1] - w[1][0] * w[0][1];
2024        for decrement in [0.99e-6_f64, 1.01e-6] {
2025            let gradient = [(2.0 * decrement / inverse_00).sqrt(), 0.0];
2026            let rhs = [gradient[0] - jacobian[0][0], gradient[1] - jacobian[0][1]];
2027            let residual = [
2028                (rhs[0] * w[1][1] - w[1][0] * rhs[1]) / transpose_det,
2029                (w[0][0] * rhs[1] - w[0][1] * rhs[0]) / transpose_det,
2030            ];
2031            let observed = [
2032                0.0,
2033                offset[1] - residual[0] * root[0],
2034                offset[2] - residual[1] * root[1],
2035            ];
2036            let model = Affine {
2037                offset: offset.clone(),
2038                jacobian: jacobian.clone(),
2039            };
2040            let result =
2041                poisson_fit(&model, &observed, &[], &mut two_free(), &at_the_start()).unwrap();
2042            assert_eq!(
2043                result.converged,
2044                decrement < NEWTON_DECREMENT_TOL,
2045                "{decrement:e}: {result:?}"
2046            );
2047        }
2048    }
2049
2050    #[test]
2051    fn a_fit_with_hundreds_of_parameters_has_the_error_bars_of_its_inverse_information() {
2052        use rand::SeedableRng;
2053        use rand_chacha::ChaCha12Rng;
2054        use rand_distr::{Distribution, StandardNormal};
2055        let (bins, count, level) = (300, 150, 1.0e4);
2056        let mut rng = ChaCha12Rng::seed_from_u64(806);
2057        let jacobian: Vec<Vec<f64>> = (0..bins)
2058            .map(|_| {
2059                (0..count)
2060                    .map(|_| StandardNormal.sample(&mut rng))
2061                    .collect()
2062            })
2063            .collect();
2064        let model = Affine {
2065            offset: vec![level; bins],
2066            jacobian: jacobian.clone(),
2067        };
2068        let mut params = ParameterSet::new(
2069            (0..count)
2070                .map(|j| FitParameter::unbounded(format!("c{j}"), 0.0))
2071                .collect(),
2072        );
2073        let result = poisson_fit(
2074            &model,
2075            &vec![level; bins],
2076            &[],
2077            &mut params,
2078            &PoissonConfig::default(),
2079        )
2080        .unwrap();
2081        assert!(result.converged && result.iterations == 0, "{result:?}");
2082        let mut cholesky = vec![vec![0.0; count]; count];
2083        for a in 0..count {
2084            for b in 0..=a {
2085                let information: f64 = jacobian.iter().map(|row| row[a] * row[b] / level).sum();
2086                let rest =
2087                    information - (0..b).map(|c| cholesky[a][c] * cholesky[b][c]).sum::<f64>();
2088                cholesky[a][b] = if a == b {
2089                    rest.sqrt()
2090                } else {
2091                    rest / cholesky[b][b]
2092                };
2093            }
2094        }
2095        for (j, error) in result.uncertainties.unwrap().into_iter().enumerate() {
2096            let mut solved = vec![0.0; count];
2097            for a in 0..count {
2098                let unit = if a == j { 1.0 } else { 0.0 };
2099                solved[a] = (unit - (0..a).map(|c| cholesky[a][c] * solved[c]).sum::<f64>())
2100                    / cholesky[a][a];
2101            }
2102            let expected = solved.iter().map(|v| v * v).sum::<f64>().sqrt();
2103            let error = error.unwrap();
2104            assert!(
2105                (error / expected - 1.0).abs() < 1e-10,
2106                "{j}: {error} vs {expected}"
2107            );
2108        }
2109    }
2110
2111    struct SilentZeroBin;
2112
2113    impl FitModel for SilentZeroBin {
2114        fn evaluate(&self, _params: &[f64]) -> Result<Vec<f64>, FittingError> {
2115            Ok(vec![0.0])
2116        }
2117
2118        fn analytical_jacobian(
2119            &self,
2120            _params: &[f64],
2121            free_param_indices: &[usize],
2122            _y_current: &[f64],
2123        ) -> Option<FlatMatrix> {
2124            let mut jacobian = FlatMatrix::zeros(1, free_param_indices.len());
2125            jacobian.data[0] = f64::NAN;
2126            Some(jacobian)
2127        }
2128    }
2129
2130    #[test]
2131    fn a_non_finite_slope_in_a_zero_bin_ends_the_fit_unconverged() {
2132        let mut params = ParameterSet::new(vec![FitParameter::unbounded("theta", 0.0)]);
2133        let result = poisson_fit(
2134            &SilentZeroBin,
2135            &[0.0],
2136            &[],
2137            &mut params,
2138            &PoissonConfig::default(),
2139        )
2140        .unwrap();
2141        assert!(!result.converged, "{result:?}");
2142    }
2143
2144    struct Wall;
2145
2146    impl FitModel for Wall {
2147        fn evaluate(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
2148            let x = params[0];
2149            Ok(vec![(100.0 - x + (x - 80.0).max(0.0).powi(2)).exp()])
2150        }
2151
2152        fn analytical_jacobian(
2153            &self,
2154            params: &[f64],
2155            free_param_indices: &[usize],
2156            _y_current: &[f64],
2157        ) -> Option<FlatMatrix> {
2158            let x = params[0];
2159            let mean = (100.0 - x + (x - 80.0).max(0.0).powi(2)).exp();
2160            let mut jacobian = FlatMatrix::zeros(1, free_param_indices.len());
2161            jacobian.data[0] = mean * (2.0 * (x - 80.0).max(0.0) - 1.0);
2162            Some(jacobian)
2163        }
2164    }
2165
2166    #[test]
2167    fn damping_recovers_after_many_accepted_steps_to_reach_the_minimum() {
2168        let mut params = ParameterSet::new(vec![FitParameter::unbounded("x", 0.0)]);
2169        let result =
2170            poisson_fit(&Wall, &[0.0], &[], &mut params, &PoissonConfig::default()).unwrap();
2171        assert!((result.params[0] - 80.5).abs() < 1e-3, "{result:?}");
2172    }
2173
2174    #[test]
2175    fn non_finite_parameter_values_are_refused() {
2176        let model = decay(1.0);
2177        let observed = model.evaluate(&[100.0, 1.5]).unwrap();
2178        for (a, b) in [(f64::NAN, 1.0), (50.0, f64::INFINITY)] {
2179            let mut params = ParameterSet::new(vec![
2180                FitParameter::non_negative("a", a),
2181                FitParameter::fixed("b", b),
2182            ]);
2183            let result = poisson_fit(
2184                &model,
2185                &observed,
2186                &[],
2187                &mut params,
2188                &PoissonConfig::default(),
2189            );
2190            assert!(
2191                matches!(result, Err(FittingError::InvalidConfig(_))),
2192                "{a}, {b}"
2193            );
2194        }
2195    }
2196
2197    struct Shrinking;
2198
2199    impl FitModel for Shrinking {
2200        fn evaluate(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
2201            let bins = if params[0] < 0.5 { 3 } else { 2 };
2202            Ok(vec![1.0 + params[0]; bins])
2203        }
2204
2205        fn analytical_jacobian(
2206            &self,
2207            params: &[f64],
2208            free_param_indices: &[usize],
2209            _y_current: &[f64],
2210        ) -> Option<FlatMatrix> {
2211            let bins = if params[0] < 0.5 { 3 } else { 2 };
2212            let mut jacobian = FlatMatrix::zeros(bins, free_param_indices.len());
2213            jacobian.data.fill(1.0);
2214            Some(jacobian)
2215        }
2216    }
2217
2218    #[test]
2219    fn a_trial_prediction_of_the_wrong_length_is_rejected() {
2220        let mut params = ParameterSet::new(vec![FitParameter::non_negative("theta", 0.0)]);
2221        let result = poisson_fit(
2222            &Shrinking,
2223            &[3.0; 3],
2224            &[],
2225            &mut params,
2226            &PoissonConfig::default(),
2227        )
2228        .unwrap();
2229        assert_eq!(
2230            Shrinking.evaluate(&result.params).unwrap().len(),
2231            3,
2232            "{result:?}"
2233        );
2234    }
2235}