crc-framework 0.2.6__tar.gz → 0.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {crc_framework-0.2.6 → crc_framework-0.3.0}/Cargo.lock +2 -2
- {crc_framework-0.2.6 → crc_framework-0.3.0}/Cargo.toml +1 -1
- {crc_framework-0.2.6 → crc_framework-0.3.0}/PKG-INFO +26 -1
- {crc_framework-0.2.6 → crc_framework-0.3.0}/README.md +25 -0
- {crc_framework-0.2.6 → crc_framework-0.3.0}/crates/core/Cargo.toml +1 -1
- crc_framework-0.3.0/crates/core/src/distribution/lmoments.rs +215 -0
- {crc_framework-0.2.6 → crc_framework-0.3.0}/crates/core/src/distribution.rs +18 -0
- {crc_framework-0.2.6 → crc_framework-0.3.0}/crates/core/src/lib.rs +1 -1
- {crc_framework-0.2.6 → crc_framework-0.3.0}/python/crc_framework/_core.pyi +1 -1
- {crc_framework-0.2.6 → crc_framework-0.3.0}/python/crc_framework/distributions/fitting.py +18 -1
- {crc_framework-0.2.6 → crc_framework-0.3.0}/src/lib.rs +24 -5
- crc_framework-0.3.0/tests/test_lmoments.py +138 -0
- crc_framework-0.3.0/tools/benchmark_lmoments.py +442 -0
- crc_framework-0.3.0/tools/benchmarks/lmoments-audit-m2-pro.json +851 -0
- crc_framework-0.3.0/tools/requirements-lmoments-benchmark.txt +6 -0
- {crc_framework-0.2.6 → crc_framework-0.3.0}/.github/workflows/release.yaml +0 -0
- {crc_framework-0.2.6 → crc_framework-0.3.0}/.gitignore +0 -0
- {crc_framework-0.2.6 → crc_framework-0.3.0}/.python-version +0 -0
- {crc_framework-0.2.6 → crc_framework-0.3.0}/LICENSE +0 -0
- {crc_framework-0.2.6 → crc_framework-0.3.0}/THIRD_PARTY_NOTICES.md +0 -0
- {crc_framework-0.2.6 → crc_framework-0.3.0}/crates/core/src/constants.rs +0 -0
- {crc_framework-0.2.6 → crc_framework-0.3.0}/crates/core/src/error.rs +0 -0
- {crc_framework-0.2.6 → crc_framework-0.3.0}/crates/core/src/metrics.rs +0 -0
- {crc_framework-0.2.6 → crc_framework-0.3.0}/crates/core/src/reference/climate/ag_params.rs +0 -0
- {crc_framework-0.2.6 → crc_framework-0.3.0}/crates/core/src/reference/climate/energy_params.rs +0 -0
- {crc_framework-0.2.6 → crc_framework-0.3.0}/crates/core/src/reference/climate/flood_params.rs +0 -0
- {crc_framework-0.2.6 → crc_framework-0.3.0}/crates/core/src/reference/climate/mod.rs +0 -0
- {crc_framework-0.2.6 → crc_framework-0.3.0}/crates/core/src/reference/climate/precipitation_thresholds.rs +0 -0
- {crc_framework-0.2.6 → crc_framework-0.3.0}/crates/core/src/reference/climate/spei_params.rs +0 -0
- {crc_framework-0.2.6 → crc_framework-0.3.0}/crates/core/src/reference/climate/wind_params.rs +0 -0
- {crc_framework-0.2.6 → crc_framework-0.3.0}/crates/core/src/reference/curve_fitting.rs +0 -0
- {crc_framework-0.2.6 → crc_framework-0.3.0}/crates/core/src/reference/mod.rs +0 -0
- {crc_framework-0.2.6 → crc_framework-0.3.0}/crates/core/src/reference/spatial/continent_r4.rs +0 -0
- {crc_framework-0.2.6 → crc_framework-0.3.0}/crates/core/src/reference/spatial/geography_r5.rs +0 -0
- {crc_framework-0.2.6 → crc_framework-0.3.0}/crates/core/src/reference/spatial/ipcc.rs +0 -0
- {crc_framework-0.2.6 → crc_framework-0.3.0}/crates/core/src/reference/spatial/mod.rs +0 -0
- {crc_framework-0.2.6 → crc_framework-0.3.0}/crates/core/src/risk.rs +0 -0
- {crc_framework-0.2.6 → crc_framework-0.3.0}/crates/core/src/spatial.rs +0 -0
- {crc_framework-0.2.6 → crc_framework-0.3.0}/crates/core/src/transform.rs +0 -0
- {crc_framework-0.2.6 → crc_framework-0.3.0}/crates/core/tests/quantile_fit_golden.rs +0 -0
- {crc_framework-0.2.6 → crc_framework-0.3.0}/crates/core/tests/quantile_fit_golden.txt +0 -0
- {crc_framework-0.2.6 → crc_framework-0.3.0}/pyproject.toml +0 -0
- {crc_framework-0.2.6 → crc_framework-0.3.0}/python/crc_framework/__init__.py +0 -0
- {crc_framework-0.2.6 → crc_framework-0.3.0}/python/crc_framework/constants.py +0 -0
- {crc_framework-0.2.6 → crc_framework-0.3.0}/python/crc_framework/distributions/__init__.py +0 -0
- {crc_framework-0.2.6 → crc_framework-0.3.0}/python/crc_framework/distributions/base.py +0 -0
- {crc_framework-0.2.6 → crc_framework-0.3.0}/python/crc_framework/distributions/empirical.py +0 -0
- {crc_framework-0.2.6 → crc_framework-0.3.0}/python/crc_framework/distributions/fitted.py +0 -0
- {crc_framework-0.2.6 → crc_framework-0.3.0}/python/crc_framework/distributions/hurdle.py +0 -0
- {crc_framework-0.2.6 → crc_framework-0.3.0}/python/crc_framework/distributions/point_mass.py +0 -0
- {crc_framework-0.2.6 → crc_framework-0.3.0}/python/crc_framework/distributions/tabulated.py +0 -0
- {crc_framework-0.2.6 → crc_framework-0.3.0}/python/crc_framework/metrics/__init__.py +0 -0
- {crc_framework-0.2.6 → crc_framework-0.3.0}/python/crc_framework/metrics/distribution.py +0 -0
- {crc_framework-0.2.6 → crc_framework-0.3.0}/python/crc_framework/metrics/microscores.py +0 -0
- {crc_framework-0.2.6 → crc_framework-0.3.0}/python/crc_framework/metrics/risk.py +0 -0
- {crc_framework-0.2.6 → crc_framework-0.3.0}/python/crc_framework/metrics/spanning.py +0 -0
- {crc_framework-0.2.6 → crc_framework-0.3.0}/python/crc_framework/models.py +0 -0
- {crc_framework-0.2.6 → crc_framework-0.3.0}/python/crc_framework/py.typed +0 -0
- {crc_framework-0.2.6 → crc_framework-0.3.0}/python/crc_framework/spatial.py +0 -0
- {crc_framework-0.2.6 → crc_framework-0.3.0}/python/crc_framework/transforms/__init__.py +0 -0
- {crc_framework-0.2.6 → crc_framework-0.3.0}/python/crc_framework/transforms/base.py +0 -0
- {crc_framework-0.2.6 → crc_framework-0.3.0}/python/crc_framework/transforms/builtin.py +0 -0
- {crc_framework-0.2.6 → crc_framework-0.3.0}/python/crc_framework/transforms/climate.py +0 -0
- {crc_framework-0.2.6 → crc_framework-0.3.0}/tests/test_impacts.py +0 -0
- {crc_framework-0.2.6 → crc_framework-0.3.0}/tests/test_sdk.py +0 -0
- {crc_framework-0.2.6 → crc_framework-0.3.0}/tests/typing_smoke.py +0 -0
- {crc_framework-0.2.6 → crc_framework-0.3.0}/uv.lock +0 -0
|
@@ -43,7 +43,7 @@ dependencies = [
|
|
|
43
43
|
|
|
44
44
|
[[package]]
|
|
45
45
|
name = "crc-framework-core"
|
|
46
|
-
version = "0.
|
|
46
|
+
version = "0.3.0"
|
|
47
47
|
dependencies = [
|
|
48
48
|
"h3o",
|
|
49
49
|
"phf",
|
|
@@ -52,7 +52,7 @@ dependencies = [
|
|
|
52
52
|
|
|
53
53
|
[[package]]
|
|
54
54
|
name = "crc-framework-rs"
|
|
55
|
-
version = "0.
|
|
55
|
+
version = "0.3.0"
|
|
56
56
|
dependencies = [
|
|
57
57
|
"crc-framework-core",
|
|
58
58
|
"h3o",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: crc-framework
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Classifier: Development Status :: 3 - Alpha
|
|
5
5
|
Classifier: License :: OSI Approved :: GNU Affero General Public License v3 or later (AGPLv3+)
|
|
6
6
|
Classifier: Operating System :: OS Independent
|
|
@@ -121,6 +121,31 @@ Kolmogorov–Smirnov p-value. The supported families are `genextreme`,
|
|
|
121
121
|
diagnostics. Passing a tabulated or fitted distribution is an error: those
|
|
122
122
|
values are not independent observations.
|
|
123
123
|
|
|
124
|
+
For a standalone L-moment estimate of GEV or Gumbel observations, select the
|
|
125
|
+
estimator explicitly:
|
|
126
|
+
|
|
127
|
+
```python
|
|
128
|
+
from crc_framework import fit_distribution
|
|
129
|
+
|
|
130
|
+
result = fit_distribution(annual_extremes, family="genextreme", method="lmoments")
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
This uses unbiased sample L-moments (Hosking, 1990), with an exact numerical
|
|
134
|
+
solution of the GEV L-skewness equation. It supports `genextreme`, `gumbel_r`
|
|
135
|
+
and `gumbel_l`, requires at least four finite, nonconstant observations, and
|
|
136
|
+
performs no likelihood optimization or automatic family selection. GEV shape
|
|
137
|
+
uses the SciPy convention (`c = -xi`); the finite-mean domain is `c > -1`.
|
|
138
|
+
Degenerate L-skewness is rejected rather than clipped. Fit constraints can
|
|
139
|
+
accept or reject the estimated distribution without refitting it.
|
|
140
|
+
|
|
141
|
+
The native implementation sorts once, uses compensated sums in scaled and
|
|
142
|
+
centered coordinates, and reuses that sort for diagnostics. Work is O(n log n)
|
|
143
|
+
with O(n) storage. The Python binding releases the GIL during native fitting.
|
|
144
|
+
The returned KS p-value is a descriptive diagnostic, not a calibrated hypothesis
|
|
145
|
+
test after parameter estimation. Defaults and the existing sample fitter are
|
|
146
|
+
unchanged. See [NIST's L-moments reference](https://www.itl.nist.gov/div898/software/dataplot/refman2/auxillar/lmoment.htm)
|
|
147
|
+
for background and the Hosking references.
|
|
148
|
+
|
|
124
149
|
Use `fit_quantiles` for probability/value knots. It minimizes weighted
|
|
125
150
|
value-space differences between the supplied values and the selected family's
|
|
126
151
|
PPF, requires an explicit family, and reports residual diagnostics rather than
|
|
@@ -95,6 +95,31 @@ Kolmogorov–Smirnov p-value. The supported families are `genextreme`,
|
|
|
95
95
|
diagnostics. Passing a tabulated or fitted distribution is an error: those
|
|
96
96
|
values are not independent observations.
|
|
97
97
|
|
|
98
|
+
For a standalone L-moment estimate of GEV or Gumbel observations, select the
|
|
99
|
+
estimator explicitly:
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
from crc_framework import fit_distribution
|
|
103
|
+
|
|
104
|
+
result = fit_distribution(annual_extremes, family="genextreme", method="lmoments")
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
This uses unbiased sample L-moments (Hosking, 1990), with an exact numerical
|
|
108
|
+
solution of the GEV L-skewness equation. It supports `genextreme`, `gumbel_r`
|
|
109
|
+
and `gumbel_l`, requires at least four finite, nonconstant observations, and
|
|
110
|
+
performs no likelihood optimization or automatic family selection. GEV shape
|
|
111
|
+
uses the SciPy convention (`c = -xi`); the finite-mean domain is `c > -1`.
|
|
112
|
+
Degenerate L-skewness is rejected rather than clipped. Fit constraints can
|
|
113
|
+
accept or reject the estimated distribution without refitting it.
|
|
114
|
+
|
|
115
|
+
The native implementation sorts once, uses compensated sums in scaled and
|
|
116
|
+
centered coordinates, and reuses that sort for diagnostics. Work is O(n log n)
|
|
117
|
+
with O(n) storage. The Python binding releases the GIL during native fitting.
|
|
118
|
+
The returned KS p-value is a descriptive diagnostic, not a calibrated hypothesis
|
|
119
|
+
test after parameter estimation. Defaults and the existing sample fitter are
|
|
120
|
+
unchanged. See [NIST's L-moments reference](https://www.itl.nist.gov/div898/software/dataplot/refman2/auxillar/lmoment.htm)
|
|
121
|
+
for background and the Hosking references.
|
|
122
|
+
|
|
98
123
|
Use `fit_quantiles` for probability/value knots. It minimizes weighted
|
|
99
124
|
value-space differences between the supplied values and the selected family's
|
|
100
125
|
PPF, requires an explicit family, and reports residual diagnostics rather than
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
//! Unbiased sample L-moments (Hosking, 1990) for annual extreme families.
|
|
2
|
+
//! One O(n log n) sort, O(n) moment/diagnostic passes, no likelihood optimizer.
|
|
3
|
+
|
|
4
|
+
use super::{DistributionFamily, FittedDistribution};
|
|
5
|
+
use crate::error::{CrcError, Result};
|
|
6
|
+
|
|
7
|
+
const EULER: f64 = 0.577_215_664_901_532_9;
|
|
8
|
+
|
|
9
|
+
// Neumaier summation keeps the signed order-statistic sums accurate.
|
|
10
|
+
#[derive(Default)]
|
|
11
|
+
struct Sum(f64, f64);
|
|
12
|
+
impl Sum {
|
|
13
|
+
fn add(&mut self, x: f64) {
|
|
14
|
+
let next = self.0 + x;
|
|
15
|
+
self.1 += if self.0.abs() >= x.abs() {
|
|
16
|
+
(self.0 - next) + x
|
|
17
|
+
} else {
|
|
18
|
+
(x - next) + self.0
|
|
19
|
+
};
|
|
20
|
+
self.0 = next;
|
|
21
|
+
}
|
|
22
|
+
fn value(&self) -> f64 {
|
|
23
|
+
self.0 + self.1
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
pub(super) fn fit(
|
|
28
|
+
samples: &[f64],
|
|
29
|
+
family: DistributionFamily,
|
|
30
|
+
) -> Result<(FittedDistribution, Vec<f64>)> {
|
|
31
|
+
if !matches!(
|
|
32
|
+
family,
|
|
33
|
+
DistributionFamily::GenExtreme
|
|
34
|
+
| DistributionFamily::GumbelRight
|
|
35
|
+
| DistributionFamily::GumbelLeft
|
|
36
|
+
) {
|
|
37
|
+
return Err(CrcError::Unsupported(
|
|
38
|
+
"L-moments supports genextreme, gumbel_r and gumbel_l only".into(),
|
|
39
|
+
));
|
|
40
|
+
}
|
|
41
|
+
if samples.len() < 4 || samples.iter().any(|x| !x.is_finite()) {
|
|
42
|
+
return Err(CrcError::InvalidInput(
|
|
43
|
+
"L-moments requires at least four finite samples".into(),
|
|
44
|
+
));
|
|
45
|
+
}
|
|
46
|
+
let mut sorted = samples.to_vec();
|
|
47
|
+
sorted.sort_unstable_by(f64::total_cmp);
|
|
48
|
+
if sorted[0] == sorted[sorted.len() - 1] {
|
|
49
|
+
return Err(CrcError::InvalidInput(
|
|
50
|
+
"L-moments requires nonconstant samples".into(),
|
|
51
|
+
));
|
|
52
|
+
}
|
|
53
|
+
// Work in centered, bounded coordinates, avoiding overflow and cancellation
|
|
54
|
+
// in records with a large offset or a very small physical scale.
|
|
55
|
+
let magnitude = sorted[0].abs().max(sorted[sorted.len() - 1].abs());
|
|
56
|
+
let offset = sorted[sorted.len() / 2];
|
|
57
|
+
let n = sorted.len() as f64;
|
|
58
|
+
let mut sums = [Sum::default(), Sum::default(), Sum::default()];
|
|
59
|
+
for (i, &x) in sorted.iter().enumerate() {
|
|
60
|
+
let i = i as f64;
|
|
61
|
+
let delta = x - offset;
|
|
62
|
+
let y = if delta.is_finite() {
|
|
63
|
+
delta / magnitude
|
|
64
|
+
} else {
|
|
65
|
+
x / magnitude - offset / magnitude
|
|
66
|
+
};
|
|
67
|
+
let w1 = i / (n - 1.0);
|
|
68
|
+
let w2 = i * (i - 1.0) / ((n - 1.0) * (n - 2.0));
|
|
69
|
+
sums[0].add(y);
|
|
70
|
+
sums[1].add((2.0 * w1 - 1.0) * y);
|
|
71
|
+
sums[2].add((6.0 * w2 - 6.0 * w1 + 1.0) * y);
|
|
72
|
+
}
|
|
73
|
+
let l1 = sums[0].value() / n;
|
|
74
|
+
let l2 = sums[1].value() / n;
|
|
75
|
+
let l3 = sums[2].value() / n;
|
|
76
|
+
if !l2.is_finite() || l2 <= 0.0 {
|
|
77
|
+
return Err(CrcError::InvalidInput(
|
|
78
|
+
"L-moments requires positive L-scale".into(),
|
|
79
|
+
));
|
|
80
|
+
}
|
|
81
|
+
let (shape, location, scale) = match family {
|
|
82
|
+
DistributionFamily::GumbelRight => {
|
|
83
|
+
let scale = l2 / std::f64::consts::LN_2;
|
|
84
|
+
(None, l1 - EULER * scale, scale)
|
|
85
|
+
}
|
|
86
|
+
DistributionFamily::GumbelLeft => {
|
|
87
|
+
let scale = l2 / std::f64::consts::LN_2;
|
|
88
|
+
(None, l1 + EULER * scale, scale)
|
|
89
|
+
}
|
|
90
|
+
DistributionFamily::GenExtreme => gev_parameters(l1, l2, l3 / l2)?,
|
|
91
|
+
_ => unreachable!(),
|
|
92
|
+
};
|
|
93
|
+
let distribution = FittedDistribution::from_parameters(
|
|
94
|
+
family,
|
|
95
|
+
shape,
|
|
96
|
+
location * magnitude + offset,
|
|
97
|
+
scale * magnitude,
|
|
98
|
+
)?;
|
|
99
|
+
Ok((distribution, sorted))
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
fn gev_skew(shape: f64) -> f64 {
|
|
103
|
+
if shape.abs() < 1e-8 {
|
|
104
|
+
// Continuous limit and first derivative at the Gumbel shape.
|
|
105
|
+
let ratio = 3.0_f64.ln() / std::f64::consts::LN_2;
|
|
106
|
+
return 2.0 * ratio - 3.0 - ratio * (3.0_f64.ln() - std::f64::consts::LN_2) * shape;
|
|
107
|
+
}
|
|
108
|
+
2.0 * (-shape * 3.0_f64.ln()).exp_m1() / (-shape * std::f64::consts::LN_2).exp_m1() - 3.0
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
fn gev_parameters(l1: f64, l2: f64, skew: f64) -> Result<(Option<f64>, f64, f64)> {
|
|
112
|
+
if !skew.is_finite() || skew.abs() >= 1.0 {
|
|
113
|
+
return Err(CrcError::InvalidInput(
|
|
114
|
+
"GEV L-skewness must be strictly between -1 and 1".into(),
|
|
115
|
+
));
|
|
116
|
+
}
|
|
117
|
+
// The exact L-skewness equation is monotone for c > -1 (finite mean).
|
|
118
|
+
// Bisection avoids approximation/clipping of strongly skewed samples.
|
|
119
|
+
let mut low = -1.0;
|
|
120
|
+
let mut high = 1.0;
|
|
121
|
+
for _ in 0..7 {
|
|
122
|
+
if gev_skew(high) <= skew {
|
|
123
|
+
break;
|
|
124
|
+
}
|
|
125
|
+
high *= 2.0;
|
|
126
|
+
}
|
|
127
|
+
if gev_skew(high) > skew {
|
|
128
|
+
return Err(CrcError::InvalidInput(
|
|
129
|
+
"GEV L-skewness cannot be resolved numerically".into(),
|
|
130
|
+
));
|
|
131
|
+
}
|
|
132
|
+
for _ in 0..64 {
|
|
133
|
+
let mid = (low + high) / 2.0;
|
|
134
|
+
if mid == low || mid == high {
|
|
135
|
+
break;
|
|
136
|
+
}
|
|
137
|
+
if gev_skew(mid) > skew {
|
|
138
|
+
low = mid;
|
|
139
|
+
} else {
|
|
140
|
+
high = mid;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
let shape = (low + high) / 2.0;
|
|
144
|
+
if shape.abs() < 1e-10 {
|
|
145
|
+
let scale = l2 / std::f64::consts::LN_2;
|
|
146
|
+
return Ok((Some(0.0), l1 - EULER * scale, scale));
|
|
147
|
+
}
|
|
148
|
+
let log_gamma = log_gamma_one_plus(shape);
|
|
149
|
+
let denominator = -(-shape * std::f64::consts::LN_2).exp_m1();
|
|
150
|
+
let scale = l2 * (shape / denominator) * (-log_gamma).exp();
|
|
151
|
+
// Algebraically scale*(Gamma(1+c)-1)/c, without gamma overflow.
|
|
152
|
+
let location = l1 + l2 / denominator * -(-log_gamma).exp_m1();
|
|
153
|
+
Ok((Some(shape), location, scale))
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
fn log_gamma_one_plus(x: f64) -> f64 {
|
|
157
|
+
if x.abs() < 0.01 {
|
|
158
|
+
// log Gamma(1+x) = -gamma*x + sum (-1)^k zeta(k)*x^k/k.
|
|
159
|
+
return x
|
|
160
|
+
* (-EULER
|
|
161
|
+
+ x * (0.822_467_033_424_113_2
|
|
162
|
+
+ x * (-0.400_685_634_386_531_4
|
|
163
|
+
+ x * (0.270_580_808_427_784_5
|
|
164
|
+
+ x * (-0.207_385_551_028_674
|
|
165
|
+
+ x * (0.169_557_176_997_408_2 - x * 0.144_049_896_768_846_1))))));
|
|
166
|
+
}
|
|
167
|
+
// Lanczos in log space; recurrence keeps the argument >= 1, even c -> -1.
|
|
168
|
+
let z = 1.0 + x;
|
|
169
|
+
if z < 1.0 {
|
|
170
|
+
return log_gamma_one_plus(x + 1.0) - z.ln();
|
|
171
|
+
}
|
|
172
|
+
let coefficients = [
|
|
173
|
+
676.520_368_121_885_1,
|
|
174
|
+
-1_259.139_216_722_402_8,
|
|
175
|
+
771.323_428_777_653_1,
|
|
176
|
+
-176.615_029_162_140_6,
|
|
177
|
+
12.507_343_278_686_905,
|
|
178
|
+
-0.138_571_095_265_720_12,
|
|
179
|
+
9.984_369_578_019_572e-6,
|
|
180
|
+
1.505_632_735_149_311_6e-7,
|
|
181
|
+
];
|
|
182
|
+
let mut a = 0.999_999_999_999_809_9;
|
|
183
|
+
for (i, coefficient) in coefficients.iter().enumerate() {
|
|
184
|
+
a += coefficient / (x + i as f64 + 1.0);
|
|
185
|
+
}
|
|
186
|
+
let t = x + 7.5;
|
|
187
|
+
0.918_938_533_204_672_7 + (x + 0.5) * t.ln() - t + a.ln()
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
#[cfg(test)]
|
|
191
|
+
mod tests {
|
|
192
|
+
use super::*;
|
|
193
|
+
#[test]
|
|
194
|
+
fn exact_shape_equation_including_heavy_tails() {
|
|
195
|
+
for shape in [-0.95, -0.7, -0.2, -1e-5, 0.0, 1e-5, 0.2, 1.0, 5.0] {
|
|
196
|
+
let (actual, _, scale) = gev_parameters(2.0, 1.0, gev_skew(shape)).unwrap();
|
|
197
|
+
assert!(
|
|
198
|
+
(actual.unwrap() - shape).abs() < 1e-10,
|
|
199
|
+
"shape {shape}: {actual:?}"
|
|
200
|
+
);
|
|
201
|
+
assert!(scale > 0.0 && scale.is_finite());
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
#[test]
|
|
205
|
+
fn small_scales_and_large_offsets() {
|
|
206
|
+
for samples in [
|
|
207
|
+
[1e-20, 2e-20, 3e-20, 4e-20],
|
|
208
|
+
[1e15, 1e15 + 1.0, 1e15 + 2.0, 1e15 + 3.0],
|
|
209
|
+
[-1e308, -5e307, 5e307, 1e308],
|
|
210
|
+
] {
|
|
211
|
+
let (fit, _) = fit(&samples, DistributionFamily::GumbelRight).unwrap();
|
|
212
|
+
assert!(fit.scale.is_finite() && fit.scale > 0.0);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
@@ -7,6 +7,8 @@ use crate::{
|
|
|
7
7
|
reference::curve_fitting as reference_curve_fitting,
|
|
8
8
|
};
|
|
9
9
|
|
|
10
|
+
mod lmoments;
|
|
11
|
+
|
|
10
12
|
const EPS: f64 = 1.0e-12;
|
|
11
13
|
|
|
12
14
|
pub trait Distribution: Send + Sync {
|
|
@@ -856,6 +858,18 @@ pub fn fit_distribution(samples: &[f64], family: Option<DistributionFamily>) ->
|
|
|
856
858
|
.ok_or_else(|| CrcError::Unsupported("no candidate distribution could be fitted".into()))
|
|
857
859
|
}
|
|
858
860
|
|
|
861
|
+
/// Fit GEV or Gumbel observations by unbiased sample L-moments.
|
|
862
|
+
///
|
|
863
|
+
/// This is a standalone estimator: no likelihood optimization or family selection.
|
|
864
|
+
/// GEV shape follows the SciPy convention (`c = -xi`).
|
|
865
|
+
pub fn fit_lmoments(samples: &[f64], family: DistributionFamily) -> Result<FitResult> {
|
|
866
|
+
let (distribution, sorted) = lmoments::fit(samples, family)?;
|
|
867
|
+
Ok(FitResult {
|
|
868
|
+
diagnostics: diagnostics_sorted(&sorted, &distribution),
|
|
869
|
+
distribution,
|
|
870
|
+
})
|
|
871
|
+
}
|
|
872
|
+
|
|
859
873
|
pub fn fit_quantiles(
|
|
860
874
|
probabilities: &[f64],
|
|
861
875
|
values: &[f64],
|
|
@@ -1057,6 +1071,10 @@ fn reference_diagnostic(diagnostic: reference_curve_fitting::DiagnosticFit) -> R
|
|
|
1057
1071
|
fn diagnostics(samples: &[f64], distribution: &dyn Distribution) -> DiagnosticMetrics {
|
|
1058
1072
|
let mut sorted = samples.to_vec();
|
|
1059
1073
|
sorted.sort_by(f64::total_cmp);
|
|
1074
|
+
diagnostics_sorted(&sorted, distribution)
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
fn diagnostics_sorted(sorted: &[f64], distribution: &dyn Distribution) -> DiagnosticMetrics {
|
|
1060
1078
|
let count = sorted.len() as f64;
|
|
1061
1079
|
let mut ks: f64 = 0.0;
|
|
1062
1080
|
let mut squared_error = 0.0;
|
|
@@ -11,7 +11,7 @@ pub use distribution::{
|
|
|
11
11
|
DiagnosticMetrics, Distribution, DistributionFamily, EmpiricalDistribution, FitResult,
|
|
12
12
|
FittedDistribution, HurdleDistribution, HurdleQuantileFitDiagnostics, HurdleQuantileFitResult,
|
|
13
13
|
Interpolation, PointMassDistribution, QuantileFitDiagnostics, QuantileFitResult,
|
|
14
|
-
TabulatedDistribution, Tail, fit_hurdle_quantiles, fit_quantiles,
|
|
14
|
+
TabulatedDistribution, Tail, fit_hurdle_quantiles, fit_lmoments, fit_quantiles,
|
|
15
15
|
};
|
|
16
16
|
pub use error::{CrcError, Result};
|
|
17
17
|
pub use metrics::{
|
|
@@ -122,7 +122,7 @@ def hurdle_distribution(
|
|
|
122
122
|
atom_probability: float,
|
|
123
123
|
atom_location: float = ...,
|
|
124
124
|
) -> NativeDistribution: ...
|
|
125
|
-
def fit(samples: Sequence[float], family: Optional[str] = ...) -> NativeFitResult: ...
|
|
125
|
+
def fit(samples: Sequence[float], family: Optional[str] = ..., *, method: str = ...) -> NativeFitResult: ...
|
|
126
126
|
def fit_candidates(samples: Sequence[float]) -> List[NativeFitResult]: ...
|
|
127
127
|
def fit_quantiles(
|
|
128
128
|
probabilities: Sequence[float],
|
|
@@ -158,9 +158,21 @@ def fit_distribution(
|
|
|
158
158
|
*,
|
|
159
159
|
candidates: Optional[Sequence[DistributionFamily]] = None,
|
|
160
160
|
selector: str = "ks",
|
|
161
|
+
method: str = "default",
|
|
161
162
|
constraints: Optional[Union[FitConstraints, Mapping[str, float]]] = None,
|
|
162
163
|
) -> FitResult:
|
|
163
|
-
"""Fit explicitly;
|
|
164
|
+
"""Fit observations explicitly; metrics never fit automatically.
|
|
165
|
+
|
|
166
|
+
``method="default"`` preserves the existing sample fitter.
|
|
167
|
+
``method="lmoments"`` uses unbiased sample L-moments for an explicit
|
|
168
|
+
``genextreme``, ``gumbel_r`` or ``gumbel_l`` family, without optimization.
|
|
169
|
+
GEV shape uses the SciPy convention (c = -xi). Automatic family selection
|
|
170
|
+
and candidate lists are unavailable for L-moments.
|
|
171
|
+
"""
|
|
172
|
+
if method not in ("default", "lmoments"):
|
|
173
|
+
raise ValueError(f"unknown fitting method {method!r}")
|
|
174
|
+
if method == "lmoments" and (family == "auto" or candidates is not None):
|
|
175
|
+
raise ValueError("L-moments fitting requires an explicit family and no candidates")
|
|
164
176
|
if selector != "ks":
|
|
165
177
|
raise ValueError("only the 'ks' selector is currently supported")
|
|
166
178
|
active_constraints = (
|
|
@@ -168,6 +180,11 @@ def fit_distribution(
|
|
|
168
180
|
if isinstance(constraints, Mapping)
|
|
169
181
|
else constraints
|
|
170
182
|
)
|
|
183
|
+
if method == "lmoments":
|
|
184
|
+
result = _result(_core.fit(_samples(data), family, method=method))
|
|
185
|
+
if active_constraints is not None and not active_constraints.accepts(result):
|
|
186
|
+
raise ValueError("L-moments fit does not satisfy the requested constraints")
|
|
187
|
+
return result
|
|
171
188
|
if candidates is not None or active_constraints is not None:
|
|
172
189
|
allowed = (
|
|
173
190
|
set(candidates)
|
|
@@ -354,17 +354,36 @@ fn hurdle_distribution(
|
|
|
354
354
|
}
|
|
355
355
|
|
|
356
356
|
#[pyfunction]
|
|
357
|
-
#[pyo3(signature = (samples, family=None))]
|
|
358
|
-
fn fit(
|
|
357
|
+
#[pyo3(signature = (samples, family=None, *, method="default"))]
|
|
358
|
+
fn fit(
|
|
359
|
+
py: Python<'_>,
|
|
360
|
+
samples: Vec<f64>,
|
|
361
|
+
family: Option<&str>,
|
|
362
|
+
method: &str,
|
|
363
|
+
) -> PyResult<PyFitResult> {
|
|
359
364
|
let family = family
|
|
360
365
|
.map(|name| {
|
|
361
366
|
DistributionFamily::from_name(name)
|
|
362
367
|
.ok_or_else(|| PyValueError::new_err(format!("unknown distribution family {name}")))
|
|
363
368
|
})
|
|
364
369
|
.transpose()?;
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
370
|
+
let result = match method {
|
|
371
|
+
"default" => {
|
|
372
|
+
py.detach(|| crc_framework_core::distribution::fit_distribution(&samples, family))
|
|
373
|
+
}
|
|
374
|
+
"lmoments" => {
|
|
375
|
+
let family = family.ok_or_else(|| {
|
|
376
|
+
PyValueError::new_err("L-moments fitting requires an explicit family")
|
|
377
|
+
})?;
|
|
378
|
+
py.detach(|| crc_framework_core::distribution::fit_lmoments(&samples, family))
|
|
379
|
+
}
|
|
380
|
+
_ => {
|
|
381
|
+
return Err(PyValueError::new_err(format!(
|
|
382
|
+
"unknown fitting method {method}"
|
|
383
|
+
)));
|
|
384
|
+
}
|
|
385
|
+
};
|
|
386
|
+
result.map(fit_result_py).map_err(py_error)
|
|
368
387
|
}
|
|
369
388
|
|
|
370
389
|
#[pyfunction]
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""Independent numerical references for the standalone native estimator."""
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
import pytest
|
|
7
|
+
from crc_framework import EmpiricalDistribution, FitConstraints, fit_distribution
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
# Unbiased PWMs evaluated separately in NumPy, with the exact GEV L-skewness
|
|
11
|
+
# equation solved by SciPy brentq and parameters evaluated with scipy.special.gamma.
|
|
12
|
+
# No SciPy runtime dependency is needed to check these fixed reference cases.
|
|
13
|
+
@pytest.mark.parametrize(
|
|
14
|
+
"samples, expected",
|
|
15
|
+
[
|
|
16
|
+
(
|
|
17
|
+
[1, 2, 3, 4, 5, 6, 7, 8],
|
|
18
|
+
(0.2837755261699676, 3.5673697128875066, 2.6485046284617866),
|
|
19
|
+
),
|
|
20
|
+
(
|
|
21
|
+
[0, 0, 1, 3, 8, 15, 30, 100],
|
|
22
|
+
(-0.6906224839693698, 2.5435518253418365, 6.22052453291623),
|
|
23
|
+
),
|
|
24
|
+
(
|
|
25
|
+
[1, 2, 2, 3, 5, 8, 13, 21, 34, 55],
|
|
26
|
+
(-0.46999345573819784, 4.659674734457967, 6.785717967583603),
|
|
27
|
+
),
|
|
28
|
+
(
|
|
29
|
+
[-100, -30, -15, -8, -3, -1, 0, 0],
|
|
30
|
+
(2.343489515427684, -6.78103358608292, 16.64903073472396),
|
|
31
|
+
),
|
|
32
|
+
],
|
|
33
|
+
)
|
|
34
|
+
def test_gev_reference_parameters(samples, expected):
|
|
35
|
+
fit = fit_distribution(samples, "genextreme", method="lmoments")
|
|
36
|
+
d = fit.distribution
|
|
37
|
+
np.testing.assert_allclose([d.shape, d.location, d.scale], expected, rtol=2e-12)
|
|
38
|
+
assert all(math.isfinite(x) for x in vars(fit.diagnostics).values())
|
|
39
|
+
# Sorting is internal and the estimator is deterministic.
|
|
40
|
+
other = fit_distribution(
|
|
41
|
+
samples[::-1], "genextreme", method="lmoments"
|
|
42
|
+
).distribution
|
|
43
|
+
assert (d.shape, d.location, d.scale) == (other.shape, other.location, other.scale)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_gumbel_analytic_and_reflected_fit():
|
|
47
|
+
# For [1,...,8], L1 = 4.5 and unbiased L2 = 1.5.
|
|
48
|
+
d = fit_distribution(list(range(1, 9)), "gumbel_r", method="lmoments").distribution
|
|
49
|
+
scale = 1.5 / math.log(2)
|
|
50
|
+
assert d.scale == pytest.approx(scale, rel=1e-14)
|
|
51
|
+
assert d.location == pytest.approx(4.5 - 0.5772156649015329 * scale, rel=1e-14)
|
|
52
|
+
reflected = fit_distribution(
|
|
53
|
+
list(range(-8, 0)), "gumbel_l", method="lmoments"
|
|
54
|
+
).distribution
|
|
55
|
+
assert reflected.location == pytest.approx(-d.location)
|
|
56
|
+
assert reflected.scale == pytest.approx(d.scale)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@pytest.mark.parametrize("shape", [-0.4, -0.2, 0.0, 0.2, 1.2])
|
|
60
|
+
def test_recovers_population_quantiles(shape):
|
|
61
|
+
from crc_framework import FittedDistribution
|
|
62
|
+
|
|
63
|
+
original = FittedDistribution("genextreme", shape=shape, location=10, scale=3)
|
|
64
|
+
data = original.quantiles((np.arange(20000) + 0.5) / 20000)
|
|
65
|
+
fitted = fit_distribution(data, "genextreme", method="lmoments").distribution
|
|
66
|
+
# Heavy tails converge slowly; compare return levels within the sample record.
|
|
67
|
+
np.testing.assert_allclose(
|
|
68
|
+
fitted.quantiles([0.5, 0.9, 0.99]),
|
|
69
|
+
original.quantiles([0.5, 0.9, 0.99]),
|
|
70
|
+
rtol=0.035,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@pytest.mark.parametrize("family", ["genextreme", "gumbel_r", "gumbel_l"])
|
|
75
|
+
@pytest.mark.parametrize("scale, offset", [(1e-20, 0), (1e20, 1e22), (0.1, 1e5)])
|
|
76
|
+
def test_affine_equivariance(family, scale, offset):
|
|
77
|
+
values = np.array([0, 0, 1, 3, 8, 15, 30, 100], dtype=float)
|
|
78
|
+
base = fit_distribution(values, family, method="lmoments").distribution
|
|
79
|
+
actual = fit_distribution(
|
|
80
|
+
values * scale + offset, family, method="lmoments"
|
|
81
|
+
).distribution
|
|
82
|
+
assert actual.shape == pytest.approx(base.shape, abs=1e-10)
|
|
83
|
+
assert actual.location == pytest.approx(base.location * scale + offset, rel=1e-10)
|
|
84
|
+
assert actual.scale == pytest.approx(base.scale * scale, rel=1e-10)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@pytest.mark.parametrize(
|
|
88
|
+
"samples", [[], [1, 2, 3], [2] * 8, [0, 1, 2, np.nan], [0, 1, 2, np.inf]]
|
|
89
|
+
)
|
|
90
|
+
def test_invalid_records(samples):
|
|
91
|
+
with pytest.raises(ValueError):
|
|
92
|
+
fit_distribution(samples, "genextreme", method="lmoments")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def test_explicit_policy_and_constraints():
|
|
96
|
+
values = EmpiricalDistribution([1, 2, 3, 4, 5, 6, 7, 8])
|
|
97
|
+
with pytest.raises(ValueError, match="explicit family"):
|
|
98
|
+
fit_distribution(values, method="lmoments")
|
|
99
|
+
with pytest.raises(ValueError, match="no candidates"):
|
|
100
|
+
fit_distribution(values, "gumbel_r", candidates=["gumbel_r"], method="lmoments")
|
|
101
|
+
with pytest.raises(ValueError, match="supports"):
|
|
102
|
+
fit_distribution(values, "genpareto", method="lmoments")
|
|
103
|
+
with pytest.raises(ValueError, match="unknown fitting method"):
|
|
104
|
+
fit_distribution(values, "gumbel_r", method="not-an-estimator")
|
|
105
|
+
fit_distribution(
|
|
106
|
+
values,
|
|
107
|
+
"gumbel_r",
|
|
108
|
+
method="lmoments",
|
|
109
|
+
constraints=FitConstraints(minimum_value=0),
|
|
110
|
+
)
|
|
111
|
+
with pytest.raises(ValueError, match="constraints"):
|
|
112
|
+
fit_distribution(
|
|
113
|
+
values,
|
|
114
|
+
"gumbel_r",
|
|
115
|
+
method="lmoments",
|
|
116
|
+
constraints=FitConstraints(maximum_value=0),
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def test_heavy_tail_estimate_converges_with_record_length():
|
|
121
|
+
from crc_framework import FittedDistribution
|
|
122
|
+
|
|
123
|
+
original = FittedDistribution("genextreme", shape=-0.8, location=10, scale=3)
|
|
124
|
+
target = original.ppf(0.99)
|
|
125
|
+
errors = []
|
|
126
|
+
for n in (1000, 20000):
|
|
127
|
+
values = original.quantiles((np.arange(n) + 0.5) / n)
|
|
128
|
+
estimated = fit_distribution(
|
|
129
|
+
values, "genextreme", method="lmoments"
|
|
130
|
+
).distribution
|
|
131
|
+
errors.append(abs(estimated.ppf(0.99) - target))
|
|
132
|
+
assert errors[1] < errors[0]
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@pytest.mark.parametrize("values", [[0, 0, 0, 1], [0, 1, 1, 1]])
|
|
136
|
+
def test_degenerate_skewness_is_rejected(values):
|
|
137
|
+
with pytest.raises(ValueError, match="L-skewness"):
|
|
138
|
+
fit_distribution(values, "genextreme", method="lmoments")
|