use std::f64::consts::PI;
use ndarray::{Array1, Array2, ArrayView1, ArrayView2, Zip};
use crate::ClimateError;
use crate::lmoments::sample_lmoments;
const SHAPE_TOLERANCE: f64 = 1e-6;
#[derive(Debug, Clone, PartialEq)]
pub struct LogLogisticFit {
pub locs: Array1<f64>,
pub scales: Array1<f64>,
pub shapes: Array1<f64>,
pub valid: Array1<bool>,
}
fn fit_column(column: ArrayView1<'_, f64>, sorted: &mut Vec<f64>) -> Option<[f64; 3]> {
let [first, second, skewness] = sample_lmoments(column, sorted)?;
let shape = -skewness;
if !(second > 0.0 && shape.abs() < 1.0) {
return None;
}
if shape.abs() <= SHAPE_TOLERANCE {
return Some([first, second, 0.0]);
}
let gg = shape * PI / (shape * PI).sin();
let scale = second / gg;
Some([first - scale * (1.0 - gg) / shape, scale, shape])
}
pub fn loglogistic_parameters(calibration: ArrayView2<'_, f64>) -> LogLogisticFit {
let columns = calibration.ncols();
let mut fit = LogLogisticFit {
locs: Array1::zeros(columns),
scales: Array1::zeros(columns),
shapes: Array1::zeros(columns),
valid: Array1::from_elem(columns, false),
};
let mut sorted = Vec::with_capacity(calibration.nrows());
Zip::from(&mut fit.locs)
.and(&mut fit.scales)
.and(&mut fit.shapes)
.and(&mut fit.valid)
.and(calibration.columns())
.for_each(|loc, scale, shape, valid, column| {
if let Some(parameters) = fit_column(column, &mut sorted) {
[*loc, *scale, *shape] = parameters;
*valid = true;
}
});
fit
}
pub fn loglogistic_cdf(x: f64, loc: f64, scale: f64, shape: f64) -> f64 {
let z = (x - loc) / scale;
let y = if shape.abs() <= SHAPE_TOLERANCE {
z
} else {
let argument = 1.0 - shape * z;
let argument = if argument.is_nan() {
argument
} else {
argument.max(0.0)
};
-argument.ln() / shape
};
(1.0 / (1.0 + (-y).exp())).clamp(0.0, 1.0)
}
pub fn loglogistic_cdf_block(
values: ArrayView2<'_, f64>,
locs: ArrayView1<'_, f64>,
scales: ArrayView1<'_, f64>,
shapes: ArrayView1<'_, f64>,
) -> Result<Array2<f64>, ClimateError> {
let columns = values.ncols();
for (argument, parameter) in [("locs", &locs), ("scales", &scales), ("shapes", &shapes)] {
if parameter.len() != columns {
return Err(ClimateError::ShapeMismatch {
argument,
expected: columns,
actual: parameter.len(),
});
}
}
let mut probabilities = Array2::<f64>::zeros(values.raw_dim());
Zip::from(probabilities.columns_mut())
.and(values.columns())
.and(&locs)
.and(&scales)
.and(&shapes)
.for_each(|mut out, column, &loc, &scale, &shape| {
Zip::from(&mut out)
.and(&column)
.for_each(|p, &x| *p = loglogistic_cdf(x, loc, scale, shape));
});
Ok(probabilities)
}
#[cfg(test)]
mod tests {
use super::*;
use ndarray::array;
#[test]
fn fit_of_a_symmetric_sample_is_the_ordinary_logistic() {
let sample = array![[1.0], [2.0], [3.0], [4.0], [5.0], [6.0], [7.0]];
let fit = loglogistic_parameters(sample.view());
assert!(fit.valid[0]);
assert_eq!(fit.shapes[0], 0.0);
assert_eq!(fit.locs[0], 4.0);
assert!((fit.scales[0] - 4.0 / 3.0).abs() < 1e-14);
}
#[test]
fn fit_keeps_zeros_and_rejects_a_short_column() {
let sample = array![
[0.0, 1.0],
[0.0, 2.0],
[1.0, f64::NAN],
[4.0, 3.0],
[9.0, f64::NAN]
];
let fit = loglogistic_parameters(sample.view());
assert!(fit.valid[0], "zeros are ordinary values in a GLO fit");
assert!(
!fit.valid[1],
"three non-missing values is below the minimum"
);
assert_eq!((fit.locs[1], fit.scales[1], fit.shapes[1]), (0.0, 0.0, 0.0));
}
#[test]
fn fit_matches_the_python_oracle_on_each_branch() {
let sample = array![
[12.0, 1.0, 99.0],
[15.5, 1.0, 99.0],
[9.1, 1.0, 99.0],
[30.2, 2.0, 98.0],
[22.4, 2.0, 98.0],
[18.8, 3.0, 97.0],
[11.3, 4.0, 96.0],
[40.7, 8.0, 92.0],
[25.0, 20.0, 80.0],
[14.2, 60.0, 40.0],
];
let expected = [
(
17.346_295_894_276_206,
4.921_322_708_270_736,
-0.287_872_841_444_269,
),
(
2.809_361_769_423_021_3,
2.175_845_858_244_121_4,
-0.766_997_167_138_809_2,
),
(
97.190_638_230_577,
2.175_845_858_244_097_4,
0.766_997_167_138_811_5,
),
];
let fit = loglogistic_parameters(sample.view());
for (column, (loc, scale, shape)) in expected.into_iter().enumerate() {
assert!(fit.valid[column]);
for (name, actual, wanted) in [
("loc", fit.locs[column], loc),
("scale", fit.scales[column], scale),
("shape", fit.shapes[column], shape),
] {
assert!(
(actual - wanted).abs() <= 1e-13 * wanted.abs(),
"column {column} {name} = {actual:e}, expected {wanted:e}"
);
}
}
}
#[test]
fn cdf_matches_the_numpy_formula() {
let cases = [
(
1.0,
2.809_361_769_423_021_3,
2.175_845_858_244_121_4,
-0.766_997_167_138_809_2,
0.210_136_047_689_925_94,
),
(
10.0,
2.809_361_769_423_021_3,
2.175_845_858_244_121_4,
-0.766_997_167_138_809_2,
0.838_378_299_114_486_9,
),
(
95.0,
97.190_638_230_577,
2.175_845_858_244_097_4,
0.766_997_167_138_811_5,
0.321_679_750_448_014_45,
),
(
17.0,
17.346_295_894_276_206,
4.921_322_708_270_736,
-0.287_872_841_444_269,
0.482_235_257_835_268_7,
),
(5.0, 0.0, 2.0, 0.0, 0.924_141_819_978_756_6),
];
for (x, loc, scale, shape, expected) in cases {
let actual = loglogistic_cdf(x, loc, scale, shape);
assert!(
(actual - expected).abs() <= 1e-14 * expected,
"loglogistic_cdf({x}, {loc}, {scale}, {shape}) = {actual:e}, expected {expected:e}"
);
}
}
#[test]
fn cdf_is_the_logistic_at_zero_shape_and_clips_beyond_the_support() {
assert_eq!(loglogistic_cdf(2.0, 2.0, 3.0, 0.0), 0.5);
assert_eq!(loglogistic_cdf(3.0, 0.0, 1.0, 0.5), 1.0);
assert_eq!(loglogistic_cdf(-3.0, 0.0, 1.0, -0.5), 0.0);
assert!(loglogistic_cdf(f64::NAN, 0.0, 1.0, 0.5).is_nan());
}
#[test]
fn cdf_block_rejects_a_parameter_of_the_wrong_length() {
let error = loglogistic_cdf_block(
array![[1.0, 2.0]].view(),
array![0.0, 0.0].view(),
array![1.0, 1.0].view(),
array![0.1].view(),
)
.unwrap_err();
assert_eq!(
error,
ClimateError::ShapeMismatch {
argument: "shapes",
expected: 2,
actual: 1
}
);
}
}