Files
GraphonSimulation/R/distributionfunctions.R
T
2026-08-14 18:03:39 +02:00

339 lines
13 KiB
R
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Function for creating functions
source(here::here("R", "qinf.R"))
source(here::here("R", "graphon_distribution.R"))
#' Factory for a Graphon CDF (empirical or analytical)
#'
#' `make_distribution_func()` builds a **callable CDF** that can be used in
#' downstream code (e.g., for simulation, inference, or plotting).
#' The function works in two mutually exclusive modes:
#'
#' * **Empirical mode** the CDF is estimated from observed covariates
#' `X_matrix` and a noise CDF `Fv`. For each evaluation point `y` the
#' expectation \eqn{E[F_v(y - a^\top X_i)]} is approximated by the empirical
#' average over the rows of `X_matrix`.
#' * **Analytical mode** the user supplies a precomputed analytical
#' function `Fa(y)` (e.g., a closedform expression for \eqn{F_a(y) = P( X\cdot a + v \leq y)}.
#' The returned function simply forwards its argument to `Fa`.
#'
#' Exactly **one** of the two mode specifications must be provided; otherwise
#' an informative error is thrown.
#'
#' @param a Numeric vector (or scalar) of coefficients. Required only in the
#' empirical mode. Its length must match the number of columns of
#' `X_matrix`.
#' @param X_matrix Numeric matrix (`n × p`) of observed covariates. Required
#' only in the empirical mode. Each row corresponds to an observation
#' \eqn{X_i}.
#' @param Fv Function. Cumulative distribution function of the noise variable
#' `v` (e.g., `pnorm`, `pexp`). Must be vectorised and is required only in
#' the empirical mode.
#' @param Fa Function. Analytical expression for the graphon CDF
#' \eqn{F_a(y)}. Required only in the analytical mode. Must be vectorised.
#'
#' @return A **function** `cdf(y)` that evaluates the graphon CDF at the
#' supplied numeric vector or scalar `y`. The returned function is fully
#' vectorised and performs input validation on each call.
#'
#' @details
#' * **Empirical mode**
#' The inner products \eqn{a^\top X_i} are precomputed once for efficiency.
#' For a vector of evaluation points `y`, the function builds an
#' \eqn{|y| \times n} matrix of deviations `y - a^\top X_i`, applies the noise
#' CDF `Fv` elementwise, and returns the row means (i.e., the empirical
#' expectation).
#' * **Analytical mode**
#' The supplied `Fa` is assumed to already implement the desired CDF; the
#' wrapper simply validates its input and forwards the call via `vapply`
#' to guarantee a numeric return of the same length as `y`.
#'
#' @examples
#' ## -----------------------------------------------------------------
#' ## Empirical mode ----------------------------------------------------
#' set.seed(42)
#' X <- matrix(rnorm(200), ncol = 2) # 100 observations, p = 2
#' a <- c(1, -0.5)
#' emp_cdf <- make_distribution_func(a = a, X_matrix = X, Fv = pnorm)
#' emp_cdf(seq(-3, 3, length.out = 50))
#'
#' ## Analytical mode --------------------------------------------------
#' ## Example: closedform CDF for Y = a*X + V where X ~ Gamma(2,1),
#' ## V ~ N(0,1) and a = 0.8
#' fX <- function(z) dgamma(z, shape = 2, rate = 1) # pdf of X
#' Fv <- function(x) pnorm(x, mean = 0, sd = 1) # CDF of V
#' a2 <- 0.8
#' ## Analytic CDF (normal convolution)
#' Fa <- function(y) {
#' muY <- a2 * 2 # E[X] = shape/rate = 2
#' sdY <- sqrt(a2^2 * 2 + 1) # Var(Y) = a^2*Var(X) + Var(V)
#' pnorm(y, mean = muY, sd = sdY)
#' }
#' ana_cdf <- make_distribution_func(Fa = Fa)
#' ana_cdf(seq(-3, 6, length.out = 100))
#'
#' @export
make_distribution_func <- function(a=NULL, X_matrix=NULL, Fv=NULL, Fa=NULL){
# ----------------------------------------------------------------
# 1. Switch between empirical and analytical mode
# ---------------------------------------------------------------
analytical_mode <- !is.null(Fa)
empirical_mode <- all(!is.null(X_matrix), !is.null(Fv), !is.null(a))
if(xor(analytical_mode, empirical_mode) == FALSE){
stop("Provide either the arguments for the empiral mode or analytical mode.
See documentation for details.")
}
# ------------------------------------------------------
# Empirical mode
# ------------------------------------------------------
if(empirical_mode) {
if (!is.matrix(X_matrix) || !is.numeric(X_matrix)) {
stop("'X_matrix' must be a numeric matrix.")
}
if (ncol(X_matrix) != length(a)) {
stop("Number of columns in `X_matrix` must equal length of `a`.")
}
if (!is.function(Fv)) {
stop("'Fv' must be a function (a CDF).")
}
# Precompute the inner products aᵀX_i once (vectorised)
inner_prod <- as.vector(X_matrix %*% a) # length n
# Return a function that only needs y
cdf <- function(y) {
if (!is.numeric(y)) {
stop("'y' must be numeric (scalar or vector).")
}
# Compute matrix of y - aᵀX_i
dev_mat <- outer(y, inner_prod, "-")
# Apply the noise CDF and average over rows
rowMeans(Fv(dev_mat))
}
return(cdf)
} else {
# -------------------------------------------------
# Analytical mode
# ------------------------------------------------
# input validation
if (!is.function(Fa)){
stop("`Fa` must be a function!")
}
cdf <- function(y) {
if (!is.numeric(y)) {
stop("'y' must be numeric (scalar or vector).")
}
vapply(y, Fa, numeric(1))
}
return(cdf)
}
}
#' Factory for a Graphon **density** function (empirical or analytical)
#'
#' `make_density_func()` builds a **callable density** that can be used in
#' simulation, likelihood evaluation, or plotting. The function operates in
#' two mutually exclusive modes:
#'
#' * **Empirical mode** the density is estimated from observed covariates
#' `X_matrix` and a noise density `fv`. For each evaluation point `y` the
#' expectation \eqn{E[f_v(y - a^\top X_i)]} is approximated by the empirical
#' average over the rows of `X_matrix`.
#' * **Analytical mode** the user supplies a precomputed analytical density
#' function `fa(y)`. The returned function simply forwards its argument to
#' `fa`.
#'
#' Exactly **one** of the two mode specifications must be provided; otherwise
#' an informative error is raised.
#'
#' @param a Numeric vector (or scalar) of coefficients. Required only in the
#' empirical mode. Its length must match the number of columns of
#' `X_matrix`.
#' @param X_matrix Numeric matrix (`n × p`) of observed covariates. Required
#' only in the empirical mode. Each row corresponds to an observation
#' \eqn{X_i}.
#' @param fv Function. Density of the noise variable `v` (e.g., `dnorm`,
#' `dgamma`). Must be vectorised and is required only in the empirical mode.
#' @param fa Function. Analytical expression for the graphon density
#' \eqn{f_a(y)}. Required only in the analytical mode. Must be vectorised.
#'
#' @return A **function** `dens(y)` that evaluates the graphon density at the
#' supplied numeric vector or scalar `y`. The returned function is fully
#' vectorised and performs input validation on each call.
#'
#' @details
#' * **Empirical mode**
#' The inner products \eqn{a^\top X_i} are precomputed once for efficiency.
#' For a vector of evaluation points `y`, the function builds an
#' \eqn{|y| \times n} matrix of deviations `y - a^\top X_i`, applies the noise
#' density `fv` elementwise, and returns the row means (i.e., the empirical
#' expectation).
#' * **Analytical mode**
#' The supplied `fa` is assumed to already implement the desired density; the
#' wrapper validates its input and forwards the call via `vapply` to guarantee
#' a numeric return of the same length as `y`.
#'
#' @examples
#' ## -----------------------------------------------------------------
#' ## Empirical mode ----------------------------------------------------
#' set.seed(123)
#' X <- matrix(rnorm(200), ncol = 2) # 100 observations, p = 2
#' a <- c(1, -0.5)
#' ## Noise density: standard normal
#' emp_dens <- make_density_func(a = a, X_matrix = X, fv = dnorm)
#' emp_dens(seq(-3, 3, length.out = 50))
#'
#' ## Analytical mode --------------------------------------------------
#' ## Example: closedform density for Y = a*X + V where
#' ## X ~ Gamma(2,1), V ~ N(0,1) and a = 0.8
#' a2 <- 0.8
#' fa <- function(y) {
#' ## Convolution of Gamma and Normal does not have a simple closed form,
#' ## but for illustration we use the normal approximation:
#' muY <- a2 * 2 # E[X] = shape/rate = 2
#' sdY <- sqrt(a2^2 * 2 + 1) # Var(Y) = a^2*Var(X) + Var(V)
#' dnorm(y, mean = muY, sd = sdY)
#' }
#' ana_dens <- make_density_func(fa = fa)
#' ana_dens(seq(-3, 6, length.out = 100))
#'
#' @export
make_density_func <- function(a=NULL, X_matrix=NULL, fv=NULL, fa=NULL){
# ----------------------------------------------------------------
# 1. Switch between empirical and analytical mode
# ---------------------------------------------------------------
analytical_mode <- !is.null(fa)
empirical_mode <- all(!is.null(X_matrix), !is.null(fv), !is.null(a))
if(xor(analytical_mode, empirical_mode) == FALSE){
stop("Provide either the arguments for the empiral mode or analytical mode.
See documentation for details.")
}
# ------------------------------------------------------
# Empirical mode
# ------------------------------------------------------
if(empirical_mode) {
if (!is.matrix(X_matrix) || !is.numeric(X_matrix)) {
stop("'X_matrix' must be a numeric matrix.")
}
if (ncol(X_matrix) != length(a)) {
stop("Number of columns in `X_matrix` must equal length of `a`.")
}
if (!is.function(Fv)) {
stop("'Fv' must be a function (a density).")
}
# Precompute the inner products aᵀX_i once (vectorised)
inner_prod <- as.vector(X_matrix %*% a) # length n
# Return a function that only needs y
cdf <- function(y) {
if (!is.numeric(y)) {
stop("'y' must be numeric (scalar or vector).")
}
# Compute matrix of y - aᵀX_i
dev_mat <- outer(y, inner_prod, "-")
# Apply the noise CDF and average over rows
rowMeans(fv(dev_mat))
}
return(cdf)
} else {
# -------------------------------------------------
# Analytical mode
# ------------------------------------------------
# input validation
if (!is.function(fa)){
stop("`fa` must be a function!")
}
cdf <- function(y) {
if (!is.numeric(y)) {
stop("'y' must be numeric (scalar or vector).")
}
vapply(y, fa, numeric(1))
}
return(cdf)
}
}
#' Factory for a Quantile Function from a Cumulative Distribution Function
#'
#' `make_quantile_function()` builds a **quantile (inverse CDF) function** by
#' wrapping the rootfinding routine `qinf()`. The returned function accepts a
#' probability (or a vector of probabilities) `p` and returns the corresponding
#' quantile(s) of the distribution defined by the supplied CDF `cdf`.
#'
#' @param cdf Function. A cumulative distribution function that takes a numeric
#' argument `x` and returns \eqn{F(x) \in [0,1]}. The function must be
#' **monotone nondecreasing** and vectorised.
#' @param lower Numeric. Lower bound of the search interval for the rootfinding
#' algorithm. Defaults to $-\infty$ (practically the leftmost support of
#' the distribution).
#' @param upper Numeric. Upper bound of the search interval. Defaults to
#' $\infty$ (rightmost support).
#' @param tol Numeric. Relative tolerance passed to `qinf()`. The default is
#' `sqrt(.Machine$double.eps)`, which provides doubleprecision accuracy.
#' @param max.iter Integer. Maximum number of iterations allowed in `qinf()`.
#' The default (`100`) is generous for most smooth CDFs.
#'
#' @return A **function** `q(p)` that takes a numeric probability (or vector of
#' probabilities) `p` with values in $[0,1]$ and returns the corresponding
#' quantile(s). The output is named with the character representation of the
#' input probabilities.
#'
#' @details
#' The heavy lifting is performed by `qinf()`, which solves for the root of
#' \eqn{F(x) - p = 0}` using a bracketing method (e.g., bisection or Brents
#' algorithm). `make_quantile_function()` simply captures the userspecified
#' arguments and returns a thin wrapper that forwards them to `qinf()`. This
#' design makes it easy to generate quantile functions for custom CDFs without
#' having to rewrite the rootfinding code each time.
#'
#' @examples
#' ## -----------------------------------------------------------------
#' ## Normal distribution (using the builtin CDF)
#' qnorm_custom <- make_quantile_function(cdf = pnorm,
#' lower = -10, upper = 10)
#' qnorm_custom(c(0.025, 0.5, 0.975))
#'
#' ## Gamma distribution custom CDF defined on the fly
#' my_gamma_cdf <- function(x) pgamma(x, shape = 2, rate = 1)
#' qgamma_custom <- make_quantile_function(cdf = my_gamma_cdf,
#' lower = 0, upper = 20)
#' qgamma_custom(c(0.1, 0.5, 0.9))
#'
#' ## Verify against the base quantile function
#' all.equal(qnorm_custom(0.8), qnorm(0.8))
#'
#' @importFrom stats pnorm pgamma
#' @export
make_quantile_function <- function(cdf, lower = -Inf, upper = Inf,
tol = .Machine$double.eps^0.5,
max.iter = 100) {
quantile_function <- function(p) {
out <- qinf(F = cdf,
p = p,
lower = lower,
upper = upper,
tol = tol,
max.iter = max.iter)
names(out) <- as.character(p)
out
}
return(quantile_function)
}