Frequently asked questions
This page answers common questions about ConvolvedDistributions.jl. If your question is not answered here, ask on the Julia Discourse or the epinowcast community forum.
How do I build a convolved, difference, product, or ratio distribution?
Each constructor takes distributions and returns a distribution:
using ConvolvedDistributions, Distributions
d = convolved(Gamma(2.0, 1.0), LogNormal(0.5, 0.4))
z = difference(Gamma(3.0, 1.0), LogNormal(0.5, 0.4))
w = product(Gamma(3.0, 1.0), LogNormal(0.5, 0.4))
r = ratio(Gamma(3.0, 1.0), LogNormal(0.5, 0.4))
cdf(d, 5.0), cdf(z, 0.0), cdf(w, 5.0), cdf(r, 5.0)(0.8051810060082844, 0.2683441902952708, 0.5794401950483765, 0.9564615242707696)convolved accepts two or more components as varargs, a tuple, or a vector. product requires both components to have non-negative support (sign-crossing supports are future work). ratio requires only that the denominator carry no probability mass at zero; either component may otherwise be two-sided.
Can I nest combinations?
Yes. The results are UnivariateDistributions, so the combinations nest across sums, differences, and products: a Convolved can itself be a component of another convolved call, one side of a difference, or (with non-negative support) a factor in a product, and a nested convolution evaluates the same integral as its flat equivalent:
nested = convolved(d, Exponential(2.0))
flat = convolved(Gamma(2.0, 1.0), LogNormal(0.5, 0.4), Exponential(2.0))
gap = difference(d, Gamma(2.5, 1.0))
mean(nested) ≈ mean(flat), cdf(nested, 8.0) ≈ cdf(flat, 8.0), mean(gap)(true, true, 1.2860384307500734)A Ratio nests too, but only when both its numerator and its denominator are non-negative (this includes the Gamma/Gamma and Chisq/Chisq analytic pairs, and any non-negative numeric pair, but not the two-sided Normal/Normal pair): a ratio that can run negative on either side has Cauchy-like tails with no cheap effective-support bound, so nesting one throws rather than silently narrowing the outer window.
The Getting started walkthrough and the Convolving distributions, The difference of two delays, and The product of two delays tutorials show nesting in more detail.
Why is the package called ConvolvedDistributions when it also has difference and product?
Every member is a convolution in the generalised sense: the distribution of X op Y for independent variables is the classical convolution for sums, the reflected convolution for differences, the Mellin convolution for products, and the corresponding generalised convolution for other operations (maxima). The family supertype is AbstractConvolvedDistribution to match.
When should I use convolved rather than Distributions.convolve?
Distributions.convolve only handles pairs with a closed-form result (for example Normal + Normal, or two Gammas with equal scale) and errors on anything else. convolved works for any pair of univariate distributions: it delegates to the closed form when one exists and falls back to AD-safe numeric quadrature otherwise. It also takes more than two components, keeps exact analytic moments, and stays differentiable with respect to the component parameters. If you only ever combine an analytic pair and want the closed-form type back directly, Distributions.convolve is fine; for anything else use convolved.
Why does quantile need the Optimization extension?
There is no closed-form inverse CDF for a generic convolution or difference, so the quantile is found by numerically inverting cdf with a Nelder-Mead solve. That solver stack is a deliberate weak dependency: cdf, pdf, logpdf, moments, sampling, and truncated scoring never need it, so the core package stays dependency-light. Load both trigger packages to activate it:
using Optimization, OptimizationOptimJL
quantile(d, 0.5)Loading the extension also enables rand on truncated wrappers of Convolved, Difference, Product, and Ratio, which routes through the base quantile. rand on a bare Convolved, Difference, Product, or Ratio samples the components directly and needs no extension.
What is the difference between AnalyticalSolver and NumericSolver?
Both constructors take a method keyword. The default AnalyticalSolver() uses the closed form when Distributions.convolve applies to every component pair (for difference, when both components are Normal; for product, when both are LogNormal; for ratio, when both are zero-mean Normal, both Gamma, or both Chisq) and falls back to Gauss-Legendre quadrature otherwise. NumericSolver() forces the quadrature path even when a closed form exists. Results agree to quadrature accuracy, so forcing the numeric path is mainly useful for testing, debugging, and 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)Both constructors also accept a custom solver payload that the numeric path honours: a NumericSolver(GaussLegendre(; n = 256)) (or AnalyticalSolver(…)) raises the nodal accuracy of the quadrature, and — once Integrals.jl is loaded — an Integrals.jl algorithm such as NumericSolver(Integrals.QuadGKJL()) routes the integration window through IntegralProblem/solve. The same holds for difference, product, and ratio, each of which accepts the same method payload.
Do batched and scalar log densities agree?
Yes, to machine precision. Evaluating cdf, pdf, or logpdf over a vector integrates every point over the same window the scalar path picks, on a composite quadrature grid whose panel nodes and integration-component density are shared across the batch (plus small per-point end corrections), which is what makes the batched path cheap. Since the quantile-panelled quadrature landed (issue #49) the widest measured batched-vs-scalar logpdf gap is below 4e-15, for batches spanning 16-fold up to a thousand-fold point ranges and including heavy-tailed integration components. Earlier versions shared one quadrature window across the whole batch and could drift from the scalar path by around 2e-3 in the tails of wide batches; the per-point-window construction (issue #29) cut that to below 1e-8 (around 2e-5 at extreme spans), and the quantile panels removed the remaining span dependence.
How does the timeseries form differ from the distribution form?
They do different jobs and have separate names. The distribution form, convolved(dists...), combines distributions into a single Convolved distribution (the sum of independent delays). The timeseries form, convolve_series(delay, series) with series a numeric vector, convolves the series with a delay PMF on the unit lag grid. With series 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. The separate verb reflects the different return type: convolved always returns a distribution, convolve_series always returns a numeric series.
For a discrete delay the lag-k mass is just pdf(delay, k), so the distribution is read straight off its own PMF:
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.07092950565089116A Convolved/Difference/Product of integer-lattice discrete components is itself discrete (its value support is derived from its components, not hardcoded) and convolves directly the same way, reading its exact masses off the lattice fold:
discrete_total = convolved(NegativeBinomial(5, 0.5), Poisson(2.0))
convolve_series(discrete_total, infections)31-element Vector{Float64}:
0.034715614182007214
0.21204346429813886
0.7097838872603454
1.7406895443885182
3.5202389833446093
6.249989545383303
10.105739970751149
15.21603788842756
21.62646042368694
29.256747392348768
⋮
50.510303866315724
42.31167087296075
34.53210274306308
27.488876626625792
21.36761656272206
16.23742461412811
12.076386156771687
8.800540150758682
6.29102951841063A continuous delay has no mass on the integer grid until it is discretised, so it matches no convolve_series method here — a MethodError, not a design gate. For a continuous delay, use CensoredDistributions.jl to build the PMF (it owns primary and interval censoring), then convolve the resulting PMF with convolve_series. convolve_series(pmf, series) takes any already-discretised PMF vector and only convolves, with the masses used exactly as given:
maxlag = length(infections) - 1
masses = pdf.(NegativeBinomial(5, 0.5), 0:maxlag)
convolve_series(masses, 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(masses)0.9999982672743509A plain vector always reads as the unit grid; a DiscreteNonParametric carries its own, coarser one.
Can the delay change over the series?
Yes: pass one delay per time point — a vector of delays, a matrix of masses with lags down columns, or a ragged vector of mass vectors — or, when the delay changes less often than the series, delay => run length pairs. Name which time the delay belongs to with indexed_by, :primary (the default) or :secondary. convolve_series documents both readings.
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.03067836891767108The elements can be of any, and of mixed, types: each delay's masses come from its own single-delay method, via ConvolvedDistributions.delay_masses.
Can I use this with automatic differentiation?
Yes. The cdf, pdf, and logpdf paths are AD-safe by construction: the quadrature uses fixed nodes, the integration window is shielded from the tape, and the gamma CDF carries analytic derivative rules (supplied by EpiAwareADTools.jl, which also hosts the cdf_ad_safe hook family wrapper packages extend). 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. Note that quantile (via the Optimization extension) is a numeric root-find and is not intended to sit on an AD path.
How does this relate to CensoredDistributions.jl and ComposedDistributions.jl?
ConvolvedDistributions.jl is the raw-distribution convolution layer of the EpiAware distribution-operations stack, ported from the corresponding machinery in CensoredDistributions.jl. It operates on plain Distributions.jl univariate distributions and knows nothing about censoring; CensoredDistributions.jl owns primary and interval censoring. ComposedDistributions.jl composes these operations into chains and re-exports this package's public surface, so in a composed model you usually reach the convolution through it rather than directly.
How can I run the tutorial notebooks?
The tutorials (for example docs/src/getting-started/tutorials/convolving-distributions.md) are built from Literate.jl scripts. You have two options:
Option 1: Copy and paste (easiest)
Copy code blocks from the online tutorials
Paste into your Julia REPL or script
Option 2: Run the tutorial scripts directly 2. Clone the repository: git clone https://github.com/EpiAware/ConvolvedDistributions.jl.git
Start Julia in the docs environment:
julia --project=docsincludethe tutorial's.jlsource fromdocs/src/getting-started/tutorials/
The tutorial .jl files are plain Julia scripts that can be run top-to-bottom in the REPL.
I get "Package not found" errors
Make sure you are in the right environment; the registered release installs with Pkg.add("ConvolvedDistributions"), and the development version by URL:
using Pkg
Pkg.activate(".") # Activate current directory
Pkg.instantiate() # Install dependencies
Pkg.add(url = "https://github.com/EpiAware/ConvolvedDistributions.jl")How do I cite ConvolvedDistributions?
See the citation section of the README.
I want to contribute to development
See the Contributing guide and Developer FAQ for development-specific questions and guidelines.
Getting help
Still have questions?
Usage questions: the Julia Discourse (the SciML or usage categories) or the epinowcast community forum, our home for epidemiological modelling questions
Bug reports and feature requests: GitHub Issues