#[macro_use]
#[cfg(test)]
pub mod basis_assertions;
#[doc(hidden)]
pub mod assertions;
#[macro_export]
macro_rules! function {
($( $(+)? $c0:literal $($x:ident $( ^ $d0:literal )?)? )+) => { {
const LEN: usize = {
let mut degree = 0; $(
let d2 = 1 $(+ 1 $(* $d0 as usize)?)?;
if d2 > degree { degree = d2; }
)+
degree
};
const COEFS: [f64; LEN] = {
let mut coefs = [0.0; LEN];
$( coefs[ 0 $(+ 1 $(* $d0 as usize)?)? ] += $c0 as f64; )+
coefs
};
$crate::MonomialPolynomial::borrowed(&COEFS)
}};
($name:ident ($x:ident) = $($rest:tt)+ ) => {
let $name: $crate::MonomialPolynomial = $crate::function!($($rest)+);
};
(const $name:ident ($x:ident) = $($rest:tt)+ ) => {
const $name: $crate::MonomialPolynomial<'static> = $crate::function!($($rest)+);
};
(static $name:ident ($x:ident) = $($rest:tt)+ ) => {
static $name: $crate::MonomialPolynomial<'static> = $crate::function!($($rest)+);
};
}
#[doc(hidden)]
pub struct FitProps<T: crate::value::Value> {
pub model_score: T,
pub rating: T,
#[cfg(feature = "plotting")]
pub plot_e: crate::plotting::PlottingElement<T>,
pub name: &'static str,
pub r2: T,
pub max_r2: T,
pub p_value: T,
pub stars: T,
pub equation: String,
pub parameters: usize,
}
impl<T: crate::value::Value> FitProps<T> {
pub fn from_fit<B>(
fit: &crate::CurveFit<B, T>,
basis_name: &'static str,
method: &impl crate::score::ModelScoreProvider<B, T>,
) -> FitProps<T>
where
B: crate::basis::Basis<T> + crate::display::PolynomialDisplay<T>,
{
use crate::value::{CoordExt, FloatClampedCast};
#[cfg(feature = "plotting")]
use crate::plotting::AsPlottingElement;
const R2_WEIGHT: f64 = 0.75;
const P_VALUE_WEIGHT: f64 = 0.25;
let equation = fit.equation();
let model_score = fit.model_score(method);
let residuals = fit.filtered_residuals().y();
let r2 = fit.r_squared(None);
let robust_r2 = fit.robust_r_squared(None);
let max_r2 = crate::value::Value::max(r2, robust_r2);
let p_value = crate::statistics::residual_normality(&residuals);
let r2_weight = R2_WEIGHT.clamped_cast::<T>();
let p_weight = P_VALUE_WEIGHT.clamped_cast::<T>();
let rating = r2_weight * max_r2 + p_weight * p_value;
let rating = crate::value::Value::clamp(rating, T::zero(), T::one());
let parameters = fit.coefficients().len();
#[cfg(feature = "plotting")]
let plot_e = fit.as_plotting_element(&[], crate::statistics::Confidence::P95, None);
Self {
model_score,
rating,
#[cfg(feature = "plotting")]
plot_e,
name: basis_name,
r2,
max_r2,
p_value,
stars: T::zero(), equation,
parameters,
}
}
}
#[macro_export]
macro_rules! basis_select {
($data:expr, $degree_bound:expr, $method:expr) => {{
use $crate::basis::*;
$crate::basis_select!($data, $degree_bound, $method, options = [
ChebyshevBasis<f64> = "Chebyshev",
FourierBasis<f64> = "Fourier",
LegendreBasis<f64> = "Legendre",
PhysicistsHermiteBasis<f64> = "Physicists' Hermite",
ProbabilistsHermiteBasis<f64> = "Probabilists' Hermite",
LaguerreBasis<f64> = "Laguerre",
LogarithmicBasis<f64> = "Logarithmic",
])
}};
($data:expr, $degree_bound:expr, $method:expr, options = [ $( $basis:path $( = $name:literal)? ),+ $(,)? ]) => {{
let num_basis = 0 $( + { let _ = stringify!($basis); 1 } )+;
let count = $data.len();
println!("[ Evaluating {count} data points against {num_basis} basis options ]\n");
if count < 100 {
println!("[ WARNING - SMALL DATASET ]");
println!("[ Results may be misleading for small datasets (<100 points) ]\n");
}
let mut all_normals_zero = true;
let mut options = vec![];
let mut min_params = usize::MAX;
$(
if let Ok(fit) = $crate::CurveFit::<$basis>::new_auto($data, $degree_bound, $method) {
#[allow(unused_mut, unused_assignments)] let mut name = stringify!($basis); $( name = $name; )?
let props = $crate::test::FitProps::from_fit(&fit, name, $method);
if props.parameters < min_params {
min_params = props.parameters;
}
if props.p_value > f64::EPSILON {
all_normals_zero = false;
}
options.push(props);
}
)+
let normalizer = $crate::statistics::DomainNormalizer::new((0.3, 1.0), (0.0, 5.0));
for o in options.iter_mut() {
let param_penalty = o.parameters as f64 / min_params as f64;
o.rating /= param_penalty;
o.stars = normalizer.normalize(o.rating).clamp(0.0, 5.0);
}
options.sort_by(|p1, p2| p1.model_score.total_cmp(&p2.model_score));
let best_score = options.first().map_or(0.0, |p| p.model_score);
let likelihoods: Vec<_> = options.iter().map(|o| (-0.5 * (o.model_score - best_score)).exp()).collect();
let sum_likelihoods: f64 = likelihoods.iter().sum();
for (o, l) in options.iter_mut().zip(likelihoods) {
o.model_score = l / sum_likelihoods;
}
let (basis, parameters, score, r2, norm, rating) = ("Basis", "Params", "Score Weight", "Fit Quality", "Normality", "Rating");
let sep = || { println!("--|-{:-^30}-|-{:-^6}-|-{:-^12}-|-{:-^11}-|-{:-^9}-|-{:-^10}", "", "", "", "", "", ""); };
println!("# | {basis:^30} | {parameters:^6} | {score:^12} | {r2:^11} | {norm:^9} | {rating}");
sep();
for (i, props) in options.iter().enumerate() {
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let whole_stars = props.stars.round() as usize;
let name = props.name;
let parameters = props.parameters;
let score = props.model_score * 100.0;
let r2 = props.max_r2 * 100.0;
let norm = props.p_value * 100.0;
let rating = props.rating * 100.0;
let vis_stars = "☆".repeat(5 - whole_stars) + &"★".repeat(whole_stars);
let score = format!("{score:.2}%");
let r2 = format!("{r2:.2}%");
let rating = format!("{rating:.0}%");
let norm = if all_normals_zero {
"-----".to_string()
} else {
format!("{norm:.2}%")
};
let n = i + 1;
println!(
"{n} | {name:>30} | {parameters:>6} | {score:>12} | {r2:>11} | {norm:>9} | {rating} {vis_stars}",
);
if i == 2 {
sep();
}
}
println!();
println!("[ How to interpret the results ]");
println!("[ Results may be misleading for small datasets (<100 points) ]");
println!(" - Params: Number of parameters (coefficients) in the fitted model. Less means simpler model, less risk of overfitting.");
println!(" - Score Weight: Relative likelihood of being the best model among the options tested, based on the scoring method used.");
println!(" - Fit Quality: Proportion of variance in the data explained by the model (uses huber loss weighted r2).");
println!(" - Normality: How closely the residuals follow a normal distribution (useless for small datasets).");
println!(" - Rating: Combined score (0.75 * Fit Quality + 0.25 * Normality) to give an overall quality measure.");
println!(" - Stars: A simple star rating out of 5 based on the Rating score. Not scientific.");
println!(" - The best 3 models are shown below with their equations and plots (if enabled).");
for props in &options {
println!();
println!("{}: {}", props.name, props.equation);
println!("Fit R²: {:.4}, Residuals Normality p-value: {:.4}", props.r2, props.p_value);
let prefix = props.name.to_lowercase().replace([' ', '\'', '"', '<', '>', ':', ';', ',', '.'], "_");
$crate::plot!(props.plot_e, {
title: props.name.to_string()
}, prefix = prefix);
}
}};
}
#[cfg(test)]
#[cfg(feature = "transforms")]
mod tests {
#[test]
fn test_bselect() {
use crate::score::Aic;
use crate::statistics::DegreeBound;
use crate::transforms::{ApplyNoise, Strength};
function!(test(x) = 2.0 x^3 + 3.0 x^2 - 4.0 x + 5.0);
let data = test
.solve_range(0.0..=1000.0, 1.0)
.apply_normal_noise(Strength::Relative(0.3), None);
basis_select!(&data, DegreeBound::Relaxed, &Aic);
}
}