Getting started
ConvolvedDistributions builds the distribution of a sum (X + Y, a convolution), a signed gap (X - Y), a product (X * Y), or a ratio (X / Y) of independent random variables, for any pair of Distributions.jl univariate distributions. Closed forms are used where they exist and an AD-safe Gauss-Legendre quadrature everywhere else, so the results can be scored, truncated, and differentiated inside a fitting loop. This page walks through the main entry points; the Public API has the full interface.
See Installation for how to install the package.
Convolving distributions
convolved returns the distribution of the sum of independent components. For pairs with a known closed form (Normal + Normal, equal-scale Gamma, equal-rate Exponential) it delegates to the analytic result; any other pair uses the numeric quadrature fallback.
using ConvolvedDistributions, Distributions
# An incubation period plus a reporting delay, say.
d = convolved(Gamma(2.0, 1.0), LogNormal(0.5, 0.4))
cdf(d, 5.0)0.8051810060082844Densities and moments work the same way; the mean and variance are exact component sums, not quadrature results.
pdf(d, 5.0), mean(d), var(d)(0.13671207573887373, 3.7860384307500734, 2.553488101144678)More than two components can be passed as varargs, a tuple, or a vector.
d3 = convolved(Gamma(2.0, 1.0), LogNormal(0.5, 0.4),
Exponential(2.0))
mean(d3)5.786038430750073Composing convolutions
The result of convolved is itself a UnivariateDistribution, so it can be a component of another call. Here d (the incubation plus reporting delay from above) is convolved with a further processing delay, and the nested distribution matches the flat three-component one.
total = convolved(d, Exponential(2.0))
mean(total), mean(d3), cdf(total, 8.0), cdf(d3, 8.0)(5.786038430750073, 5.786038430750073, 0.8304533961544223, 0.8304533961544223)Nesting is how a multi-stage delay built in one part of a model is reused in another without unpacking its components. The Convolving distributions tutorial plots a nested convolution against its flat equivalent.
Evaluating cdf or pdf over a vector of points shares one quadrature window solve across the batch, which is much cheaper than mapping the scalar call.
cdf(d, [1.0, 2.5, 5.0, 10.0])4-element Vector{Float64}:
0.001816868167571861
0.21402716540748604
0.8051810060082844
0.9966058316148315Differences
difference is the dual of the sum: the distribution of Z = X - Y for independent X and Y, with support on both sides of zero. Normal - Normal uses the closed form; everything else uses the numeric cross-correlation path.
z = difference(Gamma(3.0, 1.0), LogNormal(0.5, 0.4))
cdf(z, 0.0)0.2683441902952708A symmetric difference is centred on zero:
zs = difference(Normal(1.0, 1.0), Normal(1.0, 1.0))
mean(zs), cdf(zs, 0.0)(0.0, 0.5)Either side can itself be a Convolved, so the signed gap between a multi-stage delay and a single delay is one call. Here cdf(gap, 0.0) is the probability that the two-stage delay d is shorter than the single delay.
gap = difference(d, Gamma(2.5, 1.0))
mean(gap), cdf(gap, 0.0)(1.2860384307500734, 0.25861733505565326)Products
product builds the multiplicative member: the distribution of Z = X * Y for independent X and Y, as when a delay is stretched by an independent multiplicative factor. LogNormal * LogNormal uses the closed form (the log-parameters add); everything else uses the numeric Mellin quadrature. Both components must have non-negative support; sign-crossing supports throw an error and are future work.
w = product(Gamma(3.0, 1.0), LogNormal(0.0, 0.3))
mean(w), cdf(w, 3.0)(3.1380835797261506, 0.5754383333307876)The mean and variance are the exact independent-product moments, and a Convolved (or another combination) can itself be a component, so a multi-stage delay scaled by a factor is one call.
scaled = product(d, LogNormal(0.0, 0.2))
mean(scaled), mean(d)(3.8625214804440207, 3.7860384307500734)Ratios
ratio builds the quotient member: the distribution of Z = X / Y for independent X and Y, as when a rate, a proportion, or a normalised measurement is formed from two independent uncertain quantities. Normal(0, σx) / Normal(0, σy) uses the closed form (a Cauchy); Gamma / Gamma and Chisq / Chisq also have closed forms; everything else uses the numeric branch-split quadrature. Unlike product, either component may have two-sided support — the denominator only needs to avoid probability mass at zero.
r = ratio(Gamma(3.0, 1.0), Gamma(2.0, 1.0))
mean(r), cdf(r, 2.0)(3.0, 0.5925925925925924)The sign-crossing headline pair — a Ratio of two zero-mean Normals — matches its Cauchy closed form exactly, including when the numerator or denominator itself straddles zero.
rc = ratio(Normal(0.0, 2.0), Normal(0.0, 0.5))
cdf(rc, 0.0), cdf(Cauchy(0.0, 4.0), 0.0)(0.5, 0.5)mean/var/std throw unless an analytic pair applies: E[X / Y] = E[X] E[1/Y] needs an inverse moment of the denominator, which this package does not compute and which need not exist.
A Ratio can itself be a component of another combination only when both its numerator and its denominator are non-negative: outside that regime the ratio's tails are Cauchy-like, so nesting it throws rather than silently narrowing the outer window (see the FAQ).
Convolving a timeseries
convolve_series causally convolves a numeric series with a delay PMF on the unit lag grid. If the series holds the expected events at times 0, 1, ..., t (say infections), the result is the expected downstream counts at the same times, the renewal-style observation layer.
A discrete delay is read straight off its own PMF (the lag-k mass is pdf(delay, k)):
t = 0:30
infections = 100 .* exp.(-((t .- 10.0) .^ 2) ./ 40.0)
convolve_series(Poisson(2.0), infections)31-element Vector{Float64}:
1.1108996538242308
4.008141722979866
8.526856383040526
14.494207016175757
22.040612466668186
31.330073874163926
42.26184773370586
54.34073062309471
66.68501861759444
78.13217137485263
⋮
9.919612593388146
6.208280252442309
3.7216254767162114
2.1374312845880192
1.1764298210822512
0.6206879051664294
0.31400439684842807
0.1523624271040959
0.07092950565089116See CensoredDistributions.jl for tools that turn a continuous delay into the masses this takes.
maxlag = length(infections) - 1
pmf = pdf.(NegativeBinomial(5, 0.5), 0:maxlag)
convolve_series(pmf, infections)31-element Vector{Float64}:
0.25651562069968376
1.0537698117111787
2.6240620962620347
5.164368050762789
8.858346206111577
13.866428019808481
20.283325643091214
28.078676924114166
37.04218347085685
46.75433145856008
⋮
33.807455928780485
26.539865161903737
20.325968118376395
15.207455414959044
11.130079097937406
7.979069966780508
5.610250749929932
3.8737836505860237
2.6298948485084055sum(pmf)0.9999982672743509A delay that changes over the window is passed as one delay per time point; the Convolving a timeseries tutorial works through it.
delays = [Poisson(λ) for λ in range(3.0, 1.0; length = length(infections))]
convolve_series(delays, infections)31-element Vector{Float64}:
0.40867714384640674
1.928494010662295
5.048157769762642
9.940060355409067
16.698109411262777
25.44938016082706
36.22138116457638
48.72961625807533
62.242871888035765
75.58954753940728
⋮
9.368099242258317
5.54931436317456
3.1156879489771225
1.6572189336439593
0.8346002454646066
0.3977269735514724
0.17922764642080175
0.07631500112318403
0.03067836891767108Choosing the solver
Both constructors take a method keyword. The default AnalyticalSolver() uses the closed form when one exists and falls back to quadrature; NumericSolver() forces the quadrature path, which is mainly useful for testing and for comparing the two.
da = convolved(Normal(0.0, 1.0), Normal(1.0, 2.0))
dn = convolved(Normal(0.0, 1.0), Normal(1.0, 2.0);
method = NumericSolver())
cdf(da, 2.0), cdf(dn, 2.0)(0.6726395769907114, 0.6726395669907115)Truncation and scoring
Convolved, Difference, Product, and Ratio compose with Distributions.truncated, so right-truncated (or doubly truncated) scoring works out of the box. This is the usual pattern for fitting delay data observed up to a cut-off.
td = truncated(d, 0.0, 8.0)
logpdf(td, 5.0)-1.970917408577644Distributions.censored composes the same way, clamping rather than renormalising: the kept region's density is unchanged and the trimmed tails become point masses at the bounds. This package adds no censoring machinery of its own — that stays CensoredDistributions.jl's job — but the generic wrapper works out of the box because Convolved, Difference, Product, and Ratio implement the standard UnivariateDistribution interface.
cd = censored(d, 0.0, 8.0)
logpdf(cd, 5.0)-1.9898782016345686Quantiles and sampling truncated distributions
There is no closed-form inverse CDF for a generic convolution, so quantile lives in an extension that is loaded when both Optimization.jl and OptimizationOptimJL.jl are present. It finds the quantile by a Nelder-Mead inversion of cdf. Loading it also enables rand on truncated wrappers, which routes through the base quantile.
using Optimization, OptimizationOptimJL
quantile(d, 0.5) # median by inverse-CDF root-find3.5052589047225053length(rand(truncated(d, 0.0, 8.0), 100))100Nothing else on this page needs the extension. rand on a bare Convolved, Difference, Product, or Ratio samples the components directly.
Gradients
The cdf, pdf, and logpdf paths are AD-safe by construction. The quadrature uses fixed nodes, the window clamp is shielded from the tape, and the gamma CDF carries analytic derivative rules (supplied by EpiAwareADTools.jl). Gradients with respect to the component parameters are tested on ForwardDiff, ReverseDiff, Enzyme (forward and reverse), and Mooncake (forward and reverse) on every CI run; the per-backend badges in the README track their status.
Learning more
New to the package? Installation covers installing by URL and the optional quantile extension; for setting up Julia itself, see the EpiAware site.
Common questions (solver choice, the timeseries form, the extension, AD support) are answered in the FAQ.
Want the full interface? See the Public API.
Curious how the numeric layer is put together? The internal quadrature (
integrate,gl_integrate,GaussLegendre) is documented in the Internal API, and an Integrals.jl backend is available as an extension.Want the packages ConvolvedDistributions works alongside? See Related packages on the home page.
Contributing, or adding a new member of the combination family? Start from the developer documentation, the Contributing guide, and Adding a new combination.
Found a bug? Open a GitHub issue.
Have a usage question? Ask on the Julia Discourse or the epinowcast community forum.