crc-framework 0.2.4__tar.gz → 0.2.6__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/.python-version +1 -0
- {crc_framework-0.2.4 → crc_framework-0.2.6}/Cargo.lock +2 -2
- {crc_framework-0.2.4 → crc_framework-0.2.6}/Cargo.toml +1 -1
- {crc_framework-0.2.4 → crc_framework-0.2.6}/PKG-INFO +1 -1
- {crc_framework-0.2.4 → crc_framework-0.2.6}/crates/core/Cargo.toml +1 -1
- {crc_framework-0.2.4 → crc_framework-0.2.6}/crates/core/src/distribution.rs +377 -11
- {crc_framework-0.2.4 → crc_framework-0.2.6}/crates/core/src/reference/curve_fitting.rs +64 -15
- crc_framework-0.2.6/crates/core/tests/quantile_fit_golden.rs +320 -0
- crc_framework-0.2.6/crates/core/tests/quantile_fit_golden.txt +56 -0
- crc_framework-0.2.6/uv.lock +755 -0
- {crc_framework-0.2.4 → crc_framework-0.2.6}/.github/workflows/release.yaml +0 -0
- {crc_framework-0.2.4 → crc_framework-0.2.6}/.gitignore +0 -0
- {crc_framework-0.2.4 → crc_framework-0.2.6}/LICENSE +0 -0
- {crc_framework-0.2.4 → crc_framework-0.2.6}/README.md +0 -0
- {crc_framework-0.2.4 → crc_framework-0.2.6}/THIRD_PARTY_NOTICES.md +0 -0
- {crc_framework-0.2.4 → crc_framework-0.2.6}/crates/core/src/constants.rs +0 -0
- {crc_framework-0.2.4 → crc_framework-0.2.6}/crates/core/src/error.rs +0 -0
- {crc_framework-0.2.4 → crc_framework-0.2.6}/crates/core/src/lib.rs +0 -0
- {crc_framework-0.2.4 → crc_framework-0.2.6}/crates/core/src/metrics.rs +0 -0
- {crc_framework-0.2.4 → crc_framework-0.2.6}/crates/core/src/reference/climate/ag_params.rs +0 -0
- {crc_framework-0.2.4 → crc_framework-0.2.6}/crates/core/src/reference/climate/energy_params.rs +0 -0
- {crc_framework-0.2.4 → crc_framework-0.2.6}/crates/core/src/reference/climate/flood_params.rs +0 -0
- {crc_framework-0.2.4 → crc_framework-0.2.6}/crates/core/src/reference/climate/mod.rs +0 -0
- {crc_framework-0.2.4 → crc_framework-0.2.6}/crates/core/src/reference/climate/precipitation_thresholds.rs +0 -0
- {crc_framework-0.2.4 → crc_framework-0.2.6}/crates/core/src/reference/climate/spei_params.rs +0 -0
- {crc_framework-0.2.4 → crc_framework-0.2.6}/crates/core/src/reference/climate/wind_params.rs +0 -0
- {crc_framework-0.2.4 → crc_framework-0.2.6}/crates/core/src/reference/mod.rs +0 -0
- {crc_framework-0.2.4 → crc_framework-0.2.6}/crates/core/src/reference/spatial/continent_r4.rs +0 -0
- {crc_framework-0.2.4 → crc_framework-0.2.6}/crates/core/src/reference/spatial/geography_r5.rs +0 -0
- {crc_framework-0.2.4 → crc_framework-0.2.6}/crates/core/src/reference/spatial/ipcc.rs +0 -0
- {crc_framework-0.2.4 → crc_framework-0.2.6}/crates/core/src/reference/spatial/mod.rs +0 -0
- {crc_framework-0.2.4 → crc_framework-0.2.6}/crates/core/src/risk.rs +0 -0
- {crc_framework-0.2.4 → crc_framework-0.2.6}/crates/core/src/spatial.rs +0 -0
- {crc_framework-0.2.4 → crc_framework-0.2.6}/crates/core/src/transform.rs +0 -0
- {crc_framework-0.2.4 → crc_framework-0.2.6}/pyproject.toml +0 -0
- {crc_framework-0.2.4 → crc_framework-0.2.6}/python/crc_framework/__init__.py +0 -0
- {crc_framework-0.2.4 → crc_framework-0.2.6}/python/crc_framework/_core.pyi +0 -0
- {crc_framework-0.2.4 → crc_framework-0.2.6}/python/crc_framework/constants.py +0 -0
- {crc_framework-0.2.4 → crc_framework-0.2.6}/python/crc_framework/distributions/__init__.py +0 -0
- {crc_framework-0.2.4 → crc_framework-0.2.6}/python/crc_framework/distributions/base.py +0 -0
- {crc_framework-0.2.4 → crc_framework-0.2.6}/python/crc_framework/distributions/empirical.py +0 -0
- {crc_framework-0.2.4 → crc_framework-0.2.6}/python/crc_framework/distributions/fitted.py +0 -0
- {crc_framework-0.2.4 → crc_framework-0.2.6}/python/crc_framework/distributions/fitting.py +0 -0
- {crc_framework-0.2.4 → crc_framework-0.2.6}/python/crc_framework/distributions/hurdle.py +0 -0
- {crc_framework-0.2.4 → crc_framework-0.2.6}/python/crc_framework/distributions/point_mass.py +0 -0
- {crc_framework-0.2.4 → crc_framework-0.2.6}/python/crc_framework/distributions/tabulated.py +0 -0
- {crc_framework-0.2.4 → crc_framework-0.2.6}/python/crc_framework/metrics/__init__.py +0 -0
- {crc_framework-0.2.4 → crc_framework-0.2.6}/python/crc_framework/metrics/distribution.py +0 -0
- {crc_framework-0.2.4 → crc_framework-0.2.6}/python/crc_framework/metrics/microscores.py +0 -0
- {crc_framework-0.2.4 → crc_framework-0.2.6}/python/crc_framework/metrics/risk.py +0 -0
- {crc_framework-0.2.4 → crc_framework-0.2.6}/python/crc_framework/metrics/spanning.py +0 -0
- {crc_framework-0.2.4 → crc_framework-0.2.6}/python/crc_framework/models.py +0 -0
- {crc_framework-0.2.4 → crc_framework-0.2.6}/python/crc_framework/py.typed +0 -0
- {crc_framework-0.2.4 → crc_framework-0.2.6}/python/crc_framework/spatial.py +0 -0
- {crc_framework-0.2.4 → crc_framework-0.2.6}/python/crc_framework/transforms/__init__.py +0 -0
- {crc_framework-0.2.4 → crc_framework-0.2.6}/python/crc_framework/transforms/base.py +0 -0
- {crc_framework-0.2.4 → crc_framework-0.2.6}/python/crc_framework/transforms/builtin.py +0 -0
- {crc_framework-0.2.4 → crc_framework-0.2.6}/python/crc_framework/transforms/climate.py +0 -0
- {crc_framework-0.2.4 → crc_framework-0.2.6}/src/lib.rs +0 -0
- {crc_framework-0.2.4 → crc_framework-0.2.6}/tests/test_impacts.py +0 -0
- {crc_framework-0.2.4 → crc_framework-0.2.6}/tests/test_sdk.py +0 -0
- {crc_framework-0.2.4 → crc_framework-0.2.6}/tests/typing_smoke.py +0 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.14
|
|
@@ -43,7 +43,7 @@ dependencies = [
|
|
|
43
43
|
|
|
44
44
|
[[package]]
|
|
45
45
|
name = "crc-framework-core"
|
|
46
|
-
version = "0.2.
|
|
46
|
+
version = "0.2.6"
|
|
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.2.
|
|
55
|
+
version = "0.2.6"
|
|
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.2.
|
|
3
|
+
Version: 0.2.6
|
|
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
|
|
@@ -334,6 +334,19 @@ impl DistributionFamily {
|
|
|
334
334
|
pub fn from_name(name: &str) -> Option<Self> {
|
|
335
335
|
Self::ALL.into_iter().find(|family| family.name() == name)
|
|
336
336
|
}
|
|
337
|
+
|
|
338
|
+
pub(crate) fn from_reference_kind(kind: reference_curve_fitting::DistributionKind) -> Self {
|
|
339
|
+
use reference_curve_fitting::DistributionKind as Kind;
|
|
340
|
+
match kind {
|
|
341
|
+
Kind::GenExtreme => Self::GenExtreme,
|
|
342
|
+
Kind::WeibullMin => Self::WeibullMin,
|
|
343
|
+
Kind::WeibullMax => Self::WeibullMax,
|
|
344
|
+
Kind::SkewNorm => Self::SkewNormal,
|
|
345
|
+
Kind::GumbelR => Self::GumbelRight,
|
|
346
|
+
Kind::GumbelL => Self::GumbelLeft,
|
|
347
|
+
Kind::GenPareto => Self::GenPareto,
|
|
348
|
+
}
|
|
349
|
+
}
|
|
337
350
|
}
|
|
338
351
|
|
|
339
352
|
#[derive(Debug, Clone)]
|
|
@@ -381,6 +394,148 @@ impl FittedDistribution {
|
|
|
381
394
|
fn z(&self, x: f64) -> f64 {
|
|
382
395
|
(x - self.location) / self.scale
|
|
383
396
|
}
|
|
397
|
+
|
|
398
|
+
/// Upper-tail probability `P(X > x)`.
|
|
399
|
+
///
|
|
400
|
+
/// Evaluated directly rather than as `1 - cdf(x)`, so it keeps full
|
|
401
|
+
/// relative precision deep in the upper tail where `cdf(x)` rounds to one.
|
|
402
|
+
pub fn sf(&self, x: f64) -> f64 {
|
|
403
|
+
let z = self.z(x);
|
|
404
|
+
match self.family {
|
|
405
|
+
DistributionFamily::GumbelRight => -(-(-z).exp()).exp_m1(),
|
|
406
|
+
DistributionFamily::GumbelLeft => (-z.exp()).exp(),
|
|
407
|
+
DistributionFamily::WeibullMin => {
|
|
408
|
+
if z < 0.0 {
|
|
409
|
+
1.0
|
|
410
|
+
} else {
|
|
411
|
+
(-z.powf(self.shape.expect("validated"))).exp()
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
DistributionFamily::WeibullMax => {
|
|
415
|
+
if z >= 0.0 {
|
|
416
|
+
0.0
|
|
417
|
+
} else {
|
|
418
|
+
-(-(-z).powf(self.shape.expect("validated"))).exp_m1()
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
DistributionFamily::GenPareto => {
|
|
422
|
+
let c = self.shape.expect("validated");
|
|
423
|
+
if z < 0.0 {
|
|
424
|
+
1.0
|
|
425
|
+
} else if c.abs() < EPS {
|
|
426
|
+
(-z).exp()
|
|
427
|
+
} else {
|
|
428
|
+
let support = 1.0 + c * z;
|
|
429
|
+
if support <= 0.0 {
|
|
430
|
+
0.0
|
|
431
|
+
} else {
|
|
432
|
+
support.powf(-1.0 / c)
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
DistributionFamily::GenExtreme => {
|
|
437
|
+
let c = self.shape.expect("validated");
|
|
438
|
+
if c.abs() < EPS {
|
|
439
|
+
-(-(-z).exp()).exp_m1()
|
|
440
|
+
} else {
|
|
441
|
+
let support = 1.0 - c * z;
|
|
442
|
+
if support <= 0.0 {
|
|
443
|
+
if c > 0.0 { 0.0 } else { 1.0 }
|
|
444
|
+
} else {
|
|
445
|
+
-(-support.powf(1.0 / c)).exp_m1()
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
// If X ~ SN(alpha, loc, scale) then -X ~ SN(-alpha, -loc, scale).
|
|
450
|
+
DistributionFamily::SkewNormal => skew_normal_cdf(-z, -self.shape.expect("validated")),
|
|
451
|
+
}
|
|
452
|
+
.clamp(0.0, 1.0)
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/// Inverse of [`Self::sf`]: the value exceeded with probability `survival`.
|
|
456
|
+
///
|
|
457
|
+
/// The upper-tail counterpart of `ppf`, accurate for survival
|
|
458
|
+
/// probabilities far below the spacing of `f64` values near one.
|
|
459
|
+
pub fn isf(&self, survival: f64) -> Result<f64> {
|
|
460
|
+
validate_probability(survival)?;
|
|
461
|
+
if survival <= 0.0 || survival >= 1.0 {
|
|
462
|
+
return Err(CrcError::InvalidInput(
|
|
463
|
+
"parametric isf requires probability strictly between zero and one".into(),
|
|
464
|
+
));
|
|
465
|
+
}
|
|
466
|
+
let value = match self.family {
|
|
467
|
+
DistributionFamily::GumbelRight => {
|
|
468
|
+
self.location - self.scale * (-(-survival).ln_1p()).ln()
|
|
469
|
+
}
|
|
470
|
+
DistributionFamily::GumbelLeft => self.location + self.scale * (-survival.ln()).ln(),
|
|
471
|
+
DistributionFamily::WeibullMin => {
|
|
472
|
+
self.location
|
|
473
|
+
+ self.scale * (-survival.ln()).powf(1.0 / self.shape.expect("validated"))
|
|
474
|
+
}
|
|
475
|
+
DistributionFamily::WeibullMax => {
|
|
476
|
+
self.location
|
|
477
|
+
- self.scale * (-(-survival).ln_1p()).powf(1.0 / self.shape.expect("validated"))
|
|
478
|
+
}
|
|
479
|
+
DistributionFamily::GenPareto => {
|
|
480
|
+
let c = self.shape.expect("validated");
|
|
481
|
+
if c.abs() < EPS {
|
|
482
|
+
self.location - self.scale * survival.ln()
|
|
483
|
+
} else {
|
|
484
|
+
self.location + self.scale * (survival.powf(-c) - 1.0) / c
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
DistributionFamily::GenExtreme => {
|
|
488
|
+
let c = self.shape.expect("validated");
|
|
489
|
+
// -ln(cdf), formed from the survival probability without rounding
|
|
490
|
+
// the cdf to one first.
|
|
491
|
+
let neg_log_cdf = -(-survival).ln_1p();
|
|
492
|
+
if c.abs() < EPS {
|
|
493
|
+
self.location - self.scale * neg_log_cdf.ln()
|
|
494
|
+
} else {
|
|
495
|
+
self.location + self.scale * (1.0 - neg_log_cdf.powf(c)) / c
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
DistributionFamily::SkewNormal => {
|
|
499
|
+
let reflected = Self {
|
|
500
|
+
shape: self.shape.map(|alpha| -alpha),
|
|
501
|
+
location: -self.location,
|
|
502
|
+
..*self
|
|
503
|
+
};
|
|
504
|
+
-inverse_by_bisection(&reflected, survival)
|
|
505
|
+
}
|
|
506
|
+
};
|
|
507
|
+
Ok(value)
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/// Quantile of `base` truncated to `(atom, inf)` at `conditional_probability`.
|
|
512
|
+
///
|
|
513
|
+
/// `atom_cdf` and `atom_sf` are `base.cdf(atom)` and `base.sf(atom)`. The
|
|
514
|
+
/// target is inverted from whichever tail holds less than half the mass, so
|
|
515
|
+
/// the result stays accurate when the atom sits deep in either tail of the
|
|
516
|
+
/// base; e.g. an atom with `sf ~ 1e-12` leaves the whole conditional tail
|
|
517
|
+
/// within a few ULPs of one in cdf space.
|
|
518
|
+
pub(crate) fn truncated_ppf(
|
|
519
|
+
base: &FittedDistribution,
|
|
520
|
+
atom_cdf: f64,
|
|
521
|
+
atom_sf: f64,
|
|
522
|
+
conditional_probability: f64,
|
|
523
|
+
) -> Result<f64> {
|
|
524
|
+
let survival = (1.0 - conditional_probability) * atom_sf;
|
|
525
|
+
if survival < 0.5 {
|
|
526
|
+
base.isf(survival)
|
|
527
|
+
} else {
|
|
528
|
+
base.ppf(atom_cdf + conditional_probability * atom_sf)
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/// Conditional cdf of `base` truncated to `(atom, inf)`, evaluated at `x > atom`.
|
|
533
|
+
fn truncated_cdf(base: &FittedDistribution, atom_cdf: f64, atom_sf: f64, x: f64) -> f64 {
|
|
534
|
+
if atom_cdf < atom_sf {
|
|
535
|
+
(base.cdf(x) - atom_cdf) / atom_sf
|
|
536
|
+
} else {
|
|
537
|
+
(atom_sf - base.sf(x)) / atom_sf
|
|
538
|
+
}
|
|
384
539
|
}
|
|
385
540
|
|
|
386
541
|
impl Distribution for FittedDistribution {
|
|
@@ -535,6 +690,7 @@ pub struct HurdleDistribution {
|
|
|
535
690
|
atom_probability: f64,
|
|
536
691
|
base: FittedDistribution,
|
|
537
692
|
base_cdf_at_atom: f64,
|
|
693
|
+
base_sf_at_atom: f64,
|
|
538
694
|
}
|
|
539
695
|
|
|
540
696
|
impl HurdleDistribution {
|
|
@@ -554,7 +710,14 @@ impl HurdleDistribution {
|
|
|
554
710
|
));
|
|
555
711
|
}
|
|
556
712
|
let base_cdf_at_atom = base.cdf(atom_location);
|
|
557
|
-
|
|
713
|
+
let base_sf_at_atom = base.sf(atom_location);
|
|
714
|
+
// Only a tail that is empty (or has underflowed) is rejected: the
|
|
715
|
+
// conditional tail is evaluated in survival space, so an atom deep in
|
|
716
|
+
// the base's upper tail remains exact.
|
|
717
|
+
if !base_cdf_at_atom.is_finite()
|
|
718
|
+
|| !base_sf_at_atom.is_finite()
|
|
719
|
+
|| base_sf_at_atom < f64::MIN_POSITIVE
|
|
720
|
+
{
|
|
558
721
|
return Err(CrcError::InvalidInput(
|
|
559
722
|
"hurdle base distribution must have positive probability above the atom".into(),
|
|
560
723
|
));
|
|
@@ -564,6 +727,7 @@ impl HurdleDistribution {
|
|
|
564
727
|
atom_probability,
|
|
565
728
|
base,
|
|
566
729
|
base_cdf_at_atom,
|
|
730
|
+
base_sf_at_atom,
|
|
567
731
|
})
|
|
568
732
|
}
|
|
569
733
|
|
|
@@ -593,7 +757,7 @@ impl Distribution for HurdleDistribution {
|
|
|
593
757
|
if x <= self.atom_location {
|
|
594
758
|
0.0
|
|
595
759
|
} else {
|
|
596
|
-
(1.0 - self.atom_probability) * self.base.pdf(x) /
|
|
760
|
+
(1.0 - self.atom_probability) * self.base.pdf(x) / self.base_sf_at_atom
|
|
597
761
|
}
|
|
598
762
|
}
|
|
599
763
|
|
|
@@ -603,9 +767,9 @@ impl Distribution for HurdleDistribution {
|
|
|
603
767
|
} else if x == self.atom_location {
|
|
604
768
|
self.atom_probability
|
|
605
769
|
} else {
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
770
|
+
let conditional =
|
|
771
|
+
truncated_cdf(&self.base, self.base_cdf_at_atom, self.base_sf_at_atom, x);
|
|
772
|
+
(self.atom_probability + (1.0 - self.atom_probability) * conditional)
|
|
609
773
|
.clamp(self.atom_probability, 1.0)
|
|
610
774
|
}
|
|
611
775
|
}
|
|
@@ -621,9 +785,12 @@ impl Distribution for HurdleDistribution {
|
|
|
621
785
|
));
|
|
622
786
|
}
|
|
623
787
|
let conditional_probability = (q - self.atom_probability) / (1.0 - self.atom_probability);
|
|
624
|
-
|
|
625
|
-
self.
|
|
626
|
-
|
|
788
|
+
truncated_ppf(
|
|
789
|
+
&self.base,
|
|
790
|
+
self.base_cdf_at_atom,
|
|
791
|
+
self.base_sf_at_atom,
|
|
792
|
+
conditional_probability,
|
|
793
|
+
)
|
|
627
794
|
}
|
|
628
795
|
}
|
|
629
796
|
|
|
@@ -811,9 +978,12 @@ fn fit_quantile_points_internal(
|
|
|
811
978
|
.iter()
|
|
812
979
|
.map(|&probability| {
|
|
813
980
|
if let Some(atom) = truncation_atom {
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
981
|
+
truncated_ppf(
|
|
982
|
+
&distribution,
|
|
983
|
+
distribution.cdf(atom),
|
|
984
|
+
distribution.sf(atom),
|
|
985
|
+
probability,
|
|
986
|
+
)
|
|
817
987
|
} else {
|
|
818
988
|
distribution.ppf(probability)
|
|
819
989
|
}
|
|
@@ -1345,6 +1515,202 @@ mod tests {
|
|
|
1345
1515
|
assert!(statistics.mean > 0.0);
|
|
1346
1516
|
}
|
|
1347
1517
|
|
|
1518
|
+
fn tail_test_distributions() -> Vec<FittedDistribution> {
|
|
1519
|
+
[
|
|
1520
|
+
(DistributionFamily::GumbelRight, None),
|
|
1521
|
+
(DistributionFamily::GumbelLeft, None),
|
|
1522
|
+
(DistributionFamily::GenExtreme, Some(0.0)),
|
|
1523
|
+
(DistributionFamily::GenExtreme, Some(0.2)),
|
|
1524
|
+
(DistributionFamily::GenExtreme, Some(-0.2)),
|
|
1525
|
+
(DistributionFamily::WeibullMin, Some(1.5)),
|
|
1526
|
+
(DistributionFamily::WeibullMax, Some(2.0)),
|
|
1527
|
+
(DistributionFamily::GenPareto, Some(0.0)),
|
|
1528
|
+
(DistributionFamily::GenPareto, Some(0.2)),
|
|
1529
|
+
(DistributionFamily::GenPareto, Some(-0.2)),
|
|
1530
|
+
(DistributionFamily::SkewNormal, Some(3.0)),
|
|
1531
|
+
]
|
|
1532
|
+
.into_iter()
|
|
1533
|
+
.map(|(family, shape)| {
|
|
1534
|
+
FittedDistribution::from_parameters(family, shape, 1.5, 2.5).unwrap()
|
|
1535
|
+
})
|
|
1536
|
+
.collect()
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1539
|
+
#[test]
|
|
1540
|
+
fn survival_function_complements_cdf_and_inverts_for_every_family() {
|
|
1541
|
+
for distribution in tail_test_distributions() {
|
|
1542
|
+
// The skew-normal cdf is a quadrature over an erf approximation.
|
|
1543
|
+
let (tolerance, isf_tolerance) =
|
|
1544
|
+
if distribution.family == DistributionFamily::SkewNormal {
|
|
1545
|
+
(1.0e-6, 1.0e-5)
|
|
1546
|
+
} else {
|
|
1547
|
+
(1.0e-12, 1.0e-8)
|
|
1548
|
+
};
|
|
1549
|
+
for q in [0.001, 0.05, 0.3, 0.5, 0.7, 0.95, 0.999] {
|
|
1550
|
+
let x = distribution.ppf(q).unwrap();
|
|
1551
|
+
let survival = distribution.sf(x);
|
|
1552
|
+
assert!(
|
|
1553
|
+
(survival + distribution.cdf(x) - 1.0).abs() < tolerance,
|
|
1554
|
+
"{:?} sf+cdf at q={q}",
|
|
1555
|
+
distribution.family
|
|
1556
|
+
);
|
|
1557
|
+
assert!(
|
|
1558
|
+
(survival - (1.0 - q)).abs() < tolerance,
|
|
1559
|
+
"{:?} sf at q={q}",
|
|
1560
|
+
distribution.family
|
|
1561
|
+
);
|
|
1562
|
+
let inverted = distribution.isf(1.0 - q).unwrap();
|
|
1563
|
+
assert!(
|
|
1564
|
+
(inverted - x).abs() < isf_tolerance * x.abs().max(1.0),
|
|
1565
|
+
"{:?} isf at q={q}: {inverted} vs {x}",
|
|
1566
|
+
distribution.family
|
|
1567
|
+
);
|
|
1568
|
+
}
|
|
1569
|
+
assert!(distribution.isf(0.0).is_err());
|
|
1570
|
+
assert!(distribution.isf(1.0).is_err());
|
|
1571
|
+
}
|
|
1572
|
+
}
|
|
1573
|
+
|
|
1574
|
+
#[test]
|
|
1575
|
+
fn survival_function_keeps_relative_precision_deep_in_the_upper_tail() {
|
|
1576
|
+
for distribution in tail_test_distributions() {
|
|
1577
|
+
if distribution.family == DistributionFamily::SkewNormal {
|
|
1578
|
+
continue;
|
|
1579
|
+
}
|
|
1580
|
+
// Far below the ~1.1e-16 spacing of f64 values just under one.
|
|
1581
|
+
for survival in [1.0e-13, 1.0e-20, 1.0e-40] {
|
|
1582
|
+
let Ok(x) = distribution.isf(survival) else {
|
|
1583
|
+
panic!("{:?} isf({survival})", distribution.family);
|
|
1584
|
+
};
|
|
1585
|
+
if !x.is_finite() {
|
|
1586
|
+
// Bounded upper supports (e.g. weibull_max) end first.
|
|
1587
|
+
continue;
|
|
1588
|
+
}
|
|
1589
|
+
let round_trip = distribution.sf(x);
|
|
1590
|
+
if distribution.family == DistributionFamily::WeibullMax
|
|
1591
|
+
|| distribution.family == DistributionFamily::GenPareto
|
|
1592
|
+
&& distribution.shape == Some(-0.2)
|
|
1593
|
+
|| distribution.family == DistributionFamily::GenExtreme
|
|
1594
|
+
&& distribution.shape == Some(0.2)
|
|
1595
|
+
{
|
|
1596
|
+
// Bounded above: the survival round-trips through a value
|
|
1597
|
+
// squeezed against the upper endpoint.
|
|
1598
|
+
assert!(round_trip <= 1.0e-12, "{:?}", distribution.family);
|
|
1599
|
+
continue;
|
|
1600
|
+
}
|
|
1601
|
+
assert!(
|
|
1602
|
+
((round_trip - survival) / survival).abs() < 1.0e-9,
|
|
1603
|
+
"{:?} sf(isf({survival})) = {round_trip}",
|
|
1604
|
+
distribution.family
|
|
1605
|
+
);
|
|
1606
|
+
}
|
|
1607
|
+
}
|
|
1608
|
+
let gumbel =
|
|
1609
|
+
FittedDistribution::from_parameters(DistributionFamily::GumbelRight, None, 0.0, 1.0)
|
|
1610
|
+
.unwrap();
|
|
1611
|
+
assert_eq!(gumbel.cdf(40.0), 1.0);
|
|
1612
|
+
assert!(((gumbel.sf(40.0) - (-40.0f64).exp()) / (-40.0f64).exp()).abs() < 1.0e-15);
|
|
1613
|
+
}
|
|
1614
|
+
|
|
1615
|
+
/// Ottawa hot_days (852b83b3fffffff, ">4 degrees", 2050) as published in the
|
|
1616
|
+
/// canonical dataset: the atom sits ~27.6 scales into the base's upper tail,
|
|
1617
|
+
/// leaving 1.0232e-12 of base mass above it. Capping the base probability at
|
|
1618
|
+
/// `1 - 1e-12` collapsed every return period to ~0.41 days.
|
|
1619
|
+
fn ottawa_hot_days() -> HurdleDistribution {
|
|
1620
|
+
let base = FittedDistribution::from_parameters(
|
|
1621
|
+
DistributionFamily::GumbelRight,
|
|
1622
|
+
None,
|
|
1623
|
+
-489.120_459_721_483_3,
|
|
1624
|
+
17.716_588_490_591_306,
|
|
1625
|
+
)
|
|
1626
|
+
.unwrap();
|
|
1627
|
+
HurdleDistribution::new(0.0, 0.032, base).unwrap()
|
|
1628
|
+
}
|
|
1629
|
+
|
|
1630
|
+
#[test]
|
|
1631
|
+
fn hurdle_quantiles_are_exact_when_the_atom_is_deep_in_the_base_tail() {
|
|
1632
|
+
let hurdle = ottawa_hot_days();
|
|
1633
|
+
// Reference values from 60-digit arithmetic on the stored parameters.
|
|
1634
|
+
for (return_period, expected) in [
|
|
1635
|
+
(10.0, 40.217_752_553_305_5),
|
|
1636
|
+
(25.0, 56.451_298_387_663_2),
|
|
1637
|
+
(100.0, 81.011_705_110_451_8),
|
|
1638
|
+
(1000.0, 121.805_657_667_597),
|
|
1639
|
+
] {
|
|
1640
|
+
let probability = 1.0 - 1.0 / return_period;
|
|
1641
|
+
let value = hurdle.ppf(probability).unwrap();
|
|
1642
|
+
assert!(
|
|
1643
|
+
(value - expected).abs() < 1.0e-9,
|
|
1644
|
+
"RP{return_period}: {value} vs {expected}"
|
|
1645
|
+
);
|
|
1646
|
+
assert!((hurdle.cdf(value) - probability).abs() < 1.0e-12);
|
|
1647
|
+
}
|
|
1648
|
+
assert_eq!(hurdle.ppf(0.032).unwrap(), 0.0);
|
|
1649
|
+
let values = hurdle
|
|
1650
|
+
.quantiles(&[0.05, 0.2, 0.5, 0.8, 0.95, 0.999, 0.999_999])
|
|
1651
|
+
.unwrap();
|
|
1652
|
+
assert!(values.windows(2).all(|pair| pair[0] < pair[1]));
|
|
1653
|
+
// The continuous part is the base's (near-exponential) upper tail.
|
|
1654
|
+
let continuous_mass = {
|
|
1655
|
+
let steps = 20_000;
|
|
1656
|
+
let upper = values[values.len() - 1];
|
|
1657
|
+
let width = upper / steps as f64;
|
|
1658
|
+
(0..steps)
|
|
1659
|
+
.map(|index| hurdle.pdf((index as f64 + 0.5) * width) * width)
|
|
1660
|
+
.sum::<f64>()
|
|
1661
|
+
};
|
|
1662
|
+
assert!((continuous_mass - 0.968).abs() < 1.0e-4);
|
|
1663
|
+
}
|
|
1664
|
+
|
|
1665
|
+
#[test]
|
|
1666
|
+
fn hurdle_accepts_an_atom_beyond_the_former_tail_guard() {
|
|
1667
|
+
// cdf(atom) rounds to one here, but the survival mass is ~5.2e-17.
|
|
1668
|
+
let base =
|
|
1669
|
+
FittedDistribution::from_parameters(DistributionFamily::GumbelRight, None, -37.5, 1.0)
|
|
1670
|
+
.unwrap();
|
|
1671
|
+
assert_eq!(base.cdf(0.0), 1.0);
|
|
1672
|
+
let hurdle = HurdleDistribution::new(0.0, 0.25, base).unwrap();
|
|
1673
|
+
// The truncated tail is the unit exponential to within ~1e-15.
|
|
1674
|
+
for q in [0.3, 0.6, 0.9, 0.99] {
|
|
1675
|
+
let conditional: f64 = (q - 0.25) / 0.75;
|
|
1676
|
+
let expected = -(-conditional).ln_1p();
|
|
1677
|
+
assert!((hurdle.ppf(q).unwrap() - expected).abs() < 1.0e-9);
|
|
1678
|
+
}
|
|
1679
|
+
let empty =
|
|
1680
|
+
FittedDistribution::from_parameters(DistributionFamily::GumbelRight, None, -800.0, 1.0)
|
|
1681
|
+
.unwrap();
|
|
1682
|
+
assert!(HurdleDistribution::new(0.0, 0.25, empty).is_err());
|
|
1683
|
+
}
|
|
1684
|
+
|
|
1685
|
+
#[test]
|
|
1686
|
+
fn hurdle_quantile_fitting_scores_a_deep_tail_atom_on_its_true_quantiles() {
|
|
1687
|
+
let source = ottawa_hot_days();
|
|
1688
|
+
// The curve_fit_cdf knot grid, from the last plateau knot at the atom.
|
|
1689
|
+
let probabilities: Vec<f64> = (32..=999).map(|index| index as f64 / 1000.0).collect();
|
|
1690
|
+
let values = source.quantiles(&probabilities).unwrap();
|
|
1691
|
+
assert_eq!(values[0], 0.0);
|
|
1692
|
+
let result = fit_hurdle_quantiles(
|
|
1693
|
+
&probabilities,
|
|
1694
|
+
&values,
|
|
1695
|
+
None,
|
|
1696
|
+
DistributionFamily::GumbelRight,
|
|
1697
|
+
0.0,
|
|
1698
|
+
0.032,
|
|
1699
|
+
)
|
|
1700
|
+
.unwrap();
|
|
1701
|
+
// The truncated base is only identified up to its tail scale here.
|
|
1702
|
+
assert!((result.distribution.base().scale - 17.716_588_490_591_306).abs() < 1.0e-3);
|
|
1703
|
+
assert!(result.diagnostics.tail.normalized_rmse < 1.0e-4);
|
|
1704
|
+
for probability in [0.5, 0.9, 0.99] {
|
|
1705
|
+
let fitted = result.distribution.ppf(probability).unwrap();
|
|
1706
|
+
let expected = source.ppf(probability).unwrap();
|
|
1707
|
+
assert!(
|
|
1708
|
+
(fitted - expected).abs() < 1.0e-2,
|
|
1709
|
+
"{probability}: {fitted} vs {expected}"
|
|
1710
|
+
);
|
|
1711
|
+
}
|
|
1712
|
+
}
|
|
1713
|
+
|
|
1348
1714
|
#[test]
|
|
1349
1715
|
fn hurdle_quantile_fitting_uses_supplied_atom_probability() {
|
|
1350
1716
|
let base =
|
|
@@ -10,6 +10,9 @@
|
|
|
10
10
|
|
|
11
11
|
use std::f64::consts::{PI, SQRT_2};
|
|
12
12
|
|
|
13
|
+
use crate::distribution::{
|
|
14
|
+
Distribution as _, DistributionFamily, FittedDistribution as TailDistribution, truncated_ppf,
|
|
15
|
+
};
|
|
13
16
|
use crate::risk::climate::utility::get_hazard_limit;
|
|
14
17
|
|
|
15
18
|
// Numeric constants
|
|
@@ -240,6 +243,9 @@ pub(crate) fn fit_quantile_points(
|
|
|
240
243
|
};
|
|
241
244
|
let value_range = values.iter().copied().fold(f64::NEG_INFINITY, f64::max)
|
|
242
245
|
- values.iter().copied().fold(f64::INFINITY, f64::min);
|
|
246
|
+
// Loop-invariant across every start, iteration and objective evaluation of
|
|
247
|
+
// this fit; summed here in the same order the objective used to sum it.
|
|
248
|
+
let weight_sum = weights.iter().sum::<f64>();
|
|
243
249
|
let mut best: Option<SimplexResult> = None;
|
|
244
250
|
|
|
245
251
|
for start in starts {
|
|
@@ -268,6 +274,7 @@ pub(crate) fn fit_quantile_points(
|
|
|
268
274
|
probabilities,
|
|
269
275
|
values,
|
|
270
276
|
weights,
|
|
277
|
+
weight_sum,
|
|
271
278
|
value_scale,
|
|
272
279
|
truncation_atom,
|
|
273
280
|
)
|
|
@@ -403,12 +410,16 @@ fn constrain_quantile_parameters(kind: DistributionKind, parameters: &mut [f64])
|
|
|
403
410
|
}
|
|
404
411
|
}
|
|
405
412
|
|
|
413
|
+
/// `weight_sum` is `weights.iter().sum()`. It is a property of the fit, not of
|
|
414
|
+
/// the trial parameters, so the caller computes it once instead of having every
|
|
415
|
+
/// objective evaluation re-sum the weight vector.
|
|
406
416
|
fn quantile_objective(
|
|
407
417
|
kind: DistributionKind,
|
|
408
418
|
parameters: &[f64],
|
|
409
419
|
probabilities: &[f64],
|
|
410
420
|
values: &[f64],
|
|
411
421
|
weights: &[f64],
|
|
422
|
+
weight_sum: f64,
|
|
412
423
|
value_scale: f64,
|
|
413
424
|
truncation_atom: Option<f64>,
|
|
414
425
|
) -> f64 {
|
|
@@ -421,24 +432,62 @@ fn quantile_objective(
|
|
|
421
432
|
else {
|
|
422
433
|
return f64::INFINITY;
|
|
423
434
|
};
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
let
|
|
435
|
+
// The truncated tail is scored with the same survival-space inversion a
|
|
436
|
+
// stored hurdle curve is read back with, so an atom deep in the base's
|
|
437
|
+
// upper tail (where the whole tail rounds to one in cdf space) is fitted
|
|
438
|
+
// against the curve callers will actually evaluate.
|
|
439
|
+
let tail = match truncation_atom {
|
|
440
|
+
None => None,
|
|
441
|
+
Some(atom) => {
|
|
442
|
+
let Ok(base) = TailDistribution::from_parameters(
|
|
443
|
+
DistributionFamily::from_reference_kind(kind),
|
|
444
|
+
shape,
|
|
445
|
+
loc,
|
|
446
|
+
scale,
|
|
447
|
+
) else {
|
|
448
|
+
return f64::INFINITY;
|
|
449
|
+
};
|
|
450
|
+
let atom_cdf = base.cdf(atom);
|
|
451
|
+
let atom_sf = base.sf(atom);
|
|
452
|
+
// Beyond this the truncated base has degenerated into its limiting
|
|
453
|
+
// upper tail and `loc` is no longer identified by the knots, so the
|
|
454
|
+
// simplex is kept out of that ridge.
|
|
455
|
+
if !atom_cdf.is_finite() || !(atom_sf > 1.0e-12) {
|
|
456
|
+
return f64::INFINITY;
|
|
457
|
+
}
|
|
458
|
+
Some((base, atom_cdf, atom_sf))
|
|
459
|
+
}
|
|
460
|
+
};
|
|
461
|
+
// The truncation branch is loop-invariant, so it is resolved once here
|
|
462
|
+
// rather than per knot.
|
|
429
463
|
let mut loss = 0.0;
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
464
|
+
match tail {
|
|
465
|
+
None => {
|
|
466
|
+
for ((&probability, &value), &weight) in probabilities.iter().zip(values).zip(weights) {
|
|
467
|
+
if weight == 0.0 {
|
|
468
|
+
continue;
|
|
469
|
+
}
|
|
470
|
+
let fitted = distribution.ppf(probability);
|
|
471
|
+
if !fitted.is_finite() {
|
|
472
|
+
return f64::INFINITY;
|
|
473
|
+
}
|
|
474
|
+
loss += weight * ((fitted - value) / value_scale).powi(2);
|
|
475
|
+
}
|
|
433
476
|
}
|
|
434
|
-
|
|
435
|
-
(
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
477
|
+
Some((base, atom_cdf, atom_sf)) => {
|
|
478
|
+
for ((&probability, &value), &weight) in probabilities.iter().zip(values).zip(weights) {
|
|
479
|
+
if weight == 0.0 {
|
|
480
|
+
continue;
|
|
481
|
+
}
|
|
482
|
+
let Ok(fitted) = truncated_ppf(&base, atom_cdf, atom_sf, probability) else {
|
|
483
|
+
return f64::INFINITY;
|
|
484
|
+
};
|
|
485
|
+
if !fitted.is_finite() {
|
|
486
|
+
return f64::INFINITY;
|
|
487
|
+
}
|
|
488
|
+
loss += weight * ((fitted - value) / value_scale).powi(2);
|
|
489
|
+
}
|
|
440
490
|
}
|
|
441
|
-
loss += weight * ((fitted - value) / value_scale).powi(2);
|
|
442
491
|
}
|
|
443
492
|
loss / weight_sum
|
|
444
493
|
}
|