use self::numeric_traits::CastF64;
use crate::{fit::FitResult, prelude::SeparableNonlinearModel, problem::SingleRhs, util::Weights};
use nalgebra::{
allocator::Allocator, ComplexField, DefaultAllocator, Dim, DimAdd, DimMin, DimSub, Dyn, Matrix,
OMatrix, OVector, RealField, Scalar, VectorView, U0, U1,
};
use num_traits::{Float, FromPrimitive, One, Zero};
use thiserror::Error as ThisError;
#[cfg(any(test, doctest))]
mod test;
pub mod numeric_traits;
#[derive(Debug, Clone, ThisError)]
pub enum Error<ModelError: std::error::Error> {
#[error("Model returned error when it was evaluated:{}", .0)]
ModelEvaluation(#[from] ModelError),
#[error("Fit is underdetermined")]
Underdetermined,
#[error("Floating point unable to capture integral value {}", .0)]
IntegerToFloatConversion(usize),
#[error("Matrix inversion error")]
MatrixInversion,
#[error("Failed to calculate linear coefficients")]
LinearCoeffs,
}
#[derive(Debug, Clone)]
pub struct FitStatistics<Model>
where
Model: SeparableNonlinearModel,
DefaultAllocator: Allocator<Dyn, Dyn>,
DefaultAllocator: Allocator<Dyn>,
{
covariance_matrix: OMatrix<Model::ScalarType, Dyn, Dyn>,
weighted_residuals: OVector<Model::ScalarType, Dyn>,
reduced_chi2: Model::ScalarType,
linear_coefficient_count: usize,
degrees_of_freedom: usize,
nonlinear_parameter_count: usize,
unscaled_confidence_sigma: OVector<Model::ScalarType, Dyn>,
}
impl<Model> FitStatistics<Model>
where
Model: SeparableNonlinearModel,
DefaultAllocator: Allocator<Dyn, Dyn>,
DefaultAllocator: Allocator<Dyn>,
{
pub fn covariance_matrix(&self) -> &OMatrix<Model::ScalarType, Dyn, Dyn> {
&self.covariance_matrix
}
#[deprecated(note = "Use the method calc_correlation_matrix.", since = "0.9.0")]
pub fn correlation_matrix(&self) -> OMatrix<Model::ScalarType, Dyn, Dyn>
where
Model::ScalarType: Float,
{
self.calculate_correlation_matrix().clone()
}
pub fn calculate_correlation_matrix(&self) -> OMatrix<Model::ScalarType, Dyn, Dyn>
where
Model::ScalarType: Float,
{
calc_correlation_matrix(&self.covariance_matrix)
}
pub fn weighted_residuals(&self) -> OVector<Model::ScalarType, Dyn> {
self.weighted_residuals.clone()
}
pub fn regression_standard_error(&self) -> Model::ScalarType
where
Model::ScalarType: Float,
{
Float::sqrt(self.reduced_chi2)
}
pub fn reduced_chi2(&self) -> Model::ScalarType {
self.reduced_chi2.clone()
}
pub fn nonlinear_parameters_variance(&self) -> OVector<Model::ScalarType, Dyn>
where
Model::ScalarType: Scalar + Zero,
DefaultAllocator: Allocator<Dyn>,
{
let total_parameter_count = self.linear_coefficient_count + self.nonlinear_parameter_count;
let diagonal = self.covariance_matrix.diagonal();
extract_range(
&diagonal,
Dyn(self.linear_coefficient_count),
Dyn(total_parameter_count),
)
}
pub fn linear_coefficients_variance(&self) -> OVector<Model::ScalarType, Dyn>
where
Model::ScalarType: Scalar + Zero,
DefaultAllocator: Allocator<Dyn>,
{
let diagonal = self.covariance_matrix.diagonal();
extract_range(&diagonal, U0, Dyn(self.linear_coefficient_count))
}
pub fn confidence_band_radius(
&self,
probability: Model::ScalarType,
) -> OVector<Model::ScalarType, Dyn>
where
Model::ScalarType: num_traits::Float + One + Zero + CastF64,
{
assert!(
probability.is_finite()
&& probability > Model::ScalarType::ZERO
&& probability < Model::ScalarType::ONE,
"probability must be in open interval (0.,1.)"
);
let t_scale = distrs::StudentsT::ppf(
(probability.into_f64() + 1.) / 2.,
f64::from_usize(self.degrees_of_freedom).expect("failed int to float conversion"),
);
let mut confidence_radius =
OVector::<Model::ScalarType, Dyn>::zeros(self.unscaled_confidence_sigma.nrows());
confidence_radius
.iter_mut()
.zip(self.unscaled_confidence_sigma.iter())
.for_each(|(cb, sigma)| {
*cb = CastF64::from_f64(t_scale * sigma.into_f64());
});
confidence_radius
}
}
fn extract_range<ScalarType, D, Start, End>(
vector: &OVector<ScalarType, D>,
start: Start,
end: End,
) -> OVector<ScalarType, <End as DimSub<Start>>::Output>
where
ScalarType: Scalar + Zero,
D: Dim,
Start: Dim,
End: DimMin<Start>,
End: DimSub<Start>,
nalgebra::DefaultAllocator: Allocator<D>,
nalgebra::DefaultAllocator:
nalgebra::allocator::Allocator<<End as nalgebra::DimSub<Start>>::Output>,
{
assert!(end.value() >= start.value());
assert!(end.value() <= vector.nrows());
let mut range = OVector::<ScalarType, <End as DimSub<Start>>::Output>::zeros_generic(
<End as DimSub<Start>>::Output::from_usize(end.value() - start.value()),
U1,
);
for (idx, val) in range.iter_mut().enumerate() {
*val = vector[(idx + start.value(), 0)].clone();
}
range
}
impl<Model> TryFrom<&FitResult<Model, SingleRhs>> for FitStatistics<Model>
where
Model: SeparableNonlinearModel,
DefaultAllocator: Allocator<Dyn, Dyn>,
DefaultAllocator: Allocator<Dyn>,
Model::ScalarType: Scalar + ComplexField + Float + Zero + FromPrimitive,
Model::ScalarType: Scalar + Copy + RealField + Float + Zero + FromPrimitive,
{
type Error = self::Error<Model::Error>;
fn try_from(value: &FitResult<Model, SingleRhs>) -> Result<Self, Self::Error> {
Self::try_calculate(
value.problem.model(),
value.problem.weighted_data(),
&value.problem.weights,
value
.linear_coefficients
.as_ref()
.ok_or(Error::LinearCoeffs)?
.as_view(),
)
}
}
impl<Model> FitStatistics<Model>
where
Model: SeparableNonlinearModel,
DefaultAllocator: Allocator<Dyn, Dyn>,
DefaultAllocator: Allocator<Dyn>,
{
#[allow(non_snake_case)]
pub(crate) fn try_calculate(
model: &Model,
weighted_data: VectorView<Model::ScalarType, Dyn>,
weights: &Weights<Model::ScalarType, Dyn>,
linear_coefficients: VectorView<Model::ScalarType, Dyn>,
) -> Result<Self, Error<Model::Error>>
where
Model::ScalarType: Scalar + Copy + RealField + Float + Zero + FromPrimitive,
Model: SeparableNonlinearModel,
DefaultAllocator: Allocator<Dyn>,
DefaultAllocator: Allocator<Dyn, Dyn>,
{
debug_assert_eq!(
weighted_data.ncols(),
linear_coefficients.ncols(),
"Data dims and linear coefficient dims don't match. Indicates logic error in library!"
);
debug_assert_eq!(
weighted_data.nrows(),
model.output_len(),
"model output dimensions and data dimensions do not match. Indicates a programming error in this library!"
);
let output_len = model.output_len();
let J = model_function_jacobian(model, linear_coefficients)?;
let H = weights * J.clone();
let weighted_residuals = weighted_data - weights * model.eval()? * linear_coefficients;
let total_parameter_count = model.parameter_count() + model.base_function_count();
let degrees_of_freedom = output_len - total_parameter_count;
if output_len <= total_parameter_count {
return Err(Error::Underdetermined);
}
let reduced_chi2 = weighted_residuals.norm_squared()
/ Model::ScalarType::from_usize(degrees_of_freedom)
.ok_or(Error::IntegerToFloatConversion(degrees_of_freedom))?;
let sigma: Model::ScalarType = Float::sqrt(reduced_chi2);
let HTH_inv = (H.transpose() * H)
.try_inverse()
.ok_or(Error::MatrixInversion)?;
let covariance_matrix = HTH_inv * sigma * sigma;
let mut unscaled_confidence_sigma = OVector::<Model::ScalarType, Dyn>::zeros(output_len);
unscaled_confidence_sigma
.iter_mut()
.zip(J.row_iter())
.for_each(|(sig, j)| {
let j = j.transpose();
*sig = j.dot(&(&covariance_matrix * &j));
*sig = Float::sqrt(*sig);
});
Ok(Self {
covariance_matrix,
reduced_chi2,
weighted_residuals,
linear_coefficient_count: model.base_function_count(),
degrees_of_freedom,
nonlinear_parameter_count: model.parameter_count(),
unscaled_confidence_sigma,
})
}
}
fn calc_correlation_matrix<ScalarType, D>(
covariance_matrix: &OMatrix<ScalarType, D, D>,
) -> OMatrix<ScalarType, D, D>
where
ScalarType: Float + Scalar + Zero,
D: Dim,
nalgebra::DefaultAllocator: nalgebra::allocator::Allocator<D, D>,
{
assert_eq!(
covariance_matrix.nrows(),
covariance_matrix.ncols(),
"covariance matrix must be square"
);
let mut correlation_matrix = OMatrix::zeros_generic(
D::from_usize(covariance_matrix.nrows()),
D::from_usize(covariance_matrix.ncols()),
);
for i in 0..correlation_matrix.nrows() {
let c_ii = covariance_matrix[(i, i)];
for j in 0..correlation_matrix.ncols() {
let c_jj = covariance_matrix[(j, j)];
let sqrt_c_ii_c_jj = Float::sqrt(c_ii * c_jj);
correlation_matrix[(i, j)] = covariance_matrix[(i, j)] / sqrt_c_ii_c_jj;
}
}
correlation_matrix
}
#[allow(non_snake_case)]
fn model_function_jacobian<Model>(
model: &Model,
c: VectorView<Model::ScalarType, Dyn>,
) -> Result<OMatrix<Model::ScalarType, Dyn, Dyn>, Model::Error>
where
Model: SeparableNonlinearModel,
Model::ScalarType: Float + Zero + Scalar + ComplexField,
nalgebra::DefaultAllocator: Allocator<Dyn>,
nalgebra::DefaultAllocator: Allocator<Dyn, Dyn>,
{
let mut jacobian_matrix_for_nonlinear_params =
OMatrix::<Model::ScalarType, Dyn, Dyn>::zeros_generic(
Dyn(model.output_len()),
Dyn(model.parameter_count()),
);
for (idx, mut col) in jacobian_matrix_for_nonlinear_params
.column_iter_mut()
.enumerate()
{
col.copy_from(&(model.eval_partial_deriv(idx)? * c));
}
Ok(concat_colwise(
model.eval()?,
jacobian_matrix_for_nonlinear_params,
))
}
fn concat_colwise<T, R, C1, C2, S1, S2>(
left: Matrix<T, R, C1, S1>,
right: Matrix<T, R, C2, S2>,
) -> OMatrix<T, R, <C1 as DimAdd<C2>>::Output>
where
R: Dim,
C1: Dim + DimAdd<C2>,
C2: Dim,
T: Scalar + Zero,
nalgebra::DefaultAllocator: Allocator<R, <C1 as DimAdd<C2>>::Output>,
nalgebra::DefaultAllocator: Allocator<R, C1>,
nalgebra::DefaultAllocator: Allocator<R, C2>,
S2: nalgebra::RawStorage<T, R, C2>,
S1: nalgebra::RawStorage<T, R, C1>,
{
assert_eq!(
left.nrows(),
right.nrows(),
"left and right matrix must have the same number of rows"
);
let mut result = OMatrix::<T, R, <C1 as DimAdd<C2>>::Output>::zeros_generic(
R::from_usize(left.nrows()),
<C1 as DimAdd<C2>>::Output::from_usize(left.ncols() + right.ncols()),
);
for idx in 0..left.ncols() {
result.column_mut(idx).copy_from(&left.column(idx));
}
for idx in 0..right.ncols() {
result
.column_mut(idx + left.ncols())
.copy_from(&right.column(idx));
}
result
}