Depending on your needs, there are efficient solutions available. The purpose of this answer is to show how you can formulate the problem, encapsulate the key ideas, and implement them using capabilities offered by your programming environment.
Analysis
Given any density function $f$, all highest posterior density sets are of the form $\{x\mid f(x) \ge h\}$ or $\{x\mid f(x) \gt h\}.$ For floating-point computing purposes we needn't distinguish between them, so let's just call either one $f^{[h]}.$ Writing the indicator function as $\mathcal I,$ the total probability of any such set is
$$p_f(h) = \Pr\left(f^{[h]}\right) = \int_{\mathbb{R}} \mathcal{I}(f(x) \gt h) f(x)\mathrm{d}x.$$
Obtaining the $100\alpha\%$ highest probability density set therefore is a matter of solving
$$0 = p_f(h) - \alpha.$$
This reduces the problem to one of root finding, which has well-established solutions and is widely implemented using accurate, efficient methods.
Implementation
Here is a naive R
solution. ("Naive" means it contains no defenses against difficult-to-evaluate pdfs or bad inputs, such as when x.min
and x.max
do not include the entire support of the distribution.) It employs the function ifelse
to implement $\mathcal I,$ integrate
to integrate the pdf, and uniroot
to find a root. Its arguments are the amount of probability alpha
, a density function df
, and the support of df
(smallest and largest possible values).
highest_alpha <- function(alpha, df, x.min, x.max, ...) {
p <- function(h) {
g <- function(x) {y <- df(x); ifelse(y > h, y, 0)}
integrate(g, x.min, x.max, ...)$value - alpha
}
uniroot(p, c(x.min, x.max), tol=1e-12)$root
}
The question supposes the pdf is given in a somewhat awkward form as a sequence of vertices along its graph: its "spaghetti representation." No problem: just interpolate. Although in many cases an interpolation of the log pdf would be best, some care is needed to handle areas where the pdf might equal zero, so in this example I just use linear interpolation as implemented by approxfun
. I do take measures to ensure the pdf is normalized, though, by first integrating it to find a normalizing constant.
as.pdf <- function(x, y, ...) {
f <- approxfun(x, y, method="linear", yleft=0, yright=0, rule=2)
const <- integrate(f, min(x), max(x), ...)$value
approxfun(x, y/const, method="linear", yleft=0, yright=0, rule=2)
}
Its arguments are the x
and y
coordinates, sorted by x
, of the spaghetti representation of the pdf.
These six lines of code will perform asymptotically well, typically requiring $O(n\log(n))$ calculations when the pdf is given by $n$ vertices of its graph.
Examples
As a test, highest_alpha
was applied to a standard Normal pdf as shown in the first figure. The total probability was divided into the highest 1/6, highest 2/6, ... highest 6/6 and the corresponding areas have been colored accordingly. By symmetry, the boundaries must lie on the set of points $\Phi^{-1}(1/12), \Phi^{-1}(2/12), \ldots, \Phi^{-1}(11/12)$ where $\Phi$ is the standard normal CDF, so I have plotted those points as vertical black line segments:
The black dots, of course, show the spaghetti representation of the pdf that was used. The horizontal lines are the values of $h$ found by highest_alpha
.
Let's see a solution for a more complex pdf. This one is a mixture of three Normal distributions.
A close look shows that indeed the interpolation is working: many of the region boundaries fall between the spaghetti points.
For completeness, here is the code used for the first example. The first four lines create the spaghetti representation while the last two do the calculations.
i <- c(exp(-(1:10)), 1 - exp(-(1:10)), seq(0, 1, length.out=101))
x <- sort(qnorm(i))
x <- x[!is.infinite(x)]
y <- dnorm(x)
k <- 6
h <- sapply(1:(k-1) / k, function(h) highest_alpha(h, as.pdf(x, y), min(x), max(x)))
The figure is made with ggplot2
:
library(ggplot2)
n <- 501
X <- data.frame(x=seq(min(x), max(x), length.out=n))
X$y <- as.pdf(x, y)(X$x)
X$Interval <- factor(rowSums(outer(X$y, h, ">")))
dx <- diff(range(x)) / n
ggplot(X, aes(x, y)) +
geom_hline(aes(yintercept=h, color=Density), size=1, show.legend=FALSE,
data=data.frame(h=h, Density=factor(h, labels=signif(h, 2)))) +
geom_vline(xintercept=qnorm(1:(2*k-1)/(2*k))) +
geom_path(color="gray") +
geom_col(aes(fill=Interval, color=NULL), alpha=0.5, width=dx) +
geom_point(data=data.frame(x=x, y=y)) +
scale_fill_manual(values=terrain.colors(k)) +
scale_color_manual(values=terrain.colors(k)) +
theme(panel.grid=element_blank())
I dodged one issue by computing explicitly only the heights corresponding to the highest-density regions. To find those regions, one has to determine where the pdf crosses those heights. Because that's just another root-finding exercise, I won't go into the (redundant) details.
If you really must find an interval (a connected set), then you can formulate the problem as one of minimizing the interval length $\delta$ subject to the highest-probability constraint. That is, given $0 \lt \alpha \lt 1,$ the problem is to find an ordered pair $(x, \delta)$ where $\delta$ is a small as possible subject to the constraints
$\delta \gt 0.$
$x$ and $x+\delta$ are in the domain of the pdf $f.$
$F_\alpha(x,\delta) \ge 0$ where $$F_\alpha(x,\delta) = \int_x^{x+\delta} f(x)\mathrm{d}x - \alpha.$$
This problem is amenable to the same approach: (1) use the capabilities of your programming environment to compute the integral accurately and efficiently and (2) employ an efficient two-dimensional constrained optimization routine. Most of the constraints are linear and the single non-linear constraint is differentiable, implying this approach has a good chance of succeeding and yielding accurate solutions.