import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import corehydropy as ch27. Composite distributions
Most of the distribution surface takes a family name and a parameter vector. Five families do not, because they are built out of other distributions or out of the data itself: a truncated distribution, a mixture, a competing-risks system, an empirical curve, and a kernel density. corehydropy calls them composite, and each has its own constructor: dist_truncated(), dist_mixture(), dist_competing_risks(), dist_empirical(), and dist_kde(). What comes back is an ordinary Distribution: the density, distribution, quantile, random-draw, moment, parameter, and log-likelihood methods all take one.
This example uses three of them on one record: a mixture for a basin with two flood-generating mechanisms, a kernel density as a nonparametric reference, and a truncated distribution for a physical lower bound.
What you’ll learn
- Fit a two-component mixture with
model_mixture()andfit_mle(), then rebuild the fit as a standalonedist_mixture()distribution. - See where a second component changes the frequency curve, and where it does not.
- Build a kernel density, reproduce its default bandwidth, and understand what
bounded_by_datadoes and does not restrict. - Renormalize a distribution onto a physical range with
dist_truncated(). - Know the two things composites cannot do.
Setup
A mixed-population record
Sixty annual peak flows from a basin with two flood seasons: spring snowmelt produces a cluster of moderate peaks, and summer convective storms produce a smaller number of much larger ones. The record is written out here so the page is self-contained and both languages read the same numbers.
peaks = [
1240, 8720, 2110, 1240, 11180, 1570, 11620, 4470, 6470, 1960,
1740, 3530, 7440, 9080, 1500, 5760, 7920, 2480, 1790, 2650,
1740, 1590, 2080, 2220, 1800, 2120, 11430, 8710, 19870, 1570,
1540, 5290, 1480, 7750, 1900, 5630, 1910, 20340, 1280, 9270,
5720, 1390, 14490, 2180, 1590, 1530, 6540, 2470, 2490, 7780,
13010, 2140, 1220, 11790, 2820, 2000, 2390, 1440, 8740, 2470,
]
n = len(peaks)
print(f"n = {n}, range {min(peaks):,} to {max(peaks):,} cfs")n = 60, range 1,220 to 20,340 cfs
One distribution, or two?
model_mixture() builds a weighted mixture model, and fit_mle() fits it the same way it fits any other model. The single-family fit is the comparison.
single_fit = ch.fit_mle(ch.model_univariate("LogNormal", peaks))
mix_fit = ch.fit_mle(ch.model_mixture(["LogNormal", "LogNormal"], peaks))
# `Fit.parameters` is a dict keyed by parameter name, and a mixture labels every parameter of a
# component with that component's tag, so names repeat and the dict silently drops values: six
# fitted numbers collapse to four entries. `standard_errors`, `map`, `posterior_mean` and the
# printed summary lose the same values. Read the ordered vector off the fitted model spec.
p = list(mix_fit.model.spec["parameter_values"])
pd.Series(p, index=mix_fit.parameter_names).round(4)Weight (w₁) 0.4129
Weight (w₂) 0.5871
D1 3.9323
D1 0.1814
D2 3.2639
D2 0.1046
dtype: float64
The parameter vector is the mixture weights first, then each component’s own parameters in order, which is exactly how the core lays a mixture’s parameters out.
components = [ch.Distribution("LogNormal", p[2:4]), ch.Distribution("LogNormal", p[4:6])]
for i, comp in enumerate(components):
print(f"component {i + 1}: weight {p[i]:.4f}, median {round(comp.quantile(0.5)):,} cfs")component 1: weight 0.4129, median 8,557 cfs
component 2: weight 0.5871, median 1,836 cfs
Two seasons, recovered from the record alone: a wide component centered near 8,557 cfs carrying weight 0.4129, and a narrow one centered near 1,836 cfs carrying the rest.
gof = pd.DataFrame(
{
"parameters": [len(single_fit.parameter_names), len(mix_fit.parameter_names)],
"log_likelihood": [single_fit.log_likelihood, mix_fit.log_likelihood],
"aic": [single_fit.aic, mix_fit.aic],
"bic": [single_fit.bic, mix_fit.bic],
},
index=["LogNormal", "Two-component mixture"],
)
gof.round(3)| parameters | log_likelihood | aic | bic | |
|---|---|---|---|---|
| LogNormal | 2 | -562.620 | 1129.239 | 1133.428 |
| Two-component mixture | 6 | -541.624 | 1095.249 | 1107.815 |
The mixture costs four extra parameters, and both criteria still prefer it: its AIC and its BIC are lower than the single fit’s. A single lognormal cannot be both narrow enough for the snowmelt cluster and wide enough for the storm peaks.
Rebuilding the fit as a distribution
fit_mle() returns a fit, not a distribution. dist_mixture() turns the fitted numbers back into a Distribution the other methods accept, and the log-likelihood of that composite over the same record reproduces the fit’s own value exactly.
mixture = ch.dist_mixture(components, weights=p[0:2])
single = ch.Distribution("LogNormal", list(single_fit.parameters.values()))
print(mixture)
print(f"log-likelihood: {mixture.log_likelihood(peaks):.10f} from dist_mixture(), "
f"{mix_fit.log_likelihood:.10f} from the fit")Distribution(Mixture (composite): {"family": "Mixture", "components": [{"family": "LogNormal", "parameters": [3...)
log-likelihood: -541.6244186353 from dist_mixture(), -541.6244186353 from the fit
Where the second component changes the answer
aeps = np.array([0.5, 0.1, 0.01, 0.002])
quantiles = pd.DataFrame(
{
"single": [single.quantile(1 - a) for a in aeps],
"mixture": [mixture.quantile(1 - a) for a in aeps],
},
index=[f"{100 * a:g}% AEP" for a in aeps],
)
quantiles["ratio"] = quantiles["mixture"] / quantiles["single"]
quantiles.round(3)| single | mixture | ratio | |
|---|---|---|---|
| 50% AEP | 3465.790 | 2359.245 | 0.681 |
| 10% AEP | 9974.785 | 11461.031 | 1.149 |
| 1% AEP | 23615.112 | 19517.846 | 0.826 |
| 0.2% AEP | 37228.118 | 25218.021 | 0.677 |
The mixture is not uniformly heavier. It raises the 10% quantile from 9,975 to 11,461 cfs, because the storm component puts real probability where a single lognormal had a thin shoulder. It then lowers the 1% and 0.2% quantiles, because each component has its own scale and neither is as diffuse as the single fit forced to straddle both clusters. Splitting a bimodal record into two components is not the same as making its tail heavier.
pd.DataFrame([single.moments(), mixture.moments()], index=["single", "mixture"]).round(3)| mean | median | mode | sd | skewness | kurtosis | minimum | maximum | |
|---|---|---|---|---|---|---|---|---|
| single | 4870.273 | 3465.790 | 3465.790 | 4808.281 | 3.924 | 39.305 | 0.0 | inf |
| mixture | 4965.313 | 2359.245 | 7186.722 | 4520.883 | 1.574 | 5.837 | 0.0 | inf |
The moments say the same thing. The two distributions have nearly the same mean, but the mixture’s standard deviation, skewness, and kurtosis are all smaller and its median is far lower. Read the mixture’s reported mode with care: the core finds it by maximizing the density with a Brent search over the interval between the 0.1% and 99.9% quantiles, which on a two-humped density converges to whichever hump the search lands in.
A kernel density as a nonparametric reference
dist_kde() builds a density by summing a kernel centered at each observation. With no bandwidth supplied it uses the rule h = s * (4 / (3n))**(1/5), where s is the sample standard deviation. Passing that value explicitly reproduces the default exactly.
h = float(np.std(peaks, ddof=1) * (4 / (3 * n)) ** 0.2)
kde = ch.dist_kde(peaks)
same = kde.quantile(0.9) == ch.dist_kde(peaks, bandwidth=h).quantile(0.9)
print(f"bandwidth {h:.6f}; explicit bandwidth reproduces the default: {same}")bandwidth 2143.864822; explicit bandwidth reproduces the default: True
grid = np.linspace(500, 24000, 400)
fig, ax = plt.subplots(figsize=(7, 4.5))
ax.hist(peaks, bins=20, density=True, color="#d9d2c5", edgecolor="white")
ax.plot(grid, single.pdf(grid), lw=2, color="#4d6b8a", label="LogNormal")
ax.plot(grid, mixture.pdf(grid), lw=2, color="#8c5a3b", label="Two-component mixture")
ax.plot(grid, kde.pdf(grid), lw=2, ls="--", color="#6b7f3f", label="Kernel density")
ax.set_xlabel("Annual peak flow (cfs)")
ax.set_ylabel("Density")
ax.set_title("Three densities over the same record")
ax.legend(frameon=False)
plt.show()
bounded_by_data, on by default, sets the distribution’s reported minimum and maximum to the smallest and largest observation instead of extending three bandwidths past each. That gates the CDF and the quantile function, which cannot then return a value outside the observed range. It does not gate the density: the kernel sum is evaluated wherever you ask.
kde_open = ch.dist_kde(peaks, bounded_by_data=False)
print(f"largest observation {max(peaks):,}")
print(f"0.2% AEP quantile, bounded {kde.quantile(0.998):.1f}")
print(f"0.2% AEP quantile, unbounded {kde_open.quantile(0.998):.1f}")
print(f"density at 30,000 cfs identical either way: {kde.pdf(30000) == kde_open.pdf(30000)}")largest observation 20,340
0.2% AEP quantile, bounded 20088.4
0.2% AEP quantile, unbounded 23424.8
density at 30,000 cfs identical either way: True
That is the honest limit of a kernel density for design work. With bounded_by_data left on it describes the record it was built from and will not return a quantile past it, which is exactly why the parametric fits above still have a job. Turned off, the support runs three bandwidths beyond the largest observation, and the 0.2% AEP quantile lands at 23,424.8 cfs against a record maximum of 20,340.
pd.DataFrame(
{
"single": [single.quantile(1 - a) for a in aeps],
"mixture": [mixture.quantile(1 - a) for a in aeps],
"kde": [kde.quantile(1 - a) for a in aeps],
},
index=[f"{100 * a:g}% AEP" for a in aeps],
).round(1)| single | mixture | kde | |
|---|---|---|---|
| 50% AEP | 3465.8 | 2359.2 | 5011.0 |
| 10% AEP | 9974.8 | 11461.0 | 12151.2 |
| 1% AEP | 23615.1 | 19517.8 | 19109.9 |
| 0.2% AEP | 37228.1 | 25218.0 | 20088.4 |
A physical lower bound
Suppose a downstream diversion guarantees that at least 1,000 cfs passes the gauge in any year, so an annual maximum below that is impossible. The fitted lognormal does not know this.
print(f"P(annual peak < 1,000 cfs) under the fitted LogNormal: {single.cdf(1000):.4f}")P(annual peak < 1,000 cfs) under the fitted LogNormal: 0.0659
dist_truncated() restricts a distribution to [min, max] and renormalizes: the density becomes the base density divided by F(max) - F(min), zero outside the bounds, and the CDF becomes (F(x) - F(min)) / (F(max) - F(min)).
truncated = ch.dist_truncated(single, min=1000, max=1e6)
print(f"CDF at the lower bound: {truncated.cdf(1000):.1f}")
print(f"density ratio at 5,000 cfs: {truncated.pdf(5000) / single.pdf(5000):.10f}")
print(f"1 / (F(max) - F(min)): {1 / (single.cdf(1e6) - single.cdf(1000)):.10f}")CDF at the lower bound: 0.0
density ratio at 5,000 cfs: 1.0705813468
1 / (F(max) - F(min)): 1.0705813468
Removing the impossible lower tail pushes every quantile up, and the proportional shift shrinks as the quantile rises: 1.0706 at the median against 1.0178 at the 0.2% AEP quantile, the part of the curve a design study reads.
shifted = pd.DataFrame(
{
"base": [single.quantile(1 - a) for a in aeps],
"truncated": [truncated.quantile(1 - a) for a in aeps],
},
index=[f"{100 * a:g}% AEP" for a in aeps],
)
shifted["ratio"] = shifted["truncated"] / shifted["base"]
shifted.round(4)| base | truncated | ratio | |
|---|---|---|---|
| 50% AEP | 3465.7895 | 3710.5364 | 1.0706 |
| 10% AEP | 9974.7850 | 10296.7316 | 1.0323 |
| 1% AEP | 23615.1116 | 24116.6939 | 1.0212 |
| 0.2% AEP | 37228.1177 | 37892.5827 | 1.0178 |
What composites cannot do
Two things. First, none of the five implements linear moments upstream, so linear_moments() declines rather than approximating.
try:
mixture.linear_moments()
except Exception as err:
print(err)linear moments are not available for 'Mixture'; no composite distribution implements ILinearMomentEstimation upstream
Second, a composite has no flat parameter vector, so Distribution will not build one and there is no Distribution.fit() path to a composite family. Mixtures are fitted through model_mixture() and an estimator, as above; competing-risks systems through model_competing_risks().
for family in ("Mixture", "KernelDensity"):
try:
ch.Distribution(family, [0.5, 0.5])
except Exception as err:
print(err)
print(ch.distribution_names("structured"))'Mixture' has no flat parameter vector; use dist_mixture() instead
'KernelDensity' has no flat parameter vector; use dist_kde() instead
['TruncatedDistribution', 'Mixture', 'CompetingRisks', 'Empirical', 'KernelDensity']
Reproduction check
# Every fit on this page is deterministic (maximum likelihood by a deterministic optimizer; the
# kernel density and the truncation are closed-form), so these literals are what this port
# computes -- and the R version asserts exactly the same ones, which is what proves the
# cross-language identity. Both packages run the same compiled core, so the match is exact here
# rather than merely close.
# The two maximum-likelihood fits.
assert list(single_fit.parameters.values()) == [3.5398021872265386, 0.3582386901112835]
assert single_fit.log_likelihood == -562.619574745015
assert p == [
0.4128976914440403, 0.5871023085559597, 3.9323412160584628,
0.1814491578779595, 3.263901631894294, 0.10464430806060765,
]
assert mix_fit.log_likelihood == -541.6244186353471
assert gof.loc["LogNormal", "aic"] == 1129.23914949003
assert gof.loc["Two-component mixture", "aic"] == 1095.2488372706941
assert gof.loc["LogNormal", "bic"] == 1133.4278386144742
assert gof.loc["Two-component mixture", "bic"] == 1107.8149046440267
assert [round(c.quantile(0.5), 10) for c in components] == [8557.3878483474, 1836.1224128047]
# The composite rebuilt from the fitted values.
assert mixture.log_likelihood(peaks) == mix_fit.log_likelihood
# Quantiles: the mixture is higher at 10% and lower at 1% and 0.2%.
assert quantiles["single"].tolist() == [
3465.7895445615486, 9974.784950678773, 23615.111603538928, 37228.1177227124
]
assert quantiles["mixture"].tolist() == [
2359.2451983374535, 11461.031257850214, 19517.845994674906, 25218.020604342844
]
assert mixture.moments()["skewness"] == 1.573808301782999
assert single.moments()["skewness"] == 3.924112090654374
# The kernel density.
assert h == 2143.864821560871
assert kde.quantile(0.9) == ch.dist_kde(peaks, bandwidth=h).quantile(0.9)
assert kde.quantile(0.9) == 12151.217810408163
assert kde.quantile(0.998) == 20088.429334855526
assert kde_open.quantile(0.998) == 23424.805148963263
assert kde.pdf(30000) == kde_open.pdf(30000)
# The truncated distribution.
assert single.cdf(1000) == 0.06592805578704675
assert truncated.cdf(1000) == 0.0
assert truncated.quantile(0.5) == 3710.536428782953
assert truncated.quantile(0.99) == 24116.69387849512
assert truncated.pdf(5000) / single.pdf(5000) == 1.0705813467570697
# Internal consistency: the truncation identities the core documents, the bounded kernel density
# refusing to leave the observed range, and the mixture beating the single fit on both criteria.
assert abs(truncated.pdf(5000) / single.pdf(5000)
- 1 / (single.cdf(1e6) - single.cdf(1000))) < 1e-12
assert truncated.pdf(500) == 0.0
assert kde.quantile(0.998) < max(peaks)
assert kde_open.quantile(0.998) > max(peaks)
assert mix_fit.aic < single_fit.aic and mix_fit.bic < single_fit.bic
assert (shifted["ratio"] > 1).all() and (np.diff(shifted["ratio"]) < 0).all()
assert abs(sum(p[0:2]) - 1) < 1e-12
print("All reproduction checks passed.")All reproduction checks passed.