Guide to adding a distribution
adding-a-distribution.RmdA new distribution is one R file, one test file, and one line in the distribution list. The complete example below is meant to be copied; the sections after it cover the cases it does not.
Calling other distributions
RTMB records a computation tape by operator overloading,
so a density has to be ordinary R arithmetic on whatever it is
handed.
That is less restrictive than it sounds. RTMB promotes
around 37 stats functions (dnorm,
dgamma, pbeta, lgamma,
besselK and so on) to S4 generics with a method for AD
variables, so you can just write the bare name. With
@import RTMB on your function,
dgamma(x, shape) dispatches on its arguments: ordinary
numbers get the usual stats behaviour, AD variables get the
tape-aware method.
logdens <- dgamma(x, shape = shape, scale = scale, log = TRUE) # yes
logdens <- stats::dgamma(x, shape, scale, log = TRUE) # no - breaks taping
logdens <- RTMB::dgamma(x, shape, scale, log = TRUE) # unnecessarystats:: pins the non-AD version and the tape breaks.
RTMB:: works, but it is noise and it hides the
dispatch.
For a function RTMB does not cover, see When RTMB has no AD
version.
A complete example
This is the whole of R/foo.R, for a one-parameter
distribution with density on .
#' Foo distribution
#'
#' Density, distribution function, quantile function, and random generation
#' for the Foo distribution.
#'
#' @details
#' The Foo distribution with rate \eqn{\theta > 0} has density
#' \deqn{f(x;\,\theta) = \theta e^{-\theta x}, \quad x \ge 0.}
#'
#' @param x,q vector of quantiles
#' @param p vector of probabilities
#' @param n number of random values to return
#' @param theta positive rate parameter
#' @param log,log.p logical; if \code{TRUE}, probabilities/ densities are returned as \eqn{\log(p)}.
#' @param lower.tail logical; if \code{TRUE} (default), probabilities are \eqn{P[X \le x]}, otherwise \eqn{P[X > x]}.
#'
#' @return
#' \code{dfoo} gives the density, \code{pfoo} gives the distribution function,
#' \code{qfoo} gives the quantile function, and \code{rfoo} generates random deviates.
#'
#' @examples
#' set.seed(123)
#' x <- rfoo(5, theta = 2)
#' d <- dfoo(x, theta = 2)
#' p <- pfoo(x, theta = 2)
#' q <- qfoo(p, theta = 2)
#' @name foo
NULL
#' @rdname foo
#' @export
#' @import RTMB
dfoo <- function(x, theta, log = FALSE) {
if (!ad_context()) {
args <- as.list(environment())
simulation_check(args) # informative error if the likelihood is written in the wrong order
if (any(theta <= 0)) stop("theta must be > 0")
}
# potentially escape to RNG or CDF
if (inherits(x, "simref")) return(dGenericSim("dfoo", x = x, theta = theta, log = log))
if (inherits(x, "osa")) return(dGenericOSA("dfoo", x = x, theta = theta, log = log))
logdens <- log(theta) - theta * x + log(1 - smaller(x, 0)) # 0 outside x >= 0
if (log) return(logdens)
return(exp(logdens))
}
#' @rdname foo
#' @export
pfoo <- function(q, theta, lower.tail = TRUE, log.p = FALSE) {
if (!ad_context()) {
if (any(theta <= 0)) stop("theta must be > 0")
}
p <- (1 - smaller(q, 0)) * (-expm1(-theta * q))
if (!lower.tail) p <- 1 - p
if (log.p) p <- log(p)
return(p)
}
#' @rdname foo
#' @export
qfoo <- function(p, theta, lower.tail = TRUE, log.p = FALSE) {
if (!ad_context()) {
if (any(theta <= 0)) stop("theta must be > 0")
}
if (log.p) p <- exp(p) # undo the transformations first ...
if (!lower.tail) p <- 1 - p # ... in the reverse order to pfoo
if (!ad_context()) {
if (any(p < 0 | p > 1)) stop("p must be in [0, 1]")
}
-log1p(-p) / theta
}
#' @rdname foo
#' @export
#' @importFrom stats runif
rfoo <- function(n, theta) {
if (any(theta <= 0)) stop("theta must be > 0")
n <- ceiling(n)
qfoo(runif(n), theta)
}Five details in there are easy to get wrong:
- Validation and
simulation_checkbelong insideif (!ad_context()). On AD variables they either fail or give the wrong answer. - The string passed to
dGenericSim/dGenericOSAhas to match the function name exactly.RTMBuses it to find yourpandrfunctions. - Work in log space and call
exp()once, at the end. - Check
pafter undoinglog.pandlower.tail, not before. Checking first makeslog.p = TRUEimpossible, sincelog(p)is negative. - Match the support indicator to the boundary. Foo has support , so
1 - smaller(x, 0)is right.greater(x, 0)is 0 atx = 0and would give a density of 0 there instead of .
Then the test file, and one bullet in
vignettes/distlist.Rmd:
* [`foo(theta)`](../reference/foo.html): Foo distribution parameterised by rate `theta`
Tests
tests/testthat/test-foo.R. Two parameter settings plus a
gradient check is the minimum.
test_that("foo passes standard distribution checks (theta = 1)", {
check_continuous_dist(dfoo, pfoo, qfoo, xs = c(0.2, 0.5, 1, 2),
lower = 0, upper = Inf, theta = 1)
})
test_that("foo AD gradient has no NaN", {
check_ad_gradient(dfoo, rfoo, theta = 1)
})| Your distribution is | Use |
|---|---|
| continuous | check_continuous_dist(dfun, pfun, qfun, xs, lower, upper, ...) |
| continuous, no quantile function | same, with qfun = NULL
|
| discrete | check_discrete_dist(dfun, pfun, xs_int, sum_support, ...) |
| discrete, no cdf | same, with pfun = NULL
|
| zero-inflated continuous | check_zeroinfl_dist |
| inflated at 0 and/or 1 | check_inflated_dist |
Choose xs so pfoo(xs) stays away from 0 and
1 — the q(p(x)) round-trip cannot recover x
once the cdf saturates in double precision.
Numerical stability
| Instead of | Write |
|---|---|
exp(x) - 1 |
expm1(x) |
log(1 + x) |
log1p(x) |
1 - exp(-x) |
-expm1(-x) |
x^2, x^3
|
x * x, x * x * x
|
x^a, non-integer a
|
exp(a * log(x)) |
The one to watch for is 0 * (-Inf). That is
NaN, and it takes out the whole gradient rather than the
single element you were multiplying. It shows up when an indicator
multiplies a log that is -Inf outside the support. Either
put the indicator inside the log,
logdens + log(greater(x, 0)) # rather than greater(x, 0) * logdensor clamp with as.finite(), or offset the argument with
log(x + .Machine$double.xmin).
Branching without breaking the tape
if/else and ifelse() on a
taped value do not work: the branch is never recorded. Use the
arithmetic indicators, which carry gradients.
| Helper | Returns 1 when |
|---|---|
iszero(x) |
x == 0 |
isnonzero(x) |
x != 0 |
ispos(x), ispos_strict(x)
|
x > 0 (note: 0 at
x = 0) |
isneg(x) |
x < 0 |
greater(x, val) |
x > val |
smaller(x, val) |
x < val |
pmax.ad() and pmin.ad() are the tape-safe
pmax/pmin.
Outside a tape, if (any(...)) stop(...) is fine. That is
what the !ad_context() guard is for.
One more thing about ifelse(), even where it is
legitimate: its result takes the length of the test. If the
test is a parameter shorter than x, the answer is silently
truncated. Recycle everything to a common length first.
Zero-modified distributions
RTMBdist ships four families built on a base
distribution. The prefixes are zi (zero-inflated),
zt (zero-truncated) and h (hurdle, also called
zero-altered).
Write for the probability of a zero under the base
distribution and for the zeroprob parameter.
| Family | At x = 0
|
At x > 0
|
Helper |
|---|---|---|---|
| zero-inflated, discrete | log_zi_discrete(x, logdens, zeroprob) |
||
| zero-inflated, continuous | log_zi(x, logdens, zeroprob) |
||
| zero-truncated | 0 | write it out, see below | |
| hurdle | log_hurdle(x, logdens, p0, zeroprob) |
Zero-inflation and a hurdle only differ for discrete distributions.
Inflation adds zeros on top of the ones the base already produces, so
zeroprob is not the probability of a zero; a hurdle
replaces them, so it is. For a continuous base the two are the same
thing, because the continuous part puts no mass at zero. That is why
gamlss.dist calls those zero-adjusted rather than
zero-inflated, and why there is no h version of a
continuous distribution here.
Zero-truncated, using the Poisson as the example:
dztfoo <- function(x, theta, log = FALSE) {
# ... guards and escapes as usual ...
logdens <- dfoo(x, theta, log = TRUE) -
log1p(-p0) + # p0 = P(X = 0) under the base distribution
log(ispos_strict(x)) # -Inf below the support, so the density is 0 there
if (log) return(logdens)
return(exp(logdens))
}Hurdle, which reuses the same p0:
dhfoo <- function(x, theta, zeroprob = 0.5, log = FALSE) {
# ... guards and escapes as usual ...
logdens <- log_hurdle(x, dfoo(x, theta, log = TRUE), p0, zeroprob)
if (log) return(logdens)
return(exp(logdens))
}log_hurdle() combines the two branches with
logspace_add(), so either can be exactly zero,
zeroprob = 0 and zeroprob = 1 included,
without producing NaN. The cdf is
greater(q, -1) * (zeroprob + (1 - zeroprob) * pztfoo(q, theta)).
The RNG draws a uniform, keeps the zeros, and fills the rest from
rztfoo.
Reparameterised variants
Transform the parameters at the top and delegate. The 2
suffix means a reparameterisation, usually in terms of the mean.
dfoo2 <- function(x, mean, sd, log = FALSE) {
if (!ad_context()) { ... }
if (inherits(x, "simref")) return(dGenericSim("dfoo2", ...))
if (inherits(x, "osa")) return(dGenericOSA("dfoo2", ...))
shape <- mean * mean / (sd * sd)
scale <- sd * sd / mean
dfoo(x, shape = shape, scale = scale, log = log)
}Keep the simulation_check and the simref/osa escapes
even though the base function has them. The escapes need to report
dfoo2, not dfoo.
Distributions to read
The example above is deliberately simple. When you hit something it does not cover, the shortest route is usually to open a distribution that already solves the same problem.
| If you are writing | Read |
|---|---|
| a plain continuous distribution, closed forms throughout |
R/gompertz.R, R/llogis.R
|
| a discrete distribution |
R/ztpois.R, R/zipois.R
|
| a reparameterisation of an existing one |
R/nbinom2.R, R/beta2.R
|
| zero-inflated, zero-truncated or hurdle |
R/zipois.R, R/ztpois.R,
R/hpois.R
|
| something with an awkward support boundary |
R/pareto.R (), R/zibeta.R (excluding
) |
| a term that has to be clamped to stay finite | R/kumar.R |
| a quantile function that needs root finding | R/exgauss.R |
| a density that needs branching on a parameter |
R/bccg.R, R/bcpe.R
|
an AD version of a stats function |
R/ad-dispatch.R, R/geom-ad.R
|
Before you are done
-
devtools::document(), soNAMESPACEand theman/pages are regenerated. -
devtools::test()for the new file, then the whole suite. A new distribution that breaks an old test usually means a shared helper changed behaviour. -
devtools::check(). Watch in particular forchecking Rd cross-references, which catches a@seealsopointing at a topic that does not exist. - Check the
NAMESPACEdiff. If it contains anything beyond your own exports, something imported more than you meant it to.
When RTMB has no AD version
RTMB covers most of stats but not all of
it. dgeom, pt and plnorm have no
AD method. Exporting your own version of one would mask the
stats function for everyone who loads RTMBdist
and break their ordinary calls.
R/ad-dispatch.R avoids that. Write the AD implementation
as <fn>.ad, export it, and register an unexported S4
generic:
setGeneric("pt", signature = c("q", "df"))
setMethod("pt", signature(q = "num", df = "num."), stats::pt) # plain numbers
setMethod("pt", signature(q = "ad", df = "ad." ), # any AD variable
function(q, df, ncp, lower.tail = TRUE, log.p = FALSE) { ... pt.ad(q, df) ... })num and ad are RTMB’s dispatch
classes: ad covers AD variables and numbers,
num covers only numbers, so the more specific
num method wins whenever nothing is being taped. A trailing
dot (num., ad.) also allows the argument to be
missing.
Since the generic is not exported, a user’s stats::pt()
is untouched, while inside the package a bare pt()
dispatches correctly. It costs one S4 dispatch, under a microsecond,
paid once when the tape is built rather than on every gradient
evaluation.
One import to avoid: never write @importFrom stats pnorm
to get hold of a pnorm. That replaces RTMB’s
AD-aware pnorm across the entire package and silently
breaks taping in every file. Call stats::pnorm() at the
point of use instead.