use crate::data::{self, Sample};
use crate::error::Error;
use crate::fit::{self, Fit};
use crate::model::{self, Model};
use crate::warning::Warning;
const SIMPLICITY_TOLERANCE: f64 = 0.15;
const PARAMETER_TOLERANCE: f64 = 0.15;
const SCORE_FLOOR: f64 = 1e-8;
const ADVISED_POINTS: usize = 6;
const ADVISED_DECADES: f64 = 3.0;
const RESAMPLES: usize = 100;
const SEED: u64 = 0x9E37_79B9_7F4A_7C15;
#[derive(Clone, Debug)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[non_exhaustive]
pub struct Inference {
pub best: Fit,
pub all: Vec<Fit>,
pub confidence: f64,
pub warnings: Vec<Warning>,
}
#[derive(Clone, Debug)]
pub struct Analysis {
models: Vec<Model>,
advised_points: usize,
advised_decades: f64,
}
impl Default for Analysis {
fn default() -> Self {
Self::new()
}
}
impl Analysis {
pub fn new() -> Self {
Self {
models: model::ALL.to_vec(),
advised_points: ADVISED_POINTS,
advised_decades: ADVISED_DECADES,
}
}
pub fn models(mut self, models: impl IntoIterator<Item = Model>) -> Self {
self.models = models.into_iter().collect();
self
}
pub fn accept_range(mut self, sizes: usize, decades: f64) -> Self {
self.advised_points = sizes;
self.advised_decades = decades;
self
}
pub fn infer(&self, data: &[(f64, f64)]) -> Result<Inference, Error> {
let sample = data::prepare(data);
let points = sample.points().len();
if points < data::MIN_POINTS {
return Err(Error::NotEnoughData {
needed: data::MIN_POINTS,
got: points,
});
}
let (all, unfittable) = self.fit_all(&sample);
let best = select(&all, points).ok_or(Error::NoValidComplexity)?;
Ok(Inference {
confidence: self.confidence(&sample, best.model),
warnings: self.warnings(&sample, &unfittable),
best,
all,
})
}
fn fit_all(&self, sample: &Sample) -> (Vec<Fit>, Vec<Model>) {
let points = sample.points().len();
let mut fits: Vec<Fit> = Vec::with_capacity(self.models.len());
let mut unfittable: Vec<Model> = Vec::new();
for &model in &self.models {
match fit::fit(model, sample) {
Some(fit) if is_plausible(&fit) => fits.push(fit),
Some(_) => {}
None => unfittable.push(model),
}
}
fits.sort_by(|a, b| cmp(corrected_error(a, points), corrected_error(b, points)));
(fits, unfittable)
}
fn confidence(&self, sample: &Sample, best: Model) -> f64 {
let points = sample.points();
if points.is_empty() {
return 0.0;
}
let mut rng = Rng::new(SEED);
let mut agreed = 0usize;
let mut compared = 0usize;
for _ in 0..RESAMPLES {
let drawn: Vec<(f64, f64)> = (0..points.len())
.filter_map(|_| points.get(rng.below(points.len())).copied())
.collect();
let resample = data::prepare(&drawn);
if resample.points().len() < data::MIN_POINTS {
continue;
}
compared += 1;
let (refitted, _) = self.fit_all(&resample);
if select(&refitted, resample.points().len()).is_some_and(|fit| fit.model == best) {
agreed += 1;
}
}
match compared {
0 => 0.0,
_ => agreed as f64 / compared as f64,
}
}
fn warnings(&self, sample: &Sample, unfittable: &[Model]) -> Vec<Warning> {
let mut warnings = Vec::new();
let points = sample.points().len();
if points < self.advised_points {
warnings.push(Warning::TooFewPoints {
got: points,
advised: self.advised_points,
});
}
let decades = sample.decades();
if decades < self.advised_decades {
warnings.push(Warning::NarrowRange {
decades,
advised: self.advised_decades,
});
}
if sample.is_non_monotonic() {
warnings.push(Warning::NonMonotonic);
}
if sample.is_decreasing() {
warnings.push(Warning::DecreasingCost);
}
if !unfittable.is_empty() {
warnings.push(Warning::ModelsSkipped(unfittable.to_vec()));
}
warnings
}
}
const DEGENERATE_BASE: f64 = 1e-3;
fn is_plausible(fit: &Fit) -> bool {
use crate::fit::ModelParams::*;
match fit.params {
Constant { offset } => offset >= 0.0,
Exponential { gain, base } => gain >= 0.0 && (base - 1.0).abs() > DEGENERATE_BASE,
Logarithmic { gain, .. }
| Linear { gain, .. }
| Linearithmic { gain, .. }
| Quadratic { gain, .. }
| Cubic { gain, .. }
| Polynomial { gain, .. } => gain >= 0.0,
}
}
fn parameters(model: Model) -> usize {
match model {
Model::Constant => 1,
_ => 2,
}
}
fn corrected_error(fit: &Fit, points: usize) -> f64 {
let spent = parameters(fit.model);
match points > spent {
true => fit.relative_error * (points as f64 / (points - spent) as f64).sqrt(),
false => fit.relative_error,
}
}
fn flexibility(model: Model) -> u8 {
match model {
Model::Constant => 0,
_ if model.has_free_exponent() => 2,
_ => 1,
}
}
fn cmp(a: f64, b: f64) -> std::cmp::Ordering {
a.partial_cmp(&b).unwrap_or(std::cmp::Ordering::Equal)
}
fn fits_as_well_as(candidate: &Fit, best: &Fit, points: usize) -> bool {
let saved = parameters(best.model).saturating_sub(parameters(candidate.model));
let allowance = 1.0 + SIMPLICITY_TOLERANCE + PARAMETER_TOLERANCE * saved as f64;
corrected_error(candidate, points) <= corrected_error(best, points) * allowance + SCORE_FLOOR
}
fn select(fitted: &[Fit], points: usize) -> Option<Fit> {
let best = fitted.first().copied()?;
fitted
.iter()
.filter(|fit| fits_as_well_as(fit, &best, points))
.min_by(|a, b| {
flexibility(a.model)
.cmp(&flexibility(b.model))
.then(cmp(corrected_error(a, points), corrected_error(b, points)))
})
.copied()
.or(Some(best))
}
struct Rng(u64);
impl Rng {
fn new(seed: u64) -> Self {
Self(seed)
}
fn next_u64(&mut self) -> u64 {
let mut x = self.0;
x ^= x << 13;
x ^= x >> 7;
x ^= x << 17;
self.0 = x;
x
}
fn below(&mut self, n: usize) -> usize {
match n {
0 => 0,
n => (self.next_u64() % n as u64) as usize,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::fit::ModelParams;
const POINTS: usize = 24;
fn fit_of(model: Model, params: ModelParams, relative_error: f64) -> Fit {
Fit {
model,
params,
r_squared: 1.0 - relative_error,
relative_error,
}
}
fn quadratic(relative_error: f64) -> Fit {
fit_of(
Model::Quadratic,
ModelParams::Quadratic {
gain: 1.0,
offset: 0.0,
},
relative_error,
)
}
fn polynomial(power: f64, relative_error: f64) -> Fit {
fit_of(
Model::Polynomial,
ModelParams::Polynomial { gain: 1.0, power },
relative_error,
)
}
fn constant(relative_error: f64) -> Fit {
fit_of(
Model::Constant,
ModelParams::Constant { offset: 1.0 },
relative_error,
)
}
fn linear(relative_error: f64) -> Fit {
fit_of(
Model::Linear,
ModelParams::Linear {
gain: 1.0,
offset: 0.0,
},
relative_error,
)
}
#[test]
fn prefers_a_named_model_that_ties_with_a_free_exponent() {
let chosen = select(&[polynomial(2.0004, 0.0200), quadratic(0.0201)], POINTS);
assert_eq!(chosen.map(|fit| fit.model), Some(Model::Quadratic));
}
#[test]
fn keeps_a_free_exponent_that_wins_by_more_than_the_margin() {
let chosen = select(&[polynomial(2.5, 0.001), quadratic(0.15)], POINTS);
assert_eq!(chosen.map(|fit| fit.model), Some(Model::Polynomial));
}
#[test]
fn prefers_a_named_model_when_both_fit_exactly() {
let chosen = select(&[polynomial(2.0, 1e-17), quadratic(3e-16)], POINTS);
assert_eq!(chosen.map(|fit| fit.model), Some(Model::Quadratic));
}
#[test]
fn prefers_a_constant_to_a_line_that_only_tilts_towards_the_noise() {
let chosen = select(&[linear(0.0495), constant(0.0500)], POINTS);
assert_eq!(chosen.map(|fit| fit.model), Some(Model::Constant));
}
#[test]
fn keeps_a_line_that_beats_a_constant_by_more_than_the_margin() {
let chosen = select(&[linear(0.01), constant(0.40)], POINTS);
assert_eq!(chosen.map(|fit| fit.model), Some(Model::Linear));
}
#[test]
fn leaves_a_named_winner_alone() {
let chosen = select(&[quadratic(0.01), polynomial(2.5, 0.30)], POINTS);
assert_eq!(chosen.map(|fit| fit.model), Some(Model::Quadratic));
}
#[test]
fn selects_nothing_from_nothing() {
assert_eq!(select(&[], POINTS), None);
}
#[test]
fn rejects_a_curve_that_would_go_negative() {
assert!(!is_plausible(&fit_of(
Model::Linear,
ModelParams::Linear {
gain: -1.0,
offset: 0.0
},
0.0
)));
assert!(is_plausible(&polynomial(-1.0, 0.0)), "a falling cost");
}
#[test]
fn rejects_an_exponential_that_is_really_a_constant() {
let degenerate = fit_of(
Model::Exponential,
ModelParams::Exponential {
gain: 5.0,
base: 1.0,
},
0.0,
);
let genuine = fit_of(
Model::Exponential,
ModelParams::Exponential {
gain: 5.0,
base: 1.5,
},
0.0,
);
assert!(!is_plausible(°enerate));
assert!(is_plausible(&genuine));
}
#[test]
fn resampling_is_reproducible() {
let mut a = Rng::new(SEED);
let mut b = Rng::new(SEED);
let drawn: Vec<usize> = (0..32).map(|_| a.below(10)).collect();
assert!(drawn.iter().all(|&i| i < 10));
assert_eq!(drawn, (0..32).map(|_| b.below(10)).collect::<Vec<_>>());
assert!(drawn.windows(2).any(|pair| pair[0] != pair[1]), "not stuck");
assert_eq!(Rng::new(SEED).below(0), 0);
}
}