crc-framework 0.2.5__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.5 → crc_framework-0.2.6}/Cargo.lock +2 -2
- {crc_framework-0.2.5 → crc_framework-0.2.6}/Cargo.toml +1 -1
- {crc_framework-0.2.5 → crc_framework-0.2.6}/PKG-INFO +1 -1
- {crc_framework-0.2.5 → crc_framework-0.2.6}/crates/core/Cargo.toml +1 -1
- {crc_framework-0.2.5 → crc_framework-0.2.6}/crates/core/src/distribution.rs +377 -11
- {crc_framework-0.2.5 → crc_framework-0.2.6}/crates/core/src/reference/curve_fitting.rs +35 -10
- {crc_framework-0.2.5 → crc_framework-0.2.6}/.github/workflows/release.yaml +0 -0
- {crc_framework-0.2.5 → crc_framework-0.2.6}/.gitignore +0 -0
- {crc_framework-0.2.5 → crc_framework-0.2.6}/.python-version +0 -0
- {crc_framework-0.2.5 → crc_framework-0.2.6}/LICENSE +0 -0
- {crc_framework-0.2.5 → crc_framework-0.2.6}/README.md +0 -0
- {crc_framework-0.2.5 → crc_framework-0.2.6}/THIRD_PARTY_NOTICES.md +0 -0
- {crc_framework-0.2.5 → crc_framework-0.2.6}/crates/core/src/constants.rs +0 -0
- {crc_framework-0.2.5 → crc_framework-0.2.6}/crates/core/src/error.rs +0 -0
- {crc_framework-0.2.5 → crc_framework-0.2.6}/crates/core/src/lib.rs +0 -0
- {crc_framework-0.2.5 → crc_framework-0.2.6}/crates/core/src/metrics.rs +0 -0
- {crc_framework-0.2.5 → crc_framework-0.2.6}/crates/core/src/reference/climate/ag_params.rs +0 -0
- {crc_framework-0.2.5 → crc_framework-0.2.6}/crates/core/src/reference/climate/energy_params.rs +0 -0
- {crc_framework-0.2.5 → crc_framework-0.2.6}/crates/core/src/reference/climate/flood_params.rs +0 -0
- {crc_framework-0.2.5 → crc_framework-0.2.6}/crates/core/src/reference/climate/mod.rs +0 -0
- {crc_framework-0.2.5 → crc_framework-0.2.6}/crates/core/src/reference/climate/precipitation_thresholds.rs +0 -0
- {crc_framework-0.2.5 → crc_framework-0.2.6}/crates/core/src/reference/climate/spei_params.rs +0 -0
- {crc_framework-0.2.5 → crc_framework-0.2.6}/crates/core/src/reference/climate/wind_params.rs +0 -0
- {crc_framework-0.2.5 → crc_framework-0.2.6}/crates/core/src/reference/mod.rs +0 -0
- {crc_framework-0.2.5 → crc_framework-0.2.6}/crates/core/src/reference/spatial/continent_r4.rs +0 -0
- {crc_framework-0.2.5 → crc_framework-0.2.6}/crates/core/src/reference/spatial/geography_r5.rs +0 -0
- {crc_framework-0.2.5 → crc_framework-0.2.6}/crates/core/src/reference/spatial/ipcc.rs +0 -0
- {crc_framework-0.2.5 → crc_framework-0.2.6}/crates/core/src/reference/spatial/mod.rs +0 -0
- {crc_framework-0.2.5 → crc_framework-0.2.6}/crates/core/src/risk.rs +0 -0
- {crc_framework-0.2.5 → crc_framework-0.2.6}/crates/core/src/spatial.rs +0 -0
- {crc_framework-0.2.5 → crc_framework-0.2.6}/crates/core/src/transform.rs +0 -0
- {crc_framework-0.2.5 → crc_framework-0.2.6}/crates/core/tests/quantile_fit_golden.rs +0 -0
- {crc_framework-0.2.5 → crc_framework-0.2.6}/crates/core/tests/quantile_fit_golden.txt +0 -0
- {crc_framework-0.2.5 → crc_framework-0.2.6}/pyproject.toml +0 -0
- {crc_framework-0.2.5 → crc_framework-0.2.6}/python/crc_framework/__init__.py +0 -0
- {crc_framework-0.2.5 → crc_framework-0.2.6}/python/crc_framework/_core.pyi +0 -0
- {crc_framework-0.2.5 → crc_framework-0.2.6}/python/crc_framework/constants.py +0 -0
- {crc_framework-0.2.5 → crc_framework-0.2.6}/python/crc_framework/distributions/__init__.py +0 -0
- {crc_framework-0.2.5 → crc_framework-0.2.6}/python/crc_framework/distributions/base.py +0 -0
- {crc_framework-0.2.5 → crc_framework-0.2.6}/python/crc_framework/distributions/empirical.py +0 -0
- {crc_framework-0.2.5 → crc_framework-0.2.6}/python/crc_framework/distributions/fitted.py +0 -0
- {crc_framework-0.2.5 → crc_framework-0.2.6}/python/crc_framework/distributions/fitting.py +0 -0
- {crc_framework-0.2.5 → crc_framework-0.2.6}/python/crc_framework/distributions/hurdle.py +0 -0
- {crc_framework-0.2.5 → crc_framework-0.2.6}/python/crc_framework/distributions/point_mass.py +0 -0
- {crc_framework-0.2.5 → crc_framework-0.2.6}/python/crc_framework/distributions/tabulated.py +0 -0
- {crc_framework-0.2.5 → crc_framework-0.2.6}/python/crc_framework/metrics/__init__.py +0 -0
- {crc_framework-0.2.5 → crc_framework-0.2.6}/python/crc_framework/metrics/distribution.py +0 -0
- {crc_framework-0.2.5 → crc_framework-0.2.6}/python/crc_framework/metrics/microscores.py +0 -0
- {crc_framework-0.2.5 → crc_framework-0.2.6}/python/crc_framework/metrics/risk.py +0 -0
- {crc_framework-0.2.5 → crc_framework-0.2.6}/python/crc_framework/metrics/spanning.py +0 -0
- {crc_framework-0.2.5 → crc_framework-0.2.6}/python/crc_framework/models.py +0 -0
- {crc_framework-0.2.5 → crc_framework-0.2.6}/python/crc_framework/py.typed +0 -0
- {crc_framework-0.2.5 → crc_framework-0.2.6}/python/crc_framework/spatial.py +0 -0
- {crc_framework-0.2.5 → crc_framework-0.2.6}/python/crc_framework/transforms/__init__.py +0 -0
- {crc_framework-0.2.5 → crc_framework-0.2.6}/python/crc_framework/transforms/base.py +0 -0
- {crc_framework-0.2.5 → crc_framework-0.2.6}/python/crc_framework/transforms/builtin.py +0 -0
- {crc_framework-0.2.5 → crc_framework-0.2.6}/python/crc_framework/transforms/climate.py +0 -0
- {crc_framework-0.2.5 → crc_framework-0.2.6}/src/lib.rs +0 -0
- {crc_framework-0.2.5 → crc_framework-0.2.6}/tests/test_impacts.py +0 -0
- {crc_framework-0.2.5 → crc_framework-0.2.6}/tests/test_sdk.py +0 -0
- {crc_framework-0.2.5 → crc_framework-0.2.6}/tests/typing_smoke.py +0 -0
- {crc_framework-0.2.5 → crc_framework-0.2.6}/uv.lock +0 -0
|
@@ -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
|
|
@@ -429,15 +432,36 @@ fn quantile_objective(
|
|
|
429
432
|
else {
|
|
430
433
|
return f64::INFINITY;
|
|
431
434
|
};
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
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
|
+
};
|
|
436
461
|
// The truncation branch is loop-invariant, so it is resolved once here
|
|
437
|
-
// rather than per knot.
|
|
438
|
-
// loop computed, in the same order.
|
|
462
|
+
// rather than per knot.
|
|
439
463
|
let mut loss = 0.0;
|
|
440
|
-
match
|
|
464
|
+
match tail {
|
|
441
465
|
None => {
|
|
442
466
|
for ((&probability, &value), &weight) in probabilities.iter().zip(values).zip(weights) {
|
|
443
467
|
if weight == 0.0 {
|
|
@@ -450,13 +474,14 @@ fn quantile_objective(
|
|
|
450
474
|
loss += weight * ((fitted - value) / value_scale).powi(2);
|
|
451
475
|
}
|
|
452
476
|
}
|
|
453
|
-
Some(
|
|
477
|
+
Some((base, atom_cdf, atom_sf)) => {
|
|
454
478
|
for ((&probability, &value), &weight) in probabilities.iter().zip(values).zip(weights) {
|
|
455
479
|
if weight == 0.0 {
|
|
456
480
|
continue;
|
|
457
481
|
}
|
|
458
|
-
let
|
|
459
|
-
|
|
482
|
+
let Ok(fitted) = truncated_ppf(&base, atom_cdf, atom_sf, probability) else {
|
|
483
|
+
return f64::INFINITY;
|
|
484
|
+
};
|
|
460
485
|
if !fitted.is_finite() {
|
|
461
486
|
return f64::INFINITY;
|
|
462
487
|
}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{crc_framework-0.2.5 → crc_framework-0.2.6}/crates/core/src/reference/climate/energy_params.rs
RENAMED
|
File without changes
|
{crc_framework-0.2.5 → crc_framework-0.2.6}/crates/core/src/reference/climate/flood_params.rs
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{crc_framework-0.2.5 → crc_framework-0.2.6}/crates/core/src/reference/climate/spei_params.rs
RENAMED
|
File without changes
|
{crc_framework-0.2.5 → crc_framework-0.2.6}/crates/core/src/reference/climate/wind_params.rs
RENAMED
|
File without changes
|
|
File without changes
|
{crc_framework-0.2.5 → crc_framework-0.2.6}/crates/core/src/reference/spatial/continent_r4.rs
RENAMED
|
File without changes
|
{crc_framework-0.2.5 → crc_framework-0.2.6}/crates/core/src/reference/spatial/geography_r5.rs
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{crc_framework-0.2.5 → crc_framework-0.2.6}/python/crc_framework/distributions/point_mass.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|