Nothing
# ============================================================================
# Exact gradient of the joint-field Laplace log-marginal with respect to the
# random-effect covariance coordinates.
#
# The outer objective the EB and nested paths optimize is
#
# m(theta) = l(x_hat) - 0.5 (x_hat - mu)' P(theta) (x_hat - mu)
# + 0.5 log|P| - 0.5 log|H|, H = A' W A + P
#
# with the mode x_hat(theta) and the weights W both moving with theta. The
# stationarity of the inner solve kills the mode's contribution everywhere
# except log|H|, which depends on x_hat through W. Collecting the surviving
# terms (dev_notes/laplace_exact_gradient.md) gives, per RE block,
#
# dm/dtheta_j = 0.5 tr( dSigma/dtheta_j .
# [ Omega (R + V - C) Omega - G Omega ] )
#
# the Fisher identity evaluated at the second moment R + V - C:
#
# R = sum_g b_g b_g' mode outer products
# V = sum_g [H^-1]_gg posterior covariance of the same coordinates
# C = sym( sum_g b_g u_g' ) the term that carries dW/dtheta,
# u = H^-1 A' (dw/deta * s),
# s_i = (A H^-1 A')_ii
#
# R + V is the Laplace posterior second moment. -C is the piece the Fisher
# identity alone omits, and it is not small: dropping it leaves the gradient
# wrong by 9 to 58 percent on the cases in dev_notes/proto_exact_gradient.R.
# It vanishes exactly when the curvature is constant in eta (gaussian, and any
# family whose working weight does not move with the linear predictor), which
# is why a Gaussian response never needed it.
#
# `dw/deta` is the eta-derivative of the weight the Newton system ACTUALLY uses
# (laplace_family_curvature.h), not the third derivative of the log density --
# for the families whose H carries an expected/working weight those differ, and
# it is the former that makes this the gradient of the objective being
# optimized.
# ============================================================================
# Symmetric joint posterior precision from the lower-triangle CSC the kernel
# emits under `return_joint_hessian`. Returns a dsCMatrix and drops the raw
# triplet so callers see one object rather than four parallel vectors.
.laplace_joint_hessian <- function(result) {
need <- c("H_joint_p", "H_joint_i", "H_joint_x", "H_joint_n")
if (!all(need %in% names(result))) return(NULL)
n <- as.integer(result$H_joint_n)
if (length(n) != 1L || is.na(n) || n < 1L) return(NULL)
# stype = -1: only the lower triangle is stored, so declare the symmetry
# rather than materializing the mirror image.
Matrix::sparseMatrix(
i = as.integer(result$H_joint_i) + 1L,
p = as.integer(result$H_joint_p),
x = as.numeric(result$H_joint_x),
dims = c(n, n),
symmetric = TRUE,
index1 = TRUE
)
}
# Combined random-effect design in the latent column order the kernel uses:
# terms in `re_list` order, and within a term [g1_c1, g1_c2, g2_c1, ...].
# Extracted from the marginal-H_beta Schur block in tulpa_laplace(), which
# needs the same matrix -- one construction, so the two cannot drift.
#
# `idx = 0` is "no group" (the kernel skips the row, `src/re_structure.h`), so
# such a row carries no RE column here either; indexing column 0 was a
# sparseMatrix error on the Hessian path only (gcol33/tulpa#883).
.re_design_matrix <- function(re_list, n_obs) {
if (length(re_list) == 0L) return(NULL)
parts <- lapply(re_list, function(r) {
nc <- r$n_coefs %||% 1L
idx <- as.integer(r$idx)
in_grp <- which(idx > 0L)
Z_full <- r$Z %||% matrix(1, nrow = n_obs, ncol = 1)
Z_full <- as.matrix(Z_full)[in_grp, , drop = FALSE]
ii <- rep(in_grp, each = nc)
jj <- rep((idx[in_grp] - 1L) * nc, each = nc) +
rep(seq_len(nc), length(in_grp))
Matrix::sparseMatrix(
i = ii, j = jj, x = as.numeric(t(Z_full)),
dims = c(n_obs, r$n_groups * nc)
)
})
do.call(cbind, parts)
}
# The count predictor's design in the latent column order the kernel builds,
# [X | 0 | Z]. A zero-inflation process appends its coefficients to the fixed
# block -- laplace_core.cpp lays the mode out as [beta | beta_zi | RE] -- but
# they move a SECOND linear predictor, so their columns are zero in the count
# predictor's image. The random effects enter the count predictor only, which is
# what `data.sharing.re[1] = false` sets on the kernel side.
.laplace_count_design <- function(X, X_zi, re_list, n_obs) {
parts <- list(Matrix::Matrix(X, sparse = TRUE))
if (!is.null(X_zi)) {
parts[[length(parts) + 1L]] <-
Matrix::Matrix(0, nrow = n_obs, ncol = ncol(X_zi), sparse = TRUE)
}
Z <- .re_design_matrix(re_list, n_obs)
if (!is.null(Z)) parts[[length(parts) + 1L]] <- Z
do.call(cbind, parts)
}
# W_obs - w per observation: what the kernel's working-weight curvature is
# missing to be the observed one. Identically zero for every family whose Newton
# weight already IS the observed curvature, and non-zero only for
# neg_binomial_1 and truncated_neg_binomial_2.
#
# With a zero-inflation process it applies at y != 0 only. The mixture is
# additively separable there, so its count block is the base family's working
# weight (builtin_family_zi.h:136); the y = 0 branch already differentiates the
# density itself through obs_grad_hess_for_family (:168), so correcting it again
# would double-count. Returns NULL rather than a non-finite vector.
.laplace_obs_delta <- function(y, n_trials, eta, family, phi, phi2 = NULL,
weights = NULL, has_zi = FALSE) {
delta <- tryCatch(
cpp_family_obs_curvature_delta_vec(
as.numeric(y), as.integer(n_trials), as.numeric(eta),
family, phi, phi2 %||% NA_real_),
error = function(e) NULL)
if (is.null(delta)) return(NULL)
if (isTRUE(has_zi)) delta[y == 0] <- 0
if (!is.null(weights)) delta <- delta * as.numeric(weights)
if (any(!is.finite(delta))) return(NULL)
delta
}
# d(W_obs - w)/dphi, the DISPERSION derivative of the same correction.
#
# The phi border differentiates quantities formed on the true-curvature inverse
# in the log-phi direction, so it needs dH_true/dpsi, which carries this
# alongside the eta-derivative below: the correction moves with phi both through
# the mode (the eta path) and explicitly. Zero wherever the working weight is
# already the observed curvature; otherwise the family owes `dobs_weight` and
# the difference is formed against its own registered `dweight`, so the two
# halves cannot describe different weights.
#
# Returned UNWEIGHTED, matching .laplace_phi_fields: the observation weights are
# applied at each point of use, alongside the `phi * (wt * ...)` the log-phi
# coordinate already carries there.
.laplace_obs_delta_dphi <- function(y, n_trials, eta, family, phi, phi2 = NULL,
dphi, dphi2, has_zi = FALSE) {
if (!.family_dphi2_needs_obs(family)) return(rep(0, length(eta)))
if (is.null(dphi2) || !is.function(dphi2$dobs_weight)) return(NULL)
p2 <- if (is.null(phi2) || (length(phi2) == 1L && is.na(phi2))) NULL else phi2
dd <- rep_len(dphi2$dobs_weight(eta, y, n_trials, phi, p2), length(eta)) -
rep_len(dphi$dweight(eta, y, n_trials, phi, p2), length(eta))
if (isTRUE(has_zi)) dd[y == 0] <- 0
if (any(!is.finite(dd))) return(NULL)
dd
}
# d(W_obs - w)/deta, the eta-derivative of the correction above.
#
# The closed Hessian differentiates u, which is formed on the TRUE-curvature
# inverse; that needs dH_true/dtheta = dH/dtheta + A' diag(this * eta_dot) A.
# Masked and weighted exactly like the delta it differentiates, so the pair
# cannot drift. Returns NULL where the derivative is not registered, which sends
# the Hessian back to the stencil rather than pairing two different inverses.
.laplace_obs_delta_deta <- function(y, n_trials, eta, family, phi, phi2 = NULL,
weights = NULL, has_zi = FALSE) {
if (!isTRUE(tryCatch(cpp_family_has_obs_curvature_delta_derivative(family),
error = function(e) FALSE))) return(NULL)
dd <- tryCatch(
cpp_family_obs_curvature_delta_deta_vec(
as.numeric(y), as.integer(n_trials), as.numeric(eta),
family, phi, phi2 %||% NA_real_),
error = function(e) NULL)
if (is.null(dd)) return(NULL)
if (isTRUE(has_zi)) dd[y == 0] <- 0
if (!is.null(weights)) dd <- dd * as.numeric(weights)
if (any(!is.finite(dd))) return(NULL)
dd
}
# Per-observation dispersion derivatives of the likelihood the objective uses,
# in ONE table whether or not a zero-inflation process is present.
#
# Two disjoint sources, summed over disjoint rows:
#
# * the base family's registry (.FAMILY_DPHI / .FAMILY_DPHI2) covers every row
# the model leaves additively separable in the two predictors -- all rows
# without zero inflation, and the y != 0 rows with it;
# * the coupled y = 0 branch of a GENUINE mixture comes from the C++ engine
# (laplace_family_zi_phi.h), which returns zero everywhere the registry
# already applies, including a hurdle's y = 0 rows (phi-free there).
#
# `zmask` is what keeps the two from overlapping, and it is the only place the
# hurdle / genuine-ZI distinction is expressed: a hurdle's y = 0 rows are zero on
# BOTH sides, which is the statement that its zero branch carries no dispersion.
#
# The column set is the engine's, so a one-process fit simply leaves the six
# z-direction columns at zero and every assembly downstream reads one shape.
# Values are unweighted, as the registry returns them; `weights` are applied at
# the point of use, matching how the curvature channel is handled.
.laplace_phi_fields <- function(cq, y, n_trials, family, phi, phi2,
dphi, dphi2 = NULL) {
eta <- cq$eta
cols <- c("dl_dp", "dsc_e_dp", "dsc_z_dp", "dWee_dp", "dWez_dp", "dWzz_dp",
"dWee_dp_de", "dWee_dp_dz", "dWez_dp_dz", "dWzz_dp_dz",
"dl_dp2", "dsc_e_dp2", "dsc_z_dp2", "dWee_dp2", "dWez_dp2",
"dWzz_dp2")
PD <- matrix(0, length(y), length(cols), dimnames = list(NULL, cols))
zmask <- if (isTRUE(cq$has_zi)) as.numeric(y != 0) else rep(1, length(y))
PD[, "dl_dp"] <- dphi$dloglik(eta, y, n_trials, phi, phi2) * zmask
PD[, "dsc_e_dp"] <- dphi$dscore(eta, y, n_trials, phi, phi2) * zmask
PD[, "dWee_dp"] <- dphi$dweight(eta, y, n_trials, phi, phi2) * zmask
if (!is.null(dphi2)) {
PD[, "dl_dp2"] <- dphi2$dloglik2(eta, y, n_trials, phi, phi2) * zmask
PD[, "dsc_e_dp2"] <- dphi2$dscore2(eta, y, n_trials, phi, phi2) * zmask
PD[, "dWee_dp2"] <- dphi2$dweight2(eta, y, n_trials, phi, phi2) * zmask
PD[, "dWee_dp_de"] <- dphi2$dweight_deta(eta, y, n_trials, phi, phi2) * zmask
}
if (isTRUE(cq$has_zi)) {
ZP <- tryCatch(
cpp_zi_mixture_phi_deriv(as.numeric(y), as.integer(n_trials), eta,
cq$z_lin, family, phi, phi2 %||% NA_real_),
error = function(e) NULL)
if (is.null(ZP)) return(NULL)
PD <- PD + ZP[, cols, drop = FALSE]
}
if (any(!is.finite(PD))) return(NULL)
PD
}
# The joint negative-log-posterior curvature over the whole latent vector at
# the mode, at the OBSERVED weight: the kernel's joint Hessian plus the
# correction for the families whose Newton weight is not the observed curvature
# (zero elsewhere). One matrix, read by both the marginal fixed-effect precision
# below and the fit's joint latent draws (`H_latent`, gcol33/tulpa#871), so the
# random effects are drawn under the curvature the reported SEs came from.
#
# The count design has to span the whole latent vector, or the observed-weight
# correction cannot be formed. Refused (NULL) rather than skipped: for the two
# families whose working weight is not the observed curvature, skipping it
# silently returns a precision that is wrong by a percent.
.laplace_observed_H_joint <- function(H_joint, mode, y, n_trials, X, X_zi,
re_list, family, phi, phi2 = NULL,
weights = NULL, offset = NULL) {
if (is.null(H_joint) || is.null(mode)) return(NULL)
n_x <- nrow(H_joint)
if (length(mode) != n_x) return(NULL)
A <- .laplace_count_design(X, X_zi, re_list, length(y))
if (ncol(A) != n_x) return(NULL)
eta <- as.numeric(A %*% mode) + (offset %||% 0)
delta <- .laplace_obs_delta(y, n_trials, eta, family, phi, phi2,
weights, has_zi = !is.null(X_zi))
if (is.null(delta)) return(NULL)
if (max(abs(delta)) > 1e-12) {
H_joint <- H_joint + Matrix::crossprod(A, Matrix::Diagonal(x = delta) %*% A)
}
H_joint
}
# Marginal fixed-effect precision from the kernel's joint curvature.
#
# The joint Hessian is the negative-log-POSTERIOR curvature over the whole
# latent vector -- priors and the random-effect penalty included -- so the
# marginal precision on the fixed block is its Schur complement over the random
# effects, with no separate penalty to add back. Two things make this the only
# route rather than one of two: it is exact where a hand-assembled X'WX built
# from the Fisher weight is not (1.8% on neg_binomial_2), and it carries the
# zero-inflation process, which a single-predictor assembly cannot express.
#
# `p_fixed` counts BOTH fixed blocks, so the returned precision is
# (p + p_zi) square and lines up with the [beta | beta_zi] prefix that coef(),
# vcov() and confint() slice.
.laplace_marginal_H_fixed <- function(H_joint, mode, y, n_trials, X, X_zi,
re_list, family, phi, phi2 = NULL,
weights = NULL, offset = NULL,
H_obs = NULL) {
if (is.null(H_joint) || is.null(mode)) return(NULL)
p_fixed <- ncol(X) + (if (is.null(X_zi)) 0L else ncol(X_zi))
n_x <- nrow(H_joint)
if (length(mode) != n_x || p_fixed > n_x) return(NULL)
H <- H_obs %||% .laplace_observed_H_joint(
H_joint, mode, y, n_trials, X, X_zi, re_list, family, phi, phi2,
weights, offset)
if (is.null(H)) return(NULL)
idx <- seq_len(p_fixed)
if (p_fixed == n_x) return(as.matrix(H))
B <- H[idx, -idx, drop = FALSE]
D <- H[-idx, -idx, drop = FALSE]
mid <- tryCatch(Matrix::solve(D, Matrix::t(B)), error = function(e) NULL)
if (is.null(mid)) return(NULL)
out <- as.matrix(H[idx, idx, drop = FALSE] - B %*% mid)
if (any(!is.finite(out))) return(NULL)
(out + t(out)) / 2
}
# Largest relative mode error the reported inner solve can still be carrying,
# and the tolerance the exact gradient needs it under.
#
# `fit$score_max` is max_j |d(joint penalized log posterior)/dx_j| at the mode
# the kernel returned -- its ACHIEVED residual, as opposed to `converged`, which
# only says the stopping rule was met. The exact gradient does not depend on
# that residual directly but on the mode error it implies, dx = H_true^-1 score,
# because the term the derivation discards is score . dx_hat/dtheta. The induced
# inf-norm bounds the map: max|dx| <= ||H_true^-1||_inf max|score|, and dividing
# by the latent scale makes it a relative displacement, so the gate does not move
# with the units of x.
#
# The threshold is set where the neglected term starts to MATTER, not where the
# mode stops being pristine. Measured
# (dev_notes/probe_inner_stationarity.R), the assembled dm/dlog_phi loses
# accuracy at roughly eight times the relative mode error, so 1e-6 here bounds
# the outer gradient at ~1e-5 relative -- below anything an outer optimizer can
# act on, and four orders under the 1.0e-04 the issue reports.
#
# Both sides are measured. Settled solves swept over five families and
# n = 50 .. 18000 (scaling matters: the score's own round-off floor grows with
# the data) span 3.4e-16 to 2.1e-11, so nothing healthy comes within four orders
# of the gate. Solves stopped short span 8.8e-05 to 1.2e-02, so nothing broken
# comes within eighty times of it from the other side. A hurdle inner solve that
# settles only to 3.7e-08 -- genuinely short, but four orders better than the
# broken case -- passes, which is the intended reading: its gradient is good to
# ~3e-07 and refusing it would cost more than it buys.
#
# A fit with no `score_max` came from a path that does not report one; there is
# nothing to test, so it passes rather than blocking a gradient that was being
# returned before.
.LAPLACE_MODE_SETTLED_TOL <- 1e-6
.laplace_mode_settled <- function(fit, Hinv_mode, x) {
s <- fit$score_max
if (is.null(s) || length(s) != 1L || !is.finite(s)) return(TRUE)
if (s == 0) return(TRUE)
scale <- max(1, max(abs(x)))
rel <- s * max(rowSums(abs(Hinv_mode))) / scale
if (rel <= .LAPLACE_MODE_SETTLED_TOL) return(TRUE)
# Signalled classed so a driver that solves thousands of inner problems can
# collect these and report once (see .with_unsettled_report), while a single
# direct call still says what happened and what to change.
#
# The remedy is only named where there IS one: a solve that ran out of
# iterations gets more, but one that reported convergence stopped on its own
# stall detector at this residual and a larger max_iter
# changes nothing.
warning(structure(
class = c("tulpa_unsettled_mode", "warning", "condition"),
list(call = NULL, message = sprintf(
paste0("The inner Laplace solve stopped short of stationarity (joint ",
"score %.2e, relative mode error %.1e, %d iterations, ",
"converged = %s). The exact outer gradient differentiates ",
"through the mode and is not valid there, so it was declined.%s"),
s, rel, as.integer(fit$n_iter %||% NA_integer_), isTRUE(fit$converged),
if (isTRUE(fit$converged)) ""
else " The solve reached its iteration cap; raise the inner `max_iter`.")))
)
FALSE
}
# Run `expr` collecting the stationarity refusals above into a single warning
# rather than one per inner solve. The outer optimizers call the exact gradient
# once per trial theta, so an unreachable corner of the hyperparameter space
# would otherwise emit hundreds of identical warnings; the count is what the
# user needs, and the first one carries the actionable numbers.
#
# `state` is an environment with `n` and `first`, created by the caller so it can
# read the count DURING `expr`. That is what lets a gradient-driven optimizer
# notice it was walking blind and restart derivative-free: a declined gradient
# reaches optim() as zeros, which it cannot tell from a stationary point, so
# without the check it can stop at its own starting value and report that as the
# estimate.
.new_unsettled_state <- function() {
st <- new.env(parent = emptyenv())
st$n <- 0L
st$first <- NULL
st
}
.with_unsettled_report <- function(expr, caller, state = .new_unsettled_state()) {
out <- withCallingHandlers(
expr,
tulpa_unsettled_mode = function(w) {
state$n <- state$n + 1L
if (is.null(state$first)) state$first <- conditionMessage(w)
invokeRestart("muffleWarning")
}
)
if (state$n > 0L) {
warning(caller, "(): the exact outer gradient was declined at ",
state$n, " inner solve", if (state$n > 1L) "s" else "",
" whose mode had not settled, so the outer fit fell back to the ",
"derivative-free optimizer. ", state$first, call. = FALSE)
}
out
}
# Whether the exact outer gradient covers a zero-inflation process for this
# family. The gradient differentiates log|H| through dW/dtheta, so with two
# linear predictors it needs the eta-derivative of the MIXTURE's count-block
# curvature, not the base family's. The gate is separate from
# has_curvature_derivative() because a family can have the single-process
# derivative and not the mixture one.
.laplace_exact_supports_zi <- function(family) {
isTRUE(tryCatch(cpp_family_has_zi_curvature_derivative(family),
error = function(e) FALSE))
}
# First derivatives dL/dtheta_p for one block, in the coordinate order of
# .re_cov_theta_to_L_list: for a diagonal block, log sigma_i (i = 1..nc); for a
# full block, the column-major lower triangle with log L_ii on the diagonal and
# raw L_ij below. Because the diagonal coordinates are log-parameterized,
# dL_ii/dtheta = L_ii and d2L_ii/dtheta^2 = L_ii, both flagged here so dSigma and
# d2Sigma share one definition of which coordinate is a log.
.re_block_L_derivs <- function(L, nc, full) {
Lp <- list(); is_diag <- logical(0); di <- integer(0)
if (!isTRUE(full)) {
for (i in seq_len(nc)) {
dL <- matrix(0, nc, nc); dL[i, i] <- L[i, i]
Lp[[length(Lp) + 1L]] <- dL
is_diag <- c(is_diag, TRUE); di <- c(di, i)
}
} else {
for (j in seq_len(nc)) for (i in j:nc) {
dL <- matrix(0, nc, nc)
if (i == j) { dL[i, i] <- L[i, i]; is_diag <- c(is_diag, TRUE); di <- c(di, i) }
else { dL[i, j] <- 1; is_diag <- c(is_diag, FALSE); di <- c(di, NA_integer_) }
Lp[[length(Lp) + 1L]] <- dL
}
}
list(Lp = Lp, is_diag = is_diag, di = di)
}
# dSigma/dtheta_j for one block: Sigma = L L', so dSigma = dL L' + L dL'.
.re_block_dSigma <- function(L, nc, full) {
Lp <- .re_block_L_derivs(L, nc, full)$Lp
lapply(Lp, function(dL) dL %*% t(L) + L %*% t(dL))
}
# d2Sigma/dtheta_p dtheta_q for one block, returned as a list-of-lists indexed
# out[[p]][[q]]. From Sigma = L L',
#
# d2Sigma = L_pq L' + L_p L_q' + L_q L_p' + L L_pq'
#
# and L_pq is zero except when p == q is a diagonal (log) coordinate, where
# d2L_ii/dtheta^2 = L_ii. Term A of the exact Hessian
# (dev_notes/laplace_exact_hessian.md) contracts this against the Sigma-gradient
# the gradient already forms.
.re_block_d2Sigma <- function(L, nc, full) {
d <- .re_block_L_derivs(L, nc, full)
Lp <- d$Lp; k <- length(Lp)
out <- lapply(seq_len(k), function(.) vector("list", k))
for (p in seq_len(k)) for (q in seq_len(k)) {
d2 <- Lp[[p]] %*% t(Lp[[q]]) + Lp[[q]] %*% t(Lp[[p]])
if (p == q && isTRUE(d$is_diag[p])) {
i <- d$di[p]
Lpp <- matrix(0, nc, nc); Lpp[i, i] <- L[i, i]
d2 <- d2 + Lpp %*% t(L) + L %*% t(Lpp)
}
out[[p]][[q]] <- d2
}
out
}
# Shared intermediates for the exact gradient AND the exact Hessian: the mode,
# H^-1, the linear-predictor variance s, the dW/dtheta solve u, and per RE block
# the moments (R, V, C), the covariance/precision (Sigma via L, Omega) and the
# Sigma-gradient S. Both derivatives are contractions against these, so forming
# them once is the single source of truth the two assemblies read.
#
# Returns NULL rather than a wrong number whenever an ingredient is missing or
# unusable: no joint Hessian, a singular H or Sigma, a family without an exact
# curvature derivative, or a layout that does not line up with the mode.
.laplace_exact_core <- function(fit, y, X, n_trials, offset, weights,
re_list, layout, L_list, family, phi, phi2,
X_zi = NULL) {
H <- fit$H_joint
x <- fit$mode
if (is.null(H) || is.null(x)) return(NULL)
if (!isTRUE(cpp_family_has_curvature_derivative(family))) return(NULL)
has_zi <- !is.null(X_zi)
if (has_zi && !.laplace_exact_supports_zi(family)) return(NULL)
n_obs <- length(y)
p_fix <- ncol(X)
p_zi <- if (has_zi) ncol(X_zi) else 0L
p_fixed <- p_fix + p_zi
n_x <- length(x)
Z <- .re_design_matrix(re_list, n_obs)
if (is.null(Z)) return(NULL)
# A is the COUNT predictor's design. With zero inflation the latent vector
# also carries beta_zi, whose columns are zero here and carry the second
# predictor in A_zi instead.
A <- .laplace_count_design(X, X_zi, re_list, n_obs)
if (ncol(A) != n_x) return(NULL)
A_zi <- if (!has_zi) NULL else cbind(
Matrix::Matrix(0, nrow = n_obs, ncol = p_fix, sparse = TRUE),
Matrix::Matrix(X_zi, sparse = TRUE),
Matrix::Matrix(0, nrow = n_obs, ncol = n_x - p_fixed, sparse = TRUE))
eta <- as.numeric(A %*% x)
if (!is.null(offset)) eta <- eta + as.numeric(offset)
z_lin <- if (has_zi) as.numeric(A_zi %*% x) else NULL
# H^-1 in full. The alternative is one back-solve per observation for s and
# per group for V; at n_x below a few thousand the explicit inverse is the
# same order and serves every downstream piece from one factorization.
Hinv <- tryCatch(.spd_inverse(H), error = function(e) NULL)
if (is.null(Hinv) || any(!is.finite(Hinv))) return(NULL)
# Mode-Jacobian inverse. dx_hat/dtheta = -(A' diag(W_obs) A + P)^-1 (dP) x_hat
# is governed by the OBSERVED curvature W_obs, which the working-weight H_joint
# equals only for canonical / constant-curvature families. Where they differ
# (neg_binomial_1, truncated_neg_binomial_2) the true Hessian is H_joint plus
# A' diag(W_obs - w) A; where no exact observed form exists the Jacobian cannot
# be formed exactly and the caller differences the mode instead. Everything
# else in the gradient/Hessian reads H_joint, since the objective's log|H| is
# the working-weight one -- only the mode motion uses this inverse.
# The correction rides the COUNT predictor: it is the base family's weight
# that the mixture's y != 0 branch carries, and the y = 0 branch already uses
# the observed form (.laplace_obs_delta zeroes it there).
exact_jac <- isTRUE(cpp_family_has_exact_mode_jacobian(family))
Hinv_mode <- Hinv
# Whether the Newton working weight already IS the observed curvature. False
# only for neg_binomial_1 and truncated_neg_binomial_2, and read off the
# delta rather than a second hand-kept family list.
working_is_observed <- TRUE
if (exact_jac) {
delta <- .laplace_obs_delta(y, n_trials, eta, family, phi, phi2, weights,
has_zi = has_zi)
if (is.null(delta)) return(NULL)
if (max(abs(delta)) > 1e-12) {
working_is_observed <- FALSE
H_true <- H + Matrix::crossprod(A, Matrix::Diagonal(x = delta) %*% A)
Hinv_mode <- tryCatch(.spd_inverse(H_true), error = function(e) NULL)
if (is.null(Hinv_mode) || any(!is.finite(Hinv_mode))) {
exact_jac <- FALSE; Hinv_mode <- Hinv
}
}
}
# The inner solve has to have SETTLED, or none of the above is the gradient of
# anything. Every term here survives because the joint score at x_hat is zero,
# which kills the mode's contribution outside log|H|; a solve that stopped
# short leaves that contribution behind, and it is the term the whole
# derivation is built on discarding.
#
# What the outer gradient feels is not the residual itself but the mode error
# it implies, since the neglected term is score . dx_hat/dtheta and
# dx = H_true^-1 score. So the residual the kernel reports is mapped through
# the same inverse the mode motion travels and compared against the latent
# scale. Refused rather than warned-and-returned, matching every other
# unusable ingredient above: the caller falls back instead of optimizing a
# fiction.
if (!.laplace_mode_settled(fit, Hinv_mode, x)) return(NULL)
# s_i = (A H^-1 A')_ii, the posterior variance of the linear predictor. With
# two predictors the dW channel needs the full 2 x 2 predictor covariance --
# the variance of z and their covariance as well.
AH <- as.matrix(A %*% Hinv)
s <- rowSums(AH * as.matrix(A))
s_zz <- if (!has_zi) NULL else
rowSums(as.matrix(A_zi %*% Hinv) * as.matrix(A_zi))
s_ez <- if (!has_zi) NULL else rowSums(AH * as.matrix(A_zi))
# The dW/dtheta channel. tr(H^-1 dH_W) contracts each curvature partial
# against the matching predictor (co)variance, and what multiplies the mode
# motion is
#
# q_eta = dW_ee/deta s_ee + 2 dW_ez/deta s_ez + dW_zz/deta s_zz
# q_z = dW_ee/dz s_ee + 2 dW_ez/dz s_ez + dW_zz/dz s_zz
#
# so v_r = A' q_eta + A_zi' q_z. Without zero inflation W is the scalar
# count-block weight, q_eta collapses to dw * s and the second term is absent
# -- the one-process formula is this one with an empty A_zi.
wt_vec <- if (is.null(weights)) NULL else as.numeric(weights)
dW <- NULL
if (!has_zi) {
dw <- cpp_family_curvature_deta_vec(as.numeric(y), as.integer(n_trials),
eta, family, phi, phi2)
if (!is.null(wt_vec)) dw <- dw * wt_vec
if (any(!is.finite(dw))) return(NULL)
q_eta <- dw * s
q_z <- NULL
} else {
dW <- tryCatch(
cpp_zi_mixture_curvature_deriv(as.numeric(y), as.integer(n_trials),
eta, z_lin, family, phi, phi2),
error = function(e) NULL)
if (is.null(dW) || any(!is.finite(dW))) return(NULL)
if (!is.null(wt_vec)) dW <- dW * wt_vec
# Kept under the one-process name so the Hessian's du/dtheta and the phi
# block read the count-block derivative by the same handle either way.
dw <- dW[, "dWee_deta"]
q_eta <- dw * s + 2 * dW[, "dWez_deta"] * s_ez + dW[, "dWzz_deta"] * s_zz
q_z <- dW[, "dWee_dz"] * s + 2 * dW[, "dWez_dz"] * s_ez +
dW[, "dWzz_dz"] * s_zz
}
# One solve carries the whole dW/dtheta channel, independently of how many
# hyperparameter coordinates there are. v_r is kept for the Hessian's
# du/dtheta, where it is the vector u differentiates.
#
# Hinv_mode, not Hinv. This channel is v_r' (dx_hat/dtheta), and the mode
# motion follows the TRUE stationarity condition, so it is governed by the
# observed curvature -- the same inverse the mode Jacobian uses. The two
# coincide wherever the working weight is the observed one, which is why a
# poisson or gaussian check cannot see the difference; on
# truncated_neg_binomial_2 they differ by ~9% and the gradient was off ~0.8%.
v_r <- as.numeric(Matrix::crossprod(A, q_eta))
if (has_zi) v_r <- v_r + as.numeric(Matrix::crossprod(A_zi, q_z))
u <- as.numeric(Hinv_mode %*% v_r)
blocks <- vector("list", length(layout))
pos <- p_fixed
for (m in seq_along(layout)) {
bl <- layout[[m]]
nc <- bl$nc
G <- bl$n_groups
idx <- pos + seq_len(G * nc)
if (max(idx) > n_x) return(NULL)
pos <- pos + G * nc
# Group-major, coef-minor: column g of each is group g's nc-vector.
b_mat <- matrix(x[idx], nrow = nc)
u_mat <- matrix(u[idx], nrow = nc)
Rm <- tcrossprod(b_mat) # sum_g b_g b_g'
Vm <- matrix(0, nc, nc)
for (g in seq_len(G)) {
sl <- idx[(g - 1L) * nc + seq_len(nc)]
Vm <- Vm + Hinv[sl, sl, drop = FALSE]
}
Cm <- tcrossprod(b_mat, u_mat) # sum_g b_g u_g'
Cm <- (Cm + t(Cm)) / 2
Lm <- L_list[[m]]
Sig <- Lm %*% t(Lm)
Om <- tryCatch(chol2inv(chol(Sig)), error = function(e) NULL)
if (is.null(Om)) return(NULL)
# -Cm, not +Cm: the term is written in terms of dw/deta (the derivative of
# the POSITIVE weight H is built from), which is the negative of the third
# derivative of the log density. See the sign note in
# dev_notes/laplace_exact_gradient.md -- a gaussian check cannot catch this,
# because dw/deta is identically zero there.
M0 <- Rm + Vm - Cm
Smat <- 0.5 * (Om %*% M0 %*% Om - G * Om)
Smat <- (Smat + t(Smat)) / 2
blocks[[m]] <- list(idx = idx, nc = nc, G = G, full = isTRUE(bl$full),
b_mat = b_mat, u_mat = u_mat, Rm = Rm, Vm = Vm, Cm = Cm,
Lm = Lm, Om = Om, M0 = M0, Smat = Smat)
}
list(A = A, n_x = n_x, p_fix = p_fix, p_fixed = p_fixed, n_obs = n_obs,
Hinv = Hinv, x = x, eta = eta, dw = dw, s = s, u = u, v_r = v_r,
blocks = blocks, Hinv_mode = Hinv_mode, exact_jac = exact_jac,
working_is_observed = working_is_observed,
# q_eta / q_z are what any mode motion contracts against, whatever moved
# the mode. The RE coordinates reach them through v_r; the dispersion
# coordinate reads them directly, so the two-process channel is written
# once rather than once per coordinate kind. `dW` is the raw six-column
# block the Hessian differentiates a second time.
q_eta = q_eta, q_z = q_z, dW = dW,
has_zi = has_zi, A_zi = A_zi, z_lin = z_lin, s_zz = s_zz, s_ez = s_ez)
}
# Exact Hessian of the Laplace log-marginal w.r.t. the stacked log-Cholesky
# theta. The random-effect block is assembled column by column from the shared
# core: for each coordinate k the mode Jacobian J_k, the weight/precision moves
# dW_k and dP_k, and dH^-1_k feed a per-block transport of the Sigma-gradient
# (term B) plus the parameterization curvature (term A). Every off-diagonal is
# built twice -- as column j and as column k -- and the two agree to the inner
# solver's noise, so the raw matrix is symmetric before it is symmetrized.
#
# With `phi_block` supplied the returned matrix is bordered by the dispersion
# coordinate (dev_notes/laplace_phi_hessian.md): a log-phi column
# H_{theta_j, psi} reusing term B with the phi mode-motion (no dP, since the RE
# prior is phi-free), and the diagonal H_{psi, psi} differentiating the phi
# gradient once more. Without it the matrix is the RE block alone, the shape the
# fixed-dispersion path expects.
#
# With a zero-inflation process the same assembly runs over the full 2 x 2
# curvature block rather than one scalar weight: each coordinate's mode motion
# moves the zero predictor as well as the count predictor, so every entry of the
# block moves through both, dH_k picks up the cross and zero blocks, and du_k a
# matching A_zi' dq_z contribution. A hurdle is the case where the mixed
# fourth-order fields vanish, and the same code covers it -- the terms multiply
# by zero rather than being skipped.
#
# `dS_by_block` is the same per-block dSigma list the gradient's Jacobian uses,
# passed in so the chain rule is formed once. Returns NULL on a non-finite
# second curvature derivative.
.laplace_exact_re_hess <- function(cq, y, n_trials, family, phi, phi2, weights,
dS_by_block, phi_block = NULL) {
A <- cq$A; Hinv <- cq$Hinv; x <- cq$x; n_x <- cq$n_x
Hinv_mode <- cq$Hinv_mode
s <- cq$s; dwdeta <- cq$dw; u <- cq$u; v_r <- cq$v_r
blocks <- cq$blocks; nb <- length(blocks)
# The mixture's curvature block is -Hess(log density), so its second
# derivatives are the fourth derivatives of one scalar: five distinct values
# covering all nine second partials, indexed by how many derivatives are in
# eta. A hurdle zeroes the three mixed ones, which is what made the decoupled
# assembly enough for it; the coupled branch below is the general form and
# reduces to that one term by term.
has_zi <- isTRUE(cq$has_zi)
A_zi <- cq$A_zi
s_zz <- cq$s_zz
s_ez <- cq$s_ez
dW <- cq$dW
if (!has_zi) {
d2w <- cpp_family_curvature_deta2_vec(as.numeric(y), as.integer(n_trials),
cq$eta, family, phi, phi2)
d4 <- NULL
} else {
d4 <- tryCatch(
cpp_zi_mixture_curvature_deriv2(as.numeric(y), as.integer(n_trials),
cq$eta, cq$z_lin, family, phi, phi2),
error = function(e) NULL)
if (is.null(d4) || any(!is.finite(d4))) return(NULL)
if (!is.null(weights)) d4 <- d4 * as.numeric(weights)
d2w <- d4[, "d4_e4"]
}
if (!has_zi && !is.null(weights)) d2w <- d2w * as.numeric(weights)
if (any(!is.finite(d2w))) return(NULL)
# u is formed on the observed-curvature inverse, so differentiating it needs
# dH_true/dtheta rather than dH/dtheta. Where the working weight already IS
# the observed curvature the two coincide and this stays NULL, which is the
# cheaper path as well as the equivalent one.
ddelta <- NULL
if (!isTRUE(cq$working_is_observed)) {
ddelta <- .laplace_obs_delta_deta(y, n_trials, cq$eta, family, phi, phi2,
weights, has_zi = has_zi)
if (is.null(ddelta)) return(NULL)
}
klens <- vapply(dS_by_block, length, integer(1))
ktot <- sum(klens)
gstart <- c(0L, cumsum(klens))
blk_of <- integer(ktot); loc_of <- integer(ktot)
for (m in seq_len(nb)) {
rng <- gstart[m] + seq_len(klens[m])
blk_of[rng] <- m; loc_of[rng] <- seq_len(klens[m])
}
d2S_by_block <- lapply(seq_len(nb), function(m)
.re_block_d2Sigma(blocks[[m]]$Lm, blocks[[m]]$nc, blocks[[m]]$full))
Hm <- matrix(0, ktot, ktot)
for (k in seq_len(ktot)) {
mk <- blk_of[k]; lk <- loc_of[k]
bk <- blocks[[mk]]
Om_k <- bk$Om
dOm_k <- -Om_k %*% dS_by_block[[mk]][[lk]] %*% Om_k
# dP_k: dOmega_k on each group of block mk, zero elsewhere.
dP_k <- matrix(0, n_x, n_x)
for (g in seq_len(bk$G)) {
sl <- bk$idx[(g - 1L) * bk$nc + seq_len(bk$nc)]
dP_k[sl, sl] <- dOm_k
}
J_k <- -as.numeric(Hinv_mode %*% (dP_k %*% x)) # mode Jacobian column
eta_dot <- as.numeric(A %*% J_k)
# The mode motion moves BOTH predictors, so every entry of the curvature
# block moves through both. Without zero inflation only the first line
# survives and z_dot does not exist.
z_dot <- if (!has_zi) NULL else as.numeric(A_zi %*% J_k)
dW_k <- if (!has_zi) dwdeta * eta_dot else
dW[, "dWee_deta"] * eta_dot + dW[, "dWee_dz"] * z_dot
dH_k <- as.matrix(Matrix::crossprod(A, Matrix::Diagonal(x = dW_k) %*% A)) + dP_k
if (has_zi) {
dWez_k <- dW[, "dWez_deta"] * eta_dot + dW[, "dWez_dz"] * z_dot
dWzz_k <- dW[, "dWzz_deta"] * eta_dot + dW[, "dWzz_dz"] * z_dot
cross <- as.matrix(Matrix::crossprod(
A, Matrix::Diagonal(x = dWez_k) %*% A_zi))
dH_k <- dH_k + cross + t(cross) +
as.matrix(Matrix::crossprod(A_zi, Matrix::Diagonal(x = dWzz_k) %*% A_zi))
}
dHinv_k <- -Hinv %*% dH_k %*% Hinv
AdH <- as.matrix(A %*% dHinv_k)
ds_k <- rowSums(AdH * as.matrix(A))
# d q_eta / d theta_k and d q_z / d theta_k: the gradient's channel
# differentiated once more. Each of the three curvature partials in q moves
# through both predictors, and each of the three predictor (co)variances
# moves with the mode, so every term is a product rule over one of the five
# fourth-order fields and one variance derivative.
if (!has_zi) {
dq_eta_k <- (d2w * eta_dot) * s + dwdeta * ds_k
dq_z_k <- NULL
} else {
ds_zz_k <- rowSums(as.matrix(A_zi %*% dHinv_k) * as.matrix(A_zi))
ds_ez_k <- rowSums(AdH * as.matrix(A_zi))
dq_eta_k <-
(d4[, "d4_e4"] * eta_dot + d4[, "d4_e3z"] * z_dot) * s +
dW[, "dWee_deta"] * ds_k +
2 * ((d4[, "d4_e3z"] * eta_dot + d4[, "d4_e2z2"] * z_dot) * s_ez +
dW[, "dWez_deta"] * ds_ez_k) +
(d4[, "d4_e2z2"] * eta_dot + d4[, "d4_ez3"] * z_dot) * s_zz +
dW[, "dWzz_deta"] * ds_zz_k
dq_z_k <-
(d4[, "d4_e3z"] * eta_dot + d4[, "d4_e2z2"] * z_dot) * s +
dW[, "dWee_dz"] * ds_k +
2 * ((d4[, "d4_e2z2"] * eta_dot + d4[, "d4_ez3"] * z_dot) * s_ez +
dW[, "dWez_dz"] * ds_ez_k) +
(d4[, "d4_ez3"] * eta_dot + d4[, "d4_z4"] * z_dot) * s_zz +
dW[, "dWzz_dz"] * ds_zz_k
}
# d(H_true)^-1/dtheta_k. H_true is H plus the observed-minus-working
# correction, and only the correction's own eta-motion distinguishes its
# derivative from dHinv_k -- the prior term dP_k is already inside dH_k and
# carries no weight. Identical to dHinv_k wherever the two weights agree.
dHinv_mode_k <- if (is.null(ddelta)) dHinv_k else
-Hinv_mode %*%
(dH_k + as.matrix(Matrix::crossprod(
A, Matrix::Diagonal(x = ddelta * eta_dot) %*% A))) %*% Hinv_mode
# Hinv_mode, matching how u itself is formed. s, V and log|H| above stay on
# the working-weight inverse, which is the objective's own.
du_k <- as.numeric(dHinv_mode_k %*% v_r) +
as.numeric(Hinv_mode %*% as.numeric(Matrix::crossprod(A, dq_eta_k)))
if (has_zi) {
du_k <- du_k +
as.numeric(Hinv_mode %*% as.numeric(Matrix::crossprod(A_zi, dq_z_k)))
}
for (m in seq_len(nb)) {
bq <- blocks[[m]]; nc <- bq$nc; G <- bq$G; idx <- bq$idx
b_m <- bq$b_mat
u_m <- bq$u_mat
Jb_m <- matrix(J_k[idx], nrow = nc)
du_m <- matrix(du_k[idx], nrow = nc)
dR_m <- Jb_m %*% t(b_m) + b_m %*% t(Jb_m)
dV_m <- matrix(0, nc, nc)
for (g in seq_len(G)) {
sl <- idx[(g - 1L) * nc + seq_len(nc)]
dV_m <- dV_m + dHinv_k[sl, sl, drop = FALSE]
}
Cp <- Jb_m %*% t(u_m) + b_m %*% t(du_m); dC_m <- 0.5 * (Cp + t(Cp))
dM0_m <- dR_m + dV_m - dC_m
Om_m <- bq$Om; M0_m <- bq$M0
dS_m <- 0.5 * (Om_m %*% dM0_m %*% Om_m)
if (m == mk)
dS_m <- dS_m +
0.5 * (dOm_k %*% M0_m %*% Om_m + Om_m %*% M0_m %*% dOm_k - G * dOm_k)
# term B: <dSigma_j, dS_m> for every coordinate j in block m.
dS_list <- dS_by_block[[m]]
for (lj in seq_along(dS_list)) {
gj <- gstart[m] + lj
Hm[gj, k] <- Hm[gj, k] + sum(dS_list[[lj]] * dS_m)
}
}
# term A: <d2Sigma_{j,k}, S_mk> for coordinate j in block mk (block-local).
S_mk <- bk$Smat
d2S_mk <- d2S_by_block[[mk]]
for (lj in seq_len(klens[mk])) {
gj <- gstart[mk] + lj
Hm[gj, k] <- Hm[gj, k] + sum(d2S_mk[[lj]][[lk]] * S_mk)
}
}
# ---- dispersion column and diagonal (chi = [theta..., log phi]) -----------
# Borders the RE block with the log-phi coordinate. dxdphi (the phi mode
# motion) and the phi gradient value come pre-formed from the caller so the
# two are one computation.
#
# Everything the objective reads -- s, V, log|H| -- stays on the working-weight
# inverse. u and the mode motion are formed on H_true^-1, so differentiating
# THEM in psi needs dH_true/dpsi, which is dH_joint/dpsi plus the correction's
# own movement: through the mode (ddelta, the same eta-derivative the
# random-effect block uses) and explicitly in phi (ddelta_dphi). Where the two
# weights coincide the correction is the zero function and both reduce to the
# working-weight forms.
if (!is.null(phi_block) && !is.null(phi_block$dphi2)) {
wt <- phi_block$wt; PD <- phi_block$PD
dxdphi <- phi_block$dxdphi; deta_dphi <- phi_block$deta_dphi
dz_dphi <- phi_block$dz_dphi
ddelta_dphi <- NULL
if (!isTRUE(cq$working_is_observed)) {
ddelta_dphi <- .laplace_obs_delta_dphi(
y, n_trials, cq$eta, family, phi, phi2, phi_block$dphi,
phi_block$dphi2, has_zi = has_zi)
if (is.null(ddelta_dphi)) return(NULL)
}
# J_psi = phi dx/dphi is the log-phi mode-Jacobian column; eta_dot and z_dot
# its two linear-predictor images. d(H_joint)/dpsi moves through the mode in
# BOTH predictors and explicitly through phi, with no dP term (the RE prior
# is phi-free). Without a mixture only the first line survives.
J_psi <- phi * dxdphi
eta_dot <- phi * deta_dphi
z_dot <- if (!has_zi) NULL else phi * dz_dphi
dWee_psi <- dwdeta * eta_dot + phi * (wt * PD[, "dWee_dp"])
if (has_zi) dWee_psi <- dWee_psi + dW[, "dWee_dz"] * z_dot
dH_psi <- as.matrix(Matrix::crossprod(
A, Matrix::Diagonal(x = dWee_psi) %*% A))
if (has_zi) {
dWez_psi <- dW[, "dWez_deta"] * eta_dot + dW[, "dWez_dz"] * z_dot +
phi * (wt * PD[, "dWez_dp"])
dWzz_psi <- dW[, "dWzz_deta"] * eta_dot + dW[, "dWzz_dz"] * z_dot +
phi * (wt * PD[, "dWzz_dp"])
cross_psi <- as.matrix(Matrix::crossprod(
A, Matrix::Diagonal(x = dWez_psi) %*% A_zi))
dH_psi <- dH_psi + cross_psi + t(cross_psi) +
as.matrix(Matrix::crossprod(
A_zi, Matrix::Diagonal(x = dWzz_psi) %*% A_zi))
}
dHinv_psi <- -Hinv %*% dH_psi %*% Hinv
# The true-curvature counterpart, for the two quantities formed on it.
dHinv_mode_psi <- if (is.null(ddelta)) dHinv_psi else
-Hinv_mode %*%
(dH_psi + as.matrix(Matrix::crossprod(
A, Matrix::Diagonal(
x = ddelta * eta_dot + phi * (wt * ddelta_dphi)) %*% A))) %*%
Hinv_mode
AdH_psi <- as.matrix(A %*% dHinv_psi)
ds_psi <- rowSums(AdH_psi * as.matrix(A))
ds_zz_psi <- if (!has_zi) NULL else
rowSums(as.matrix(A_zi %*% dHinv_psi) * as.matrix(A_zi))
ds_ez_psi <- if (!has_zi) NULL else rowSums(AdH_psi * as.matrix(A_zi))
# Each of the curvature block's own derivatives moves with both predictors
# and explicitly with phi: the fourth-order fields supply the first two
# directions, PD the third. The mixed fields are the same fourth derivative
# reached two ways -- dW_ez/deta is dW_ee/dz, and dW_zz/deta is dW_ez/dz --
# so four of the six are shared rather than computed twice.
if (!has_zi) {
dWee_de_psi <- d2w * eta_dot + phi * (wt * PD[, "dWee_dp_de"])
dq_eta_psi <- dWee_de_psi * s + dwdeta * ds_psi
dq_z_psi <- NULL
} else {
mv <- function(f_e, f_z, pcol)
d4[, f_e] * eta_dot + d4[, f_z] * z_dot + phi * (wt * PD[, pcol])
dWee_de_psi <- mv("d4_e4", "d4_e3z", "dWee_dp_de")
dWee_dz_psi <- mv("d4_e3z", "d4_e2z2", "dWee_dp_dz")
dWez_dz_psi <- mv("d4_e2z2", "d4_ez3", "dWez_dp_dz")
dWzz_dz_psi <- mv("d4_ez3", "d4_z4", "dWzz_dp_dz")
dq_eta_psi <- dWee_de_psi * s + dwdeta * ds_psi +
2 * (dWee_dz_psi * s_ez + dW[, "dWez_deta"] * ds_ez_psi) +
dWez_dz_psi * s_zz + dW[, "dWzz_deta"] * ds_zz_psi
dq_z_psi <- dWee_dz_psi * s + dW[, "dWee_dz"] * ds_psi +
2 * (dWez_dz_psi * s_ez + dW[, "dWez_dz"] * ds_ez_psi) +
dWzz_dz_psi * s_zz + dW[, "dWzz_dz"] * ds_zz_psi
}
# u = Hinv_mode v_r, so its psi-derivative rides the true-curvature inverse
# in both terms -- the same pairing the random-effect block's du_k uses.
du_psi <- as.numeric(dHinv_mode_psi %*% v_r) +
as.numeric(Hinv_mode %*% as.numeric(Matrix::crossprod(A, dq_eta_psi)))
if (has_zi) {
du_psi <- du_psi +
as.numeric(Hinv_mode %*% as.numeric(Matrix::crossprod(A_zi, dq_z_psi)))
}
# phi column: term B alone (psi does not touch Sigma, so term A and dOm drop).
phi_col <- numeric(ktot)
for (m in seq_len(nb)) {
bq <- blocks[[m]]; nc <- bq$nc; G <- bq$G; idx <- bq$idx
b_m <- bq$b_mat; u_m <- bq$u_mat
Jb_m <- matrix(J_psi[idx], nrow = nc)
du_m <- matrix(du_psi[idx], nrow = nc)
dR_m <- Jb_m %*% t(b_m) + b_m %*% t(Jb_m)
dV_m <- matrix(0, nc, nc)
for (g in seq_len(G)) {
sl <- idx[(g - 1L) * nc + seq_len(nc)]
dV_m <- dV_m + dHinv_psi[sl, sl, drop = FALSE]
}
Cp <- Jb_m %*% t(u_m) + b_m %*% t(du_m); dC_m <- 0.5 * (Cp + t(Cp))
dM0_m <- dR_m + dV_m - dC_m
dS_m <- 0.5 * (bq$Om %*% dM0_m %*% bq$Om)
dS_list <- dS_by_block[[m]]
for (lj in seq_along(dS_list)) {
gj <- gstart[m] + lj
phi_col[gj] <- phi_col[gj] + sum(dS_list[[lj]] * dS_m)
}
}
# phi diagonal: differentiate the phi gradient once more in psi. The second
# mode derivative d(dx/dphi)/dpsi is the one genuinely new solve. Its right
# hand side needs d(score)/dpsi in each predictor, and those come from the
# identity that the score's eta-derivative is -W_OBSERVED -- the curvature of
# the actual log density, not the Newton working weight. The two coincide
# exactly where working_weight_is_observed() holds, and elsewhere their
# phi-derivatives differ by ddelta_dphi.
dA <- sum(wt * (PD[, "dsc_e_dp"] * eta_dot + phi * PD[, "dl_dp2"]))
dsc_e_deta_dp <- PD[, "dWee_dp"] +
(if (is.null(ddelta_dphi)) 0 else ddelta_dphi)
rhs_e <- (-dsc_e_deta_dp) * eta_dot + phi * PD[, "dsc_e_dp2"]
if (has_zi) rhs_e <- rhs_e - PD[, "dWez_dp"] * z_dot
d2x_rhs <- as.numeric(Matrix::crossprod(A, wt * rhs_e))
if (has_zi) {
dA <- dA + sum(wt * PD[, "dsc_z_dp"] * z_dot)
rhs_z <- (-PD[, "dWez_dp"]) * eta_dot - PD[, "dWzz_dp"] * z_dot +
phi * PD[, "dsc_z_dp2"]
d2x_rhs <- d2x_rhs +
as.numeric(Matrix::crossprod(A_zi, wt * rhs_z))
}
d2x_dpsi <- as.numeric(dHinv_mode_psi %*% phi_block$rhs_mode) +
as.numeric(Hinv_mode %*% d2x_rhs)
d_deta_dphi_dpsi <- as.numeric(A %*% d2x_dpsi)
# dB is the log|H| half of the gradient differentiated once more. Written as
# the product rule over (predictor covariance) x (explicit dW/dphi) plus the
# mode-motion channel q . d(eta,z)/dphi, which is the same decomposition the
# gradient itself uses -- so the one-process case reduces to it term by term.
dWee_dp_psi <- PD[, "dWee_dp_de"] * eta_dot + phi * PD[, "dWee_dp2"]
if (has_zi) dWee_dp_psi <- dWee_dp_psi + PD[, "dWee_dp_dz"] * z_dot
dB <- sum(ds_psi * wt * PD[, "dWee_dp"] + s * wt * dWee_dp_psi +
dq_eta_psi * deta_dphi + cq$q_eta * d_deta_dphi_dpsi)
if (has_zi) {
d_dz_dphi_dpsi <- as.numeric(A_zi %*% d2x_dpsi)
dWez_dp_psi <- PD[, "dWee_dp_dz"] * eta_dot +
PD[, "dWez_dp_dz"] * z_dot + phi * PD[, "dWez_dp2"]
dWzz_dp_psi <- PD[, "dWez_dp_dz"] * eta_dot +
PD[, "dWzz_dp_dz"] * z_dot + phi * PD[, "dWzz_dp2"]
dB <- dB +
sum(2 * (ds_ez_psi * wt * PD[, "dWez_dp"] + s_ez * wt * dWez_dp_psi) +
ds_zz_psi * wt * PD[, "dWzz_dp"] + s_zz * wt * dWzz_dp_psi +
dq_z_psi * dz_dphi + cq$q_z * d_dz_dphi_dpsi)
}
phi_diag <- phi_block$grad_phi + phi * (dA - 0.5 * dB)
if (!is.finite(phi_diag) || any(!is.finite(phi_col))) return(NULL)
# unname: cbind/rbind would otherwise label the border after the local
# variable (`phi_col`); the marginal correction sets the real coordinate
# names, and a stray one here diverges from the stencils' plain matrices.
Hm <- unname(rbind(cbind(Hm, phi_col), c(phi_col, phi_diag)))
}
if (any(!is.finite(Hm))) return(NULL)
(Hm + t(Hm)) / 2
}
# Exact gradient of the Laplace log-marginal w.r.t. the stacked log-Cholesky
# theta, given a fit produced with `return_joint_hessian = TRUE`. On request
# also the exact mode Jacobian J (closed form) and the exact theta-Hessian H.
#
# Returns NULL rather than a wrong number whenever an ingredient is missing or
# unusable; the caller then falls back to the derivative-free path instead of
# optimizing a fiction. With `want_hessian` the returned H is NULL (grad and J
# still valid) for a family that has no closed-form second curvature derivative,
# which sends only the Hessian back to the differencing stencil.
.laplace_exact_re_grad <- function(fit, y, X, n_trials, offset, weights,
re_list, layout, L_list, family, phi,
phi2 = NA_real_, want_jacobian = FALSE,
want_hessian = FALSE, want_phi = FALSE,
X_zi = NULL) {
cq <- .laplace_exact_core(fit, y, X, n_trials, offset, weights,
re_list, layout, L_list, family, phi, phi2,
X_zi = X_zi)
if (is.null(cq)) return(NULL)
# The dispersion block reads the BASE family's phi derivatives for every row
# the mixture leaves additively separable, and the coupled y = 0 branch from
# the mixture engine for the rest (.laplace_phi_fields). Under a hurdle the
# second source is identically zero -- a zero-truncated base has P(Y = 0) = 0,
# so its zero branch is log(pi), phi-free -- which is why the base registry
# alone was already exact there. Under genuine zero inflation the y = 0 branch
# is log(pi + (1 - pi) P(Y = 0, phi)) and carries phi through P(Y = 0) as well,
# coupled to both predictors; that is what the engine supplies.
#
# Gated on the engine rather than on the base being truncated, so a family
# without a registered phi column is declined instead of receiving the base
# derivatives under a model they do not describe.
if (isTRUE(cq$has_zi) && isTRUE(want_phi) &&
!isTRUE(tryCatch(cpp_family_has_zi_phi_deriv(family),
error = function(e) FALSE))) return(NULL)
A <- cq$A; Hinv <- cq$Hinv; x <- cq$x; n_x <- cq$n_x
Hinv_mode <- cq$Hinv_mode
s <- cq$s; dw <- cq$dw; eta <- cq$eta; n_obs <- cq$n_obs
want_J <- isTRUE(want_jacobian) || isTRUE(want_hessian)
# The mode Jacobian, and the closed Hessian that reads it, are exact only when
# the true observed curvature is available (has_exact_mode_jacobian). Decline
# both rather than return a working-weight Jacobian; the caller then differences
# the mode. The gradient value itself is unaffected and still returned below.
if (want_J && !isTRUE(cq$exact_jac)) return(NULL)
grad <- numeric(0)
# Exact mode Jacobian dx_hat/dtheta = -H^-1 (dP/dtheta) x_hat, assembled
# column by column as the blocks are walked. Each column is a matrix-vector
# product against the H^-1 already formed, so the whole Jacobian costs no
# further factorization -- against 2k full inner Laplace re-solves to
# finite-difference the mode. `dS_by_block` is kept for the Hessian, which
# reuses the same chain rule rather than re-forming it.
J <- if (want_J) matrix(0, n_x, 0) else NULL
dS_by_block <- vector("list", length(cq$blocks))
for (m in seq_along(cq$blocks)) {
bq <- cq$blocks[[m]]
gm <- tryCatch(
cpp_recov_block_grad(bq$Smat, bq$Lm, bq$full, 1.0),
error = function(e) NULL
)
if (is.null(gm)) return(NULL)
grad <- c(grad, gm)
if (want_J) {
dS_list <- .re_block_dSigma(bq$Lm, bq$nc, bq$full)
if (length(dS_list) != length(gm)) return(NULL)
dS_by_block[[m]] <- dS_list
Jm <- vapply(dS_list, function(dSig) {
dOm <- -bq$Om %*% dSig %*% bq$Om
# (dP/dtheta_j) x_hat is zero outside this block, and inside it is
# dOmega applied to each group's coefficients.
rhs <- numeric(n_x)
rhs[bq$idx] <- as.numeric(dOm %*% bq$b_mat)
-as.numeric(Hinv_mode %*% rhs)
}, numeric(n_x))
J <- cbind(J, Jm)
}
}
if (any(!is.finite(grad))) return(NULL)
# ---- Dispersion coordinate ------------------------------------------------
# Formed before the Hessian so its mode motion (dx_hat/dphi) and gradient value
# are one computation the phi Hessian block reads back, rather than two. The
# stacked theta the optimizer walks is [block coordinates ..., log phi]; the
# random-effect half is untouched when the dispersion is held fixed.
#
# dm/dphi = sum_i w_i dloglik_i/dphi
# - 0.5 sum_i [ w_i s_i dW_i/dphi
# + q_eta_i (A dx_hat/dphi)_i
# + q_z_i (A_zi dx_hat/dphi)_i ]
#
# The mode terms are the reason this cannot be read off the likelihood alone:
# log|H| moves with phi both explicitly, through W(eta, phi), and through the
# mode. They contract against the same q_eta / q_z channel the covariance
# coordinates travel, so one derivation covers both kinds of coordinate.
#
# Both reduce under the mixtures this path admits, and measurably so rather
# than by assumption (dev_notes/probe_hurdle_phi_gradient.R): a hurdle's two
# processes are variation-independent, which zeroes dWez and dWzz's
# eta-derivative, leaving q_eta = dw * s; and it makes H block-diagonal
# between the zero block and the count-plus-random-effect block, so the phi
# mode motion has no zero-block component and q_z's term is identically zero.
# The general form is kept because q_eta / q_z are already formed in the core,
# so writing the hurdle's special case would cost nothing and would be
# silently wrong if the gate above ever admits an untruncated mixture. What
# actually distinguishes the mixture here is the mask, not these terms.
#
# `dw` already carries the observation weights (applied in the core), so only
# the explicit dW/dphi is scaled here.
grad_phi <- NULL
phi_block <- NULL
if (isTRUE(want_phi)) {
dphi <- .family_dphi(family)
if (is.null(dphi)) return(NULL)
if (!is.finite(phi) || phi <= 0) return(NULL)
wt <- if (is.null(weights)) rep(1, n_obs) else as.numeric(weights)
# Under a hurdle the y = 0 branch is log(pi): it carries no phi, and the
# base family's derivatives are not even finite there (a zero-truncated
# density is -Inf at y = 0). Zeroing them is what makes the base registry
# the mixture's derivative rather than an approximation to it.
dphi2 <- .family_dphi2(family)
PD <- .laplace_phi_fields(cq, y, n_trials, family, phi, phi2, dphi, dphi2)
if (is.null(PD)) return(NULL)
# dx_hat/dphi from implicit differentiation of the inner stationarity
# condition, on the OBSERVED-curvature H:
#
# H_true dx_hat/dphi = A'(w d(score_eta)/dphi) + A_zi'(w d(score_z)/dphi)
#
# The second term is what genuine zero inflation adds. Its zero predictor is
# logistic and carries no phi of its own, but at y = 0 the mixture weight
# log(pi + (1 - pi) P(Y = 0, phi)) does, so the zero-predictor score moves
# with phi through P(Y = 0). Under a hurdle that probability is zero and the
# term vanishes identically, which is why the count-predictor solve alone was
# right there.
rhs_mode <- as.numeric(Matrix::crossprod(A, wt * PD[, "dsc_e_dp"]))
if (isTRUE(cq$has_zi)) {
rhs_mode <- rhs_mode +
as.numeric(Matrix::crossprod(cq$A_zi, wt * PD[, "dsc_z_dp"]))
}
dxdphi <- as.numeric(Hinv_mode %*% rhs_mode)
deta_dphi <- as.numeric(A %*% dxdphi)
dz_dphi <- if (!isTRUE(cq$has_zi)) NULL else
as.numeric(cq$A_zi %*% dxdphi)
# The explicit dW/dphi term runs over the whole 2 x 2 block, each partial
# contracted against the matching predictor (co)variance -- the same
# contraction q_eta / q_z make in the eta and z directions.
dW_term <- wt * s * PD[, "dWee_dp"]
if (isTRUE(cq$has_zi)) {
dW_term <- dW_term +
wt * (2 * cq$s_ez * PD[, "dWez_dp"] + cq$s_zz * PD[, "dWzz_dp"])
}
g_phi <- sum(wt * PD[, "dl_dp"]) -
0.5 * sum(dW_term + cq$q_eta * deta_dphi +
(if (is.null(dz_dphi)) 0 else cq$q_z * dz_dphi))
# The optimizer works in log phi, where the positivity constraint is free.
grad_phi <- phi * g_phi
if (!is.finite(grad_phi)) return(NULL)
grad <- c(grad, grad_phi)
# log-phi column of the mode Jacobian, dx_hat/dlog_phi = phi dx_hat/dphi.
if (want_J) J <- cbind(J, phi * dxdphi)
phi_block <- list(dphi = dphi, dphi2 = dphi2, wt = wt, PD = PD,
grad_phi = grad_phi, dxdphi = dxdphi,
deta_dphi = deta_dphi, dz_dphi = dz_dphi,
rhs_mode = rhs_mode)
}
# Closed-form theta-Hessian, gated on the second curvature derivative. A family
# that has only the first derivative leaves H NULL, so the gradient and J are
# still exact and only the Hessian returns to the stencil. When the dispersion
# is estimated but has no second-order block, the phi row/column cannot be
# formed closed either, so H is left NULL and the caller differences the
# gradient (which carries the phi row) instead.
# The closed Hessian differentiates the curvature once more, which under a
# mixture means the SECOND derivatives of the 2 x 2 block. Because that block
# is -Hess(log density), those are the fourth derivatives of one scalar: five
# distinct fields covering all nine second partials, of which a hurdle needs
# only two. Registered per family by has_zi_curvature_2nd_derivative(), which
# additionally needs the second eta-derivative of the observed curvature for
# the coupled y = 0 branch.
#
# Where the working weight is not the observed curvature (neg_binomial_1,
# truncated_neg_binomial_2, and so hurdle_nbinom2), u is formed on the
# observed-curvature inverse and must be differentiated on it too, which needs
# d(W_obs - w)/deta. .laplace_exact_re_hess declines on its own when that is
# unregistered, so the condition lives there rather than being duplicated
# here.
#
# The dispersion border runs over both predictors as well: its dW/dpsi is the
# full 2 x 2 block and its second mode derivative solves against both scores.
# It still needs the family's second-order phi registry, which is what leaves
# a hurdle over truncated_neg_binomial_2 on the stencil -- .FAMILY_DPHI2 has no
# entry for the truncated pair, independently of the mixture.
hess_curv_ok <- isTRUE(tryCatch(
if (isTRUE(cq$has_zi)) cpp_family_has_zi_curvature_2nd_derivative(family)
else cpp_family_has_curvature_2nd_derivative(family),
error = function(e) FALSE))
Hmat <- NULL
if (isTRUE(want_hessian) && hess_curv_ok &&
!(isTRUE(want_phi) && is.null(phi_block$dphi2))) {
Hmat <- .laplace_exact_re_hess(cq, y, n_trials, family, phi, phi2, weights,
dS_by_block, phi_block = phi_block)
}
if (!want_J) return(grad)
if (any(!is.finite(J))) return(NULL)
list(grad = grad, J = J, H = Hmat, grad_phi = grad_phi)
}
Any scripts or data that you put into this service are public.
Add the following code to your website.
For more information on customizing the embed code, read Embedding Snippets.