From 00c6a0c698691373465feed98045a5c339b9cbe2 Mon Sep 17 00:00:00 2001 From: fabian-s Date: Fri, 18 Sep 2026 11:02:20 +0200 Subject: [PATCH 1/5] Fix the Simpson integration weights used by ff() and sff() compute_integration_weights(., "simpson") and simpson_weights() scaled the [1, 4, 2, ..., 4, 1] pattern by (b - a) / (3 * n) instead of (b - a) / (3 * (n - 1)), and for even n the alternation ended in 2 before the closing 1, which composite Simpson does not allow. The weights therefore summed to less than the length of the integration domain: a constant on [0, 1] integrated to 0.956 at n = 30, 0.968 at n = 31, 0.978 at n = 60, 0.984 at n = 61 and 0.989 at n = 93 (the DTI CCA grid). Estimated ff() surfaces were rescaled by the reciprocal of that factor. Simulation studies in which the same weights generate and fit the data are unaffected; real-data fits are. The weights now implement composite Simpson's rule with h = (b - a) / (n - 1): the classical pattern for odd n, Simpson on the first n - 3 points plus Simpson's 3/8 rule on the last three intervals for even n >= 4, and the trapezoidal rule for n = 2. A constant now integrates to exactly b - a and cubics are integrated exactly for every n >= 3. The old behaviour stays reachable as integration = "simpson_legacy" in ff() and sff() so earlier fits and benchmarks can be reproduced; it is documented as deprecated. simpson_weights() now delegates to compute_integration_weights() so the two can no longer drift apart. The trapezoidal weights were checked and are correct. The riemann weights (used whenever limits= is given) sum to (b - a) plus one mean grid spacing; that is inherent to a first-order rule giving all n points positive weight and the truncated-domain behaviour depends on it, so it is documented rather than changed. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QSoB6DyAfeyS9PytuzQs3x --- NEWS.md | 18 ++ R/pffr-ff.R | 9 +- R/pffr-sff.R | 9 +- R/pffr-utilities.R | 109 ++++++++- man/compute_integration_weights.Rd | 28 ++- man/ff.Rd | 7 +- man/sff.Rd | 7 +- man/simpson_pattern.Rd | 31 +++ man/simpson_weights.Rd | 15 +- .../testthat/test-pffr-integration-weights.R | 208 ++++++++++++++++++ 10 files changed, 417 insertions(+), 24 deletions(-) create mode 100644 man/simpson_pattern.Rd create mode 100644 tests/testthat/test-pffr-integration-weights.R diff --git a/NEWS.md b/NEWS.md index ba87a9aa..a4553090 100644 --- a/NEWS.md +++ b/NEWS.md @@ -12,6 +12,24 @@ GLS-based covariance correction produced poorly calibrated inference. Use `pffr()` with `sandwich = "cluster"` (default) or `sandwich = "cl2"` instead. +* **The Simpson integration weights used by `ff()` and `sff()` are fixed.** + The `integration = "simpson"` weights were scaled by + `(b - a) / (3 * nxgrid)` instead of `(b - a) / (3 * (nxgrid - 1))`, and for + even `nxgrid` the `[1, 4, 2, ..., 4, 1]` alternation ended in `2` before the + closing `1`, which composite Simpson does not allow. The weights therefore + summed to less than the length of the integration domain: a constant on + `[0, 1]` integrated to 0.956 at `nxgrid = 30`, 0.968 at `nxgrid = 31`, 0.978 + at `nxgrid = 60`, 0.984 at `nxgrid = 61` and 0.989 at `nxgrid = 93` (the DTI + CCA grid) instead of 1. Estimated `ff()` coefficient surfaces were rescaled + by the reciprocal of that factor, i.e. inflated by up to ~4.5% on typical + grids. Simulation studies in which the same weights generated *and* fitted + the data are unaffected; real-data fits are. The weights now implement + composite Simpson's rule with `h = (b - a) / (nxgrid - 1)`, using Simpson's + 3/8 rule on the last three intervals when `nxgrid` is even, so that a + constant integrates to exactly `b - a` and cubics are integrated exactly for + every `nxgrid >= 3`. The old behaviour is still reachable as + `integration = "simpson_legacy"` for reproducing results from earlier + versions; it is deprecated and should not be used for new analyses. ## Function renames (old names deprecated) diff --git a/R/pffr-ff.R b/R/pffr-ff.R index 4465f895..1a706f03 100644 --- a/R/pffr-ff.R +++ b/R/pffr-ff.R @@ -73,7 +73,10 @@ #' @param integration method used for numerical integration. Defaults to #' \code{"simpson"}'s rule for calculating entries in \code{L}. Alternatively #' and for non-equidistant grids, \code{"trapezoidal"} or \code{"riemann"}. -#' \code{"riemann"} integration is always used if \code{limits} is specified +#' \code{"riemann"} integration is always used if \code{limits} is specified. +#' \code{"simpson_legacy"} reproduces the mis-scaled Simpson weights used up +#' to refund 0.1-40 (see \code{\link{compute_integration_weights}}) and is +#' deprecated; it exists only to reproduce results from older versions. #' @param L optional: an n by \code{ncol(xind)} matrix giving the weights for #' the numerical integration over \eqn{s}. #' @param limits defaults to NULL for integration across the entire range of @@ -114,7 +117,7 @@ ff <- function( yind = NULL, xind = seq(0, 1, l = ncol(X)), basistype = c("te", "t2", "ti", "s", "tes"), - integration = c("simpson", "trapezoidal", "riemann"), + integration = c("simpson", "trapezoidal", "riemann", "simpson_legacy"), L = NULL, limits = NULL, splinepars = if (basistype != "s") { @@ -156,7 +159,7 @@ ff <- function( if ( is.null(L) && any(apply(diff_xind, 1, \(x) length(unique(x))) != 1) && - integration == "simpson" + integration %in% c("simpson", "simpson_legacy") ) { message( "Non-equidistant grid detected for ", diff --git a/R/pffr-sff.R b/R/pffr-sff.R index 5a937760..67e14c62 100644 --- a/R/pffr-sff.R +++ b/R/pffr-sff.R @@ -25,7 +25,10 @@ #' \code{\link[mgcv]{t2}}). #' @param integration method used for numerical integration. Defaults to #' \code{"simpson"}'s rule. Alternatively and for non-equidistant grids, -#' \code{"trapezoidal"}. +#' \code{"trapezoidal"}. \code{"simpson_legacy"} reproduces the mis-scaled +#' Simpson weights used up to refund 0.1-40 (see +#' \code{\link{compute_integration_weights}}) and is deprecated; it exists +#' only to reproduce results from older versions. #' @param L optional: an n by \code{ncol(xind)} giving the weights for the #' numerical integration over \eqn{s}. #' @param limits defaults to NULL for integration across the entire range of @@ -59,7 +62,7 @@ sff <- function( yind = NULL, xind = seq(0, 1, length.out = ncol(X)), basistype = c("te", "t2", "s"), - integration = c("simpson", "trapezoidal"), + integration = c("simpson", "trapezoidal", "simpson_legacy"), L = NULL, limits = NULL, splinepars = list(bs = "ps", m = c(2, 2, 2)) @@ -96,7 +99,7 @@ sff <- function( if ( is.null(L) && any(apply(diff_xind, 1, \(x) length(unique(x))) != 1) && - integration == "simpson" + integration %in% c("simpson", "simpson_legacy") ) { warning( "Non-equidistant grid detected for ", diff --git a/R/pffr-utilities.R b/R/pffr-utilities.R index 7602e453..8f5315b1 100755 --- a/R/pffr-utilities.R +++ b/R/pffr-utilities.R @@ -167,15 +167,82 @@ expand_windows_to_maxwidth <- function(windows, max_col) { # Shared Utilities for ff/sff Terms #-------------------------------------- +#' Simpson weight pattern for an equidistant grid of `nxgrid` points +#' +#' Returns the dimensionless composite-Simpson weight pattern for `nxgrid` +#' equidistant points, in units of the step size `h`. The pattern sums to +#' `nxgrid - 1`, so that multiplying it by `h = (b - a) / (nxgrid - 1)` yields +#' weights summing to `b - a`. +#' +#' Cases: +#' * odd `nxgrid >= 3`: classical composite Simpson, `[1, 4, 2, ..., 4, 1] / 3`. +#' * even `nxgrid >= 4`: composite Simpson on the first `nxgrid - 3` points +#' plus Simpson's 3/8 rule on the last three intervals. This keeps the rule +#' exact for cubic polynomials for every `nxgrid >= 3` (a trapezoidal +#' closing interval would degrade the even-`nxgrid` rule to second order). +#' * `nxgrid == 2`: trapezoidal rule. +#' * `nxgrid == 1`: degenerate, weight 1 (no integration possible). +#' +#' @param nxgrid Integer number of grid points. +#' @returns Numeric vector of length `nxgrid` summing to `max(nxgrid - 1, 1)`. +#' @keywords internal +simpson_pattern <- function(nxgrid) { + if (nxgrid < 2) { + return(1) + } + if (nxgrid == 2) { + return(c(0.5, 0.5)) + } + if (nxgrid %% 2 == 1) { + return(c(1, rep(c(4, 2), length.out = nxgrid - 2), 1) / 3) + } + # even nxgrid: Simpson on points 1..(nxgrid - 3), 3/8 on the last 3 intervals + pattern <- numeric(nxgrid) + n_simpson <- nxgrid - 3 + if (n_simpson >= 3) { + pattern[seq_len(n_simpson)] <- + c(1, rep(c(4, 2), length.out = n_simpson - 2), 1) / 3 + } + tail_idx <- (nxgrid - 3):nxgrid + pattern[tail_idx] <- pattern[tail_idx] + c(1, 3, 3, 1) * 3 / 8 + pattern +} + #' Compute integration weights for ff/sff terms #' #' Computes numerical integration weights using Simpson's rule, trapezoidal #' rule, or Riemann sums. Used by [ff()] and [sff()] for constructing #' functional regression terms. #' +#' @details +#' `integration = "simpson"` implements *composite* Simpson's rule on an +#' equidistant grid with step `h = (b - a) / (nxgrid - 1)`; see +#' [simpson_pattern()] for the exact pattern used for odd and even `nxgrid`. +#' The weights sum to `b - a`, so a constant function integrates exactly. +#' +#' `integration = "simpson_legacy"` reproduces the (incorrect) weights used by +#' `ff()`/`sff()` up to refund 0.1-40: the pattern `[1, 4, 2, ..., 4, 1]` was +#' scaled by `(b - a) / (3 * nxgrid)` instead of `(b - a) / (3 * (nxgrid - 1))`, +#' and for even `nxgrid` the alternation ended in `2` before the closing `1`, +#' which composite Simpson does not allow. The resulting weights sum to less +#' than `b - a` (e.g. a constant on `[0, 1]` integrates to 0.956 at +#' `nxgrid = 30` and to 0.989 at `nxgrid = 93`), so estimated `ff()` surfaces +#' are rescaled by the reciprocal of that factor. This option is retained +#' **only** for reproducing results computed with older versions and is +#' deprecated; do not use it for new analyses. +#' +#' `integration = "trapezoidal"` is exact for constants (its weights sum to +#' `b - a`) and is the recommended rule for non-equidistant grids. +#' `integration = "riemann"` (used whenever `limits` is specified) attaches a +#' rectangle to every grid point including the left endpoint, so its weights +#' sum to `b - a` plus one mean grid spacing; this is inherent to a first-order +#' rule that gives all `nxgrid` points positive weight and is left unchanged +#' because the truncated-domain (`limits`) behaviour depends on it. +#' #' @param xind Matrix of x-index values (n x nxgrid). Each row contains the #' evaluation points for one observation. -#' @param integration Character: "simpson", "trapezoidal", or "riemann". +#' @param integration Character: "simpson", "simpson_legacy", "trapezoidal", +#' or "riemann". #' @returns Matrix of integration weights (n x nxgrid). #' @keywords internal compute_integration_weights <- function(xind, integration = "simpson") { @@ -185,7 +252,17 @@ compute_integration_weights <- function(xind, integration = "simpson") { switch( integration, simpson = { - # Simpson's rule: int^b_a f(t) dt ā‰ˆ (b-a)/(3n) * [f(a) + 4f(t_1) + 2f(t_2) + ...] + # composite Simpson: weights sum to (b - a) + pattern <- simpson_pattern(nxgrid) + h <- if (nxgrid > 1) { + (xind[, nxgrid] - xind[, 1]) / (nxgrid - 1) + } else { + rep(1, n) + } + matrix(outer(h, pattern), nrow = n, ncol = nxgrid) + }, + simpson_legacy = { + # deprecated: refund <= 0.1-40 weights, scaled by (b-a)/(3*nxgrid) ((xind[, nxgrid] - xind[, 1]) / nxgrid) / 3 * matrix( @@ -209,7 +286,13 @@ compute_integration_weights <- function(xind, integration = "simpson") { # Simple Riemann sums diffs <- t(apply(xind, 1, diff)) cbind(rep(mean(diffs), n), diffs) - } + }, + stop( + "Unknown `integration` method: ", + sQuote(integration), + ". Use one of \"simpson\", \"simpson_legacy\", \"trapezoidal\", ", + "\"riemann\"." + ) ) } @@ -1208,18 +1291,26 @@ center_functional_covariate <- function(X) { #' Compute Simpson integration weights #' -#' Computes Simpson's rule weights for numerical integration on an equidistant -#' grid. This matches pffr's default integration method in ff() terms. -#' Note: pffr uses (b-a)/n/3 (divides by grid length n), not (b-a)/(n-1)/3. +#' Computes composite Simpson's rule weights for numerical integration on an +#' equidistant grid. This matches the default integration method used by +#' [ff()] and [sff()] terms, i.e. the weights sum to `xind[n] - xind[1]`. +#' +#' `integration = "simpson_legacy"` returns the deprecated weights used by +#' refund <= 0.1-40, which were scaled by `(b - a) / (3 * n)` rather than +#' `(b - a) / (3 * (n - 1))`; see [compute_integration_weights()]. #' #' @param xind Numeric vector of evaluation points. +#' @param integration Character: "simpson" (default) or "simpson_legacy". #' @returns Numeric vector of Simpson weights. #' @keywords internal -simpson_weights <- function(xind) { +simpson_weights <- function( + xind, + integration = c("simpson", "simpson_legacy") +) { + integration <- match.arg(integration) n <- length(xind) if (n < 2) return(1) - # pffr's formula: ((xind[n] - xind[1]) / n) / 3 * [1, 4, 2, ..., 1] - ((xind[n] - xind[1]) / n / 3) * c(1, rep(c(4, 2), length.out = n - 2), 1) + drop(compute_integration_weights(matrix(xind, nrow = 1), integration)) } #' Center beta(s,t) surface for ff identifiability diff --git a/man/compute_integration_weights.Rd b/man/compute_integration_weights.Rd index 3b02d722..6c1550ba 100644 --- a/man/compute_integration_weights.Rd +++ b/man/compute_integration_weights.Rd @@ -10,7 +10,8 @@ compute_integration_weights(xind, integration = "simpson") \item{xind}{Matrix of x-index values (n x nxgrid). Each row contains the evaluation points for one observation.} -\item{integration}{Character: "simpson", "trapezoidal", or "riemann".} +\item{integration}{Character: "simpson", "simpson_legacy", "trapezoidal", +or "riemann".} } \value{ Matrix of integration weights (n x nxgrid). @@ -20,4 +21,29 @@ Computes numerical integration weights using Simpson's rule, trapezoidal rule, or Riemann sums. Used by [ff()] and [sff()] for constructing functional regression terms. } +\details{ +`integration = "simpson"` implements *composite* Simpson's rule on an +equidistant grid with step `h = (b - a) / (nxgrid - 1)`; see +[simpson_pattern()] for the exact pattern used for odd and even `nxgrid`. +The weights sum to `b - a`, so a constant function integrates exactly. + +`integration = "simpson_legacy"` reproduces the (incorrect) weights used by +`ff()`/`sff()` up to refund 0.1-40: the pattern `[1, 4, 2, ..., 4, 1]` was +scaled by `(b - a) / (3 * nxgrid)` instead of `(b - a) / (3 * (nxgrid - 1))`, +and for even `nxgrid` the alternation ended in `2` before the closing `1`, +which composite Simpson does not allow. The resulting weights sum to less +than `b - a` (e.g. a constant on `[0, 1]` integrates to 0.956 at +`nxgrid = 30` and to 0.989 at `nxgrid = 93`), so estimated `ff()` surfaces +are rescaled by the reciprocal of that factor. This option is retained +**only** for reproducing results computed with older versions and is +deprecated; do not use it for new analyses. + +`integration = "trapezoidal"` is exact for constants (its weights sum to +`b - a`) and is the recommended rule for non-equidistant grids. +`integration = "riemann"` (used whenever `limits` is specified) attaches a +rectangle to every grid point including the left endpoint, so its weights +sum to `b - a` plus one mean grid spacing; this is inherent to a first-order +rule that gives all `nxgrid` points positive weight and is left unchanged +because the truncated-domain (`limits`) behaviour depends on it. +} \keyword{internal} diff --git a/man/ff.Rd b/man/ff.Rd index 937c45aa..2d73ea30 100644 --- a/man/ff.Rd +++ b/man/ff.Rd @@ -9,7 +9,7 @@ ff( yind = NULL, xind = seq(0, 1, l = ncol(X)), basistype = c("te", "t2", "ti", "s", "tes"), - integration = c("simpson", "trapezoidal", "riemann"), + integration = c("simpson", "trapezoidal", "riemann", "simpson_legacy"), L = NULL, limits = NULL, splinepars = if (basistype != "s") { @@ -40,7 +40,10 @@ bivariate basis functions (see \code{mgcv}'s \code{\link[mgcv]{s}}) or \item{integration}{method used for numerical integration. Defaults to \code{"simpson"}'s rule for calculating entries in \code{L}. Alternatively and for non-equidistant grids, \code{"trapezoidal"} or \code{"riemann"}. -\code{"riemann"} integration is always used if \code{limits} is specified} +\code{"riemann"} integration is always used if \code{limits} is specified. +\code{"simpson_legacy"} reproduces the mis-scaled Simpson weights used up +to refund 0.1-40 (see \code{\link{compute_integration_weights}}) and is +deprecated; it exists only to reproduce results from older versions.} \item{L}{optional: an n by \code{ncol(xind)} matrix giving the weights for the numerical integration over \eqn{s}.} diff --git a/man/sff.Rd b/man/sff.Rd index 40c09a0f..add97d37 100644 --- a/man/sff.Rd +++ b/man/sff.Rd @@ -9,7 +9,7 @@ sff( yind = NULL, xind = seq(0, 1, length.out = ncol(X)), basistype = c("te", "t2", "s"), - integration = c("simpson", "trapezoidal"), + integration = c("simpson", "trapezoidal", "simpson_legacy"), L = NULL, limits = NULL, splinepars = list(bs = "ps", m = c(2, 2, 2)) @@ -34,7 +34,10 @@ alternative parameterization of tensor product splines (see \item{integration}{method used for numerical integration. Defaults to \code{"simpson"}'s rule. Alternatively and for non-equidistant grids, -\code{"trapezoidal"}.} +\code{"trapezoidal"}. \code{"simpson_legacy"} reproduces the mis-scaled +Simpson weights used up to refund 0.1-40 (see +\code{\link{compute_integration_weights}}) and is deprecated; it exists +only to reproduce results from older versions.} \item{L}{optional: an n by \code{ncol(xind)} giving the weights for the numerical integration over \eqn{s}.} diff --git a/man/simpson_pattern.Rd b/man/simpson_pattern.Rd new file mode 100644 index 00000000..bb217dfa --- /dev/null +++ b/man/simpson_pattern.Rd @@ -0,0 +1,31 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/pffr-utilities.R +\name{simpson_pattern} +\alias{simpson_pattern} +\title{Simpson weight pattern for an equidistant grid of `nxgrid` points} +\usage{ +simpson_pattern(nxgrid) +} +\arguments{ +\item{nxgrid}{Integer number of grid points.} +} +\value{ +Numeric vector of length `nxgrid` summing to `max(nxgrid - 1, 1)`. +} +\description{ +Returns the dimensionless composite-Simpson weight pattern for `nxgrid` +equidistant points, in units of the step size `h`. The pattern sums to +`nxgrid - 1`, so that multiplying it by `h = (b - a) / (nxgrid - 1)` yields +weights summing to `b - a`. +} +\details{ +Cases: +* odd `nxgrid >= 3`: classical composite Simpson, `[1, 4, 2, ..., 4, 1] / 3`. +* even `nxgrid >= 4`: composite Simpson on the first `nxgrid - 3` points + plus Simpson's 3/8 rule on the last three intervals. This keeps the rule + exact for cubic polynomials for every `nxgrid >= 3` (a trapezoidal + closing interval would degrade the even-`nxgrid` rule to second order). +* `nxgrid == 2`: trapezoidal rule. +* `nxgrid == 1`: degenerate, weight 1 (no integration possible). +} +\keyword{internal} diff --git a/man/simpson_weights.Rd b/man/simpson_weights.Rd index 307afc45..cbd08597 100644 --- a/man/simpson_weights.Rd +++ b/man/simpson_weights.Rd @@ -4,17 +4,24 @@ \alias{simpson_weights} \title{Compute Simpson integration weights} \usage{ -simpson_weights(xind) +simpson_weights(xind, integration = c("simpson", "simpson_legacy")) } \arguments{ \item{xind}{Numeric vector of evaluation points.} + +\item{integration}{Character: "simpson" (default) or "simpson_legacy".} } \value{ Numeric vector of Simpson weights. } \description{ -Computes Simpson's rule weights for numerical integration on an equidistant -grid. This matches pffr's default integration method in ff() terms. -Note: pffr uses (b-a)/n/3 (divides by grid length n), not (b-a)/(n-1)/3. +Computes composite Simpson's rule weights for numerical integration on an +equidistant grid. This matches the default integration method used by +[ff()] and [sff()] terms, i.e. the weights sum to `xind[n] - xind[1]`. +} +\details{ +`integration = "simpson_legacy"` returns the deprecated weights used by +refund <= 0.1-40, which were scaled by `(b - a) / (3 * n)` rather than +`(b - a) / (3 * (n - 1))`; see [compute_integration_weights()]. } \keyword{internal} diff --git a/tests/testthat/test-pffr-integration-weights.R b/tests/testthat/test-pffr-integration-weights.R new file mode 100644 index 00000000..8aa52e6f --- /dev/null +++ b/tests/testthat/test-pffr-integration-weights.R @@ -0,0 +1,208 @@ +#-------------------------------------- +# Numerical integration weights for ff()/sff() +#-------------------------------------- +# +# Regression tests for the Simpson weights used to build the integration +# operator L in ff()/sff(). Up to refund 0.1-40 the [1, 4, 2, ..., 4, 1] +# pattern was scaled by (b - a) / (3 * n) instead of (b - a) / (3 * (n - 1)), +# and for even n the alternation ended in 2 before the closing 1. The weights +# therefore summed to less than b - a (a constant on [0, 1] integrated to +# 0.956 at n = 30 and to 0.989 at n = 93), which rescaled estimated ff() +# surfaces by the reciprocal of that factor. + +grid_sizes <- c(2:12, 30, 31, 60, 61, 93) + +# f(x) = 1 + 2x - 3x^2 + 4x^3, with int_0^1 f = 1 + 1 - 1 + 1 = 2 +cubic <- function(x) 1 + 2 * x - 3 * x^2 + 4 * x^3 + +test_that("simpson weights integrate a constant exactly on [0, 1]", { + for (n in grid_sizes) { + w <- refund:::simpson_weights(seq(0, 1, length.out = n)) + expect_equal(sum(w), 1, tolerance = 1e-12, info = paste("n =", n)) + } +}) + +test_that("simpson weights integrate a constant exactly on a general interval", { + for (n in grid_sizes) { + xind <- seq(-2, 5, length.out = n) + w <- refund:::simpson_weights(xind) + expect_equal(sum(w), 7, tolerance = 1e-10, info = paste("n =", n)) + } +}) + +test_that("simpson weights integrate a cubic exactly for odd n", { + for (n in grid_sizes[grid_sizes %% 2 == 1]) { + xind <- seq(0, 1, length.out = n) + w <- refund:::simpson_weights(xind) + expect_equal( + sum(w * cubic(xind)), + 2, + tolerance = 1e-12, + info = paste("n =", n) + ) + } +}) + +test_that("the even-n closing rule (Simpson 3/8) is also exact for cubics", { + # even n uses composite Simpson on the first n - 3 points plus Simpson's + # 3/8 rule on the last three intervals, which stays exact for cubics + for (n in grid_sizes[grid_sizes %% 2 == 0 & grid_sizes >= 4]) { + xind <- seq(0, 1, length.out = n) + w <- refund:::simpson_weights(xind) + expect_equal( + sum(w * cubic(xind)), + 2, + tolerance = 1e-12, + info = paste("n =", n) + ) + } + # n = 2 falls back to the trapezoidal rule: exact for linear, not cubic + w2 <- refund:::simpson_weights(c(0, 1)) + expect_equal(unname(w2), c(0.5, 0.5)) +}) + +test_that("weight patterns match the textbook composite rules", { + expect_equal(refund:::simpson_pattern(3), c(1, 4, 1) / 3) + expect_equal(refund:::simpson_pattern(5), c(1, 4, 2, 4, 1) / 3) + expect_equal(refund:::simpson_pattern(4), c(1, 3, 3, 1) * 3 / 8) + expect_equal( + refund:::simpson_pattern(6), + c(1, 4, 1, 0, 0, 0) / 3 + c(0, 0, 1, 3, 3, 1) * 3 / 8 + ) + # pattern sums to n - 1 so that h * pattern sums to b - a + for (n in grid_sizes) { + expect_equal( + sum(refund:::simpson_pattern(n)), + n - 1, + tolerance = 1e-12, + info = paste("n =", n) + ) + } +}) + +test_that("compute_integration_weights() handles matrix input row-wise", { + xind <- rbind( + seq(0, 1, length.out = 31), + seq(2, 5, length.out = 31) + ) + L <- refund:::compute_integration_weights(xind, "simpson") + expect_equal(dim(L), c(2L, 31L)) + expect_equal(rowSums(L), c(1, 3), tolerance = 1e-12) + expect_equal(L[1, ], refund:::simpson_weights(xind[1, ])) + expect_equal(L[2, ], refund:::simpson_weights(xind[2, ])) +}) + +test_that("trapezoidal weights also sum to the length of the domain", { + equi <- rbind(seq(0, 1, length.out = 30), seq(0, 1, length.out = 30)) + expect_equal( + rowSums(refund:::compute_integration_weights(equi, "trapezoidal")), + c(1, 1), + tolerance = 1e-12 + ) + noneq <- rbind(sort(c(0, 1, runif(28))), sort(c(0, 1, runif(28)))) + expect_equal( + rowSums(refund:::compute_integration_weights(noneq, "trapezoidal")), + c(1, 1), + tolerance = 1e-12 + ) +}) + +test_that("simpson_legacy reproduces the pre-0.1-41 weights", { + legacy_sum <- function(n) { + sum(refund:::simpson_weights(seq(0, 1, length.out = n), "simpson_legacy")) + } + # documented effect sizes for a constant on [0, 1] + expect_equal(legacy_sum(30), 0.9556, tolerance = 1e-4) + expect_equal(legacy_sum(60), 0.9778, tolerance = 1e-4) + expect_equal(legacy_sum(31), 0.9677, tolerance = 1e-4) + expect_equal(legacy_sum(61), 0.9836, tolerance = 1e-4) + expect_equal(legacy_sum(93), 0.9892, tolerance = 1e-4) + + # verbatim old formula: ((b - a) / n) / 3 * [1, 4, 2, ..., 4/2, 1] + for (n in grid_sizes) { + xind <- seq(0, 1, length.out = n) + old <- ((xind[n] - xind[1]) / n / 3) * + c(1, rep(c(4, 2), length.out = n - 2), 1) + expect_equal( + refund:::simpson_weights(xind, "simpson_legacy"), + old, + info = paste("n =", n) + ) + } + # for odd n the fix is a pure rescaling by n / (n - 1) + for (n in c(31, 61, 93)) { + xind <- seq(0, 1, length.out = n) + expect_equal( + refund:::simpson_weights(xind), + refund:::simpson_weights(xind, "simpson_legacy") * n / (n - 1) + ) + } +}) + +test_that("compute_integration_weights() rejects unknown methods", { + expect_error( + refund:::compute_integration_weights( + matrix(seq(0, 1, length.out = 5), nrow = 1), + "midpoint" + ), + "Unknown `integration` method" + ) +}) + +test_that("ff() exposes integration = 'simpson_legacy'", { + set.seed(20260918) + xind <- seq(0, 1, length.out = 31) + X <- matrix(rnorm(20 * 31), 20, 31) + + trm_new <- ff(X, xind = xind, check.ident = FALSE) + trm_old <- ff( + X, + xind = xind, + integration = "simpson_legacy", + check.ident = FALSE + ) + + expect_equal(rowSums(trm_new$L), rep(1, 20), tolerance = 1e-12) + expect_equal(trm_old$L[1, ], refund:::simpson_weights(xind, "simpson_legacy")) + expect_equal(trm_old$L / trm_new$L, matrix(30 / 31, 20, 31)) +}) + +test_that("the ff() surface is rescaled by the old/new weight-sum ratio", { + skip_on_cran() + set.seed(20260918) + nxgrid <- 31 + dat <- pffr_simulate( + Y ~ ff(X1, xind = s), + n = 40, + nxgrid = nxgrid, + nygrid = 20, + SNR = 20, + effects = list(X1 = "cosine") + ) + yind <- attr(dat, "yindex") + xind <- attr(dat, "xindex") + + fit_new <- pffr( + Y ~ ff(X1, xind = xind), + yind = yind, + data = dat, + sandwich = "none" + ) + fit_old <- pffr( + Y ~ ff(X1, xind = xind, integration = "simpson_legacy"), + yind = yind, + data = dat, + sandwich = "none" + ) + + # identical fits, only the parameterisation of beta(s, t) changes + expect_equal(fitted(fit_new), fitted(fit_old), tolerance = 1e-8) + + beta_new <- coef(fit_new, n1 = 12, n2 = 12, n3 = 12)$smterms[["ff(X1)"]]$value + beta_old <- coef(fit_old, n1 = 12, n2 = 12, n3 = 12)$smterms[["ff(X1)"]]$value + + # sum(w_legacy) / sum(w_simpson) = (n - 1) / n for odd n + ratio <- (nxgrid - 1) / nxgrid + expect_gt(max(abs(beta_old)), 0.1) + expect_equal(beta_new, beta_old * ratio, tolerance = 1e-5) +}) From d3b6c9a9cd120f77a11917f5e646db24b64cbea9 Mon Sep 17 00:00:00 2001 From: fabian-s Date: Fri, 18 Sep 2026 11:02:55 +0200 Subject: [PATCH 2/5] Declare test-only dependencies and stop tracking Rplots.pdf numDeriv and withr are used in tests/testthat/ but were not declared, which R CMD check reports as a WARNING ("'::' or ':::' imports not declared from"). Add both to Suggests:. tests/testthat/Rplots.pdf is a byproduct of the default graphics device opened during the test run, so it shows up as modified after every check. Untrack it and add it to .gitignore. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QSoB6DyAfeyS9PytuzQs3x --- .gitignore | 3 ++- DESCRIPTION | 4 +++- tests/testthat/Rplots.pdf | Bin 102820 -> 0 bytes 3 files changed, 5 insertions(+), 2 deletions(-) delete mode 100644 tests/testthat/Rplots.pdf diff --git a/.gitignore b/.gitignore index 998e5831..5d9d9c24 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,5 @@ .RData refund.Rproj *~ -\#*\# \ No newline at end of file +\#*\# +tests/testthat/Rplots.pdf diff --git a/DESCRIPTION b/DESCRIPTION index a600274d..dcf033e9 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -48,12 +48,14 @@ Imports: methods Suggests: knitr, + numDeriv, RColorBrewer, clubSandwich, reshape2, rmarkdown, sandwich, - testthat + testthat, + withr VignetteBuilder: knitr Description: Methods for regression for functional data, including function-on-scalar, scalar-on-function, and diff --git a/tests/testthat/Rplots.pdf b/tests/testthat/Rplots.pdf deleted file mode 100644 index ae609c10dcb5a090402a7027a733a91f42f19299..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 102820 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 From 0f66a1f043d828bb7c148c14a4bb59b48b54d526 Mon Sep 17 00:00:00 2001 From: fabian-s Date: Fri, 18 Sep 2026 11:03:46 +0200 Subject: [PATCH 3/5] Pin the quasi-family dispersion convention in the cluster sandwich (S-F) quasipoisson()/quasibinomial() already flow through the exact GLM score path: pffr_score_kind() returns "exact" for them, so both gam_sandwich_cluster() and build_cl2_working_standard() build s_i = x_i w_i (y_i - mu_i) (dmu/deta)_i / (phi-hat V(mu_i)) with phi-hat = fit$sig2, and mgcv's bread carries the same phi-hat once, V_p = phi-hat (X'WX + S)^{-1}. The meat therefore scales as phi^-2, the bread squared as phi^2, and the sampling core c V_p M V_p is dispersion-free: at a shared lambda it is numerically identical (1e-15 relative) to the fixed-dispersion fit's. The additive B_2 = V_p - V_e allowance scales with phi-hat, consistently with the S/phi penalty convention, so V(quasi) - V(fixed) = (phi-hat - 1) B_2(fixed). No code change was needed. Adds tests/testthat/test-pffr-quasi-score.R, which asserts all of this as a known answer at 1e-8 relative on a fixed-sp refit pair (quasipoisson vs poisson, quasibinomial vs binomial): the per-cluster score sums differ by exactly 1/phi-hat, V_p and V_e by exactly phi-hat, the CR1 and CL2 sampling cores agree, the CL2/CR1 inflation and the hat leverages are dispersion-free, sandwich = "auto" promotes quasi families to CL2 just like their fixed-dispersion counterparts (unlike the "approx" extended families), and coef(fit, sandwich = "cl2") returns finite positive SEs on a quasipoisson fit. Mutation-checked: dropping phi-hat from the score, or squaring it, fails the CR1 and CL2 assertions. Also documents the convention in pffr_score_kind()'s "exact" branch. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QSoB6DyAfeyS9PytuzQs3x --- R/pffr-core.R | 9 +- man/pffr_score_kind.Rd | 9 +- tests/testthat/test-pffr-quasi-score.R | 257 +++++++++++++++++++++++++ 3 files changed, 273 insertions(+), 2 deletions(-) create mode 100644 tests/testthat/test-pffr-quasi-score.R diff --git a/R/pffr-core.R b/R/pffr-core.R index 146820a8..5bd34c7b 100644 --- a/R/pffr-core.R +++ b/R/pffr-core.R @@ -1034,7 +1034,14 @@ build_cluster_id <- function(pffr_meta, cluster = NULL) { #' `poisson`, `binomial`, `Gamma`, `inverse.gaussian`, quasi-families). #' Their generic working-residual score #' \eqn{(y-\mu)\,(\mathrm{d}\mu/\mathrm{d}\eta)/(\phi V(\mu))} is the exact -#' log-likelihood score, so no approximation is involved.} +#' log-likelihood score, so no approximation is involved. For the +#' quasi-families the estimated \eqn{\hat\phi} (`fit$sig2`) enters this +#' score once and the bread \eqn{V_p = \hat\phi (X'WX + S)^{-1}} once, so it +#' cancels from the sampling core \eqn{V_p M V_p} (which is therefore +#' identical to the fixed-dispersion fit's at the same \eqn{\lambda}) while +#' the additive \eqn{B_2 = V_p - V_e} allowance scales with \eqn{\hat\phi}, +#' consistently with the \eqn{S/\hat\phi} penalty convention; see +#' `tests/testthat/test-pffr-quasi-score.R`.} #' \item{`"approx"`}{An extended family (`nb`, `tw`, `betar`, `ocat`, ...) #' that is neither scaled-t nor location-scale. The generic #' working-residual score is only an exponential-family approximation to the diff --git a/man/pffr_score_kind.Rd b/man/pffr_score_kind.Rd index 146c550d..d2fcceaf 100644 --- a/man/pffr_score_kind.Rd +++ b/man/pffr_score_kind.Rd @@ -29,7 +29,14 @@ a given family. The returned label drives the dispatch in `poisson`, `binomial`, `Gamma`, `inverse.gaussian`, quasi-families). Their generic working-residual score \eqn{(y-\mu)\,(\mathrm{d}\mu/\mathrm{d}\eta)/(\phi V(\mu))} is the exact - log-likelihood score, so no approximation is involved.} + log-likelihood score, so no approximation is involved. For the + quasi-families the estimated \eqn{\hat\phi} (`fit$sig2`) enters this + score once and the bread \eqn{V_p = \hat\phi (X'WX + S)^{-1}} once, so it + cancels from the sampling core \eqn{V_p M V_p} (which is therefore + identical to the fixed-dispersion fit's at the same \eqn{\lambda}) while + the additive \eqn{B_2 = V_p - V_e} allowance scales with \eqn{\hat\phi}, + consistently with the \eqn{S/\hat\phi} penalty convention; see + `tests/testthat/test-pffr-quasi-score.R`.} \item{`"approx"`}{An extended family (`nb`, `tw`, `betar`, `ocat`, ...) that is neither scaled-t nor location-scale. The generic working-residual score is only an exponential-family approximation to the diff --git a/tests/testthat/test-pffr-quasi-score.R b/tests/testthat/test-pffr-quasi-score.R new file mode 100644 index 00000000..50d3e544 --- /dev/null +++ b/tests/testthat/test-pffr-quasi-score.R @@ -0,0 +1,257 @@ +#-------------------------------------- +# S-F: quasi-likelihood families through the exact GLM score path +#-------------------------------------- +# +# quasipoisson()/quasibinomial() carry an estimated dispersion phi-hat +# (`fit$sig2`). The cluster-robust sandwich must use it exactly once, and these +# tests pin the resulting known answer. +# +# Write W = diag(omega_i (dmu/deta)_i^2 / V(mu_i)) (dispersion-free) and +# S = sum_j lambda_j S_j. mgcv's bread is +# V_p = phi (X'WX + S)^{-1} = (X'WX/phi + S/phi)^{-1}, +# i.e. the Fisher information X'WX/phi penalized by S/phi, so +# V_p(quasi) = phi-hat * V_p(fixed-dispersion fit) [at the same lambda] +# and likewise for V_e. The per-observation score used by both cluster paths +# (gam_sandwich_cluster() and build_cl2_working_standard()) is +# s_i = x_i omega_i (y_i - mu_i) (dmu/deta)_i / (phi V(mu_i)), +# so the meat M = sum_g U_g U_g' scales as phi^-2. Hence the sampling core +# c V_p M V_p ~ phi^2 * phi^-2 = phi^0 +# is DISPERSION-FREE: the quasi fit's CR1/CL2 sampling core is *identical* to +# the fixed-dispersion fit's at the same smoothing parameters. The additive +# Bayesian smoothing-bias allowance B_2 = V_p - V_e is a model-based quantity +# and scales with phi-hat, consistently with the penalty convention S/phi +# above. So the full Bayesian sandwich satisfies +# V(quasi) - V(fixed) = (phi-hat - 1) * B_2(fixed). +# Per-cluster leverage H_gg = Xw_g V_p Xw_g' is dispersion-free too +# (Xw ~ phi^-1/2, V_p ~ phi), so CL2's leverage adjustment is unchanged. +# +# Both fits below are refitted at the SAME fixed `sp`, so lambda is shared and +# the identities hold to machine precision (a fresh IRLS run in each case; a +# warm-started refit only converges to the fit's own tolerance). + +quasi_env <- new.env(parent = emptyenv()) + +# Overdispersed counts on a functional-covariate design, small enough to fit +# fast and with effective rank(X1) above the ff() identifiability guard. +make_quasi_count_fixture <- function() { + if (!is.null(quasi_env$count)) { + return(quasi_env$count) + } + set.seed(7) + G <- 12 + ns <- 10 + ny <- 12 + sgrid <- seq(0, 1, length.out = ns) + tgrid <- seq(0, 1, length.out = ny) + X1 <- matrix(rnorm(G * ns), nrow = G, ncol = ns) + beta <- outer(sgrid, tgrid, function(s, t) 0.6 * cos(pi * s) * (1 + t)) + mu <- exp(1.2 + (X1 %*% beta) / ns) + # negative-binomial draws => genuine overdispersion => phi-hat clearly > 1 + Y <- matrix( + rnbinom(length(mu), mu = as.vector(mu), size = as.vector(mu) / 1.5), + nrow = G, + ncol = ny + ) + quasi_env$count <- list( + data = list(Y = Y, X1 = X1), + sgrid = sgrid, + tgrid = tgrid, + G = G + ) + quasi_env$count +} + +make_quasi_binary_fixture <- function() { + if (!is.null(quasi_env$binary)) { + return(quasi_env$binary) + } + set.seed(11) + G <- 12 + ns <- 10 + ny <- 12 + sgrid <- seq(0, 1, length.out = ns) + tgrid <- seq(0, 1, length.out = ny) + X1 <- matrix(rnorm(G * ns), nrow = G, ncol = ns) + beta <- outer(sgrid, tgrid, function(s, t) 1.5 * cos(pi * s) * (1 + t)) + p <- plogis(0.2 + (X1 %*% beta) / ns) + Y <- matrix(rbinom(length(p), 1, as.vector(p)), nrow = G, ncol = ny) + quasi_env$binary <- list( + data = list(Y = Y, X1 = X1), + sgrid = sgrid, + tgrid = tgrid, + G = G + ) + quasi_env$binary +} + +fit_quasi_fixture <- function(fx, family, sp = NULL) { + args <- list( + formula = Y ~ + ff( + X1, + xind = fx$sgrid, + splinepars = list( + bs = "ps", + k = c(5, 5), + m = list(c(2, 1), c(2, 1)) + ) + ), + data = fx$data, + yind = fx$tgrid, + family = family, + sandwich = "none", + bs.yindex = list(bs = "ps", k = 5, m = c(2, 1)) + ) + if (!is.null(sp)) { + args$sp <- sp + } + suppressWarnings(do.call(pffr, args)) +} + +# One fixed-dispersion fit selects lambda; BOTH arms are then refitted at that +# sp, so the two fits differ only in the dispersion treatment. +get_quasi_pair <- function(which = c("count", "binary")) { + which <- match.arg(which) + if (!is.null(quasi_env[[paste0("pair_", which)]])) { + return(quasi_env[[paste0("pair_", which)]]) + } + if (which == "count") { + fx <- make_quasi_count_fixture() + fixed_family <- poisson() + quasi_family <- quasipoisson() + } else { + fx <- make_quasi_binary_fixture() + fixed_family <- binomial() + quasi_family <- quasibinomial() + } + sp <- fit_quasi_fixture(fx, fixed_family)$sp + pair <- list( + fx = fx, + fixed = fit_quasi_fixture(fx, fixed_family, sp = sp), + quasi = fit_quasi_fixture(fx, quasi_family, sp = sp) + ) + quasi_env[[paste0("pair_", which)]] <- pair + pair +} + +rel_diff <- function(a, b) max(abs(a - b)) / max(abs(b)) + +test_that("quasi families are classified as an exact GLM score path", { + expect_identical(refund:::pffr_score_kind(quasipoisson()), "exact") + expect_identical(refund:::pffr_score_kind(quasibinomial()), "exact") + expect_true(refund:::family_has_exact_score(quasipoisson())) + expect_true(refund:::family_has_exact_score(quasibinomial())) +}) + +test_that("sandwich = 'auto' routes quasi families through the exact path", { + # (c): quasi families are promoted to CL2 exactly like their fixed-dispersion + # counterparts, and are NOT lumped in with the "approx" extended families. + expect_identical( + refund:::pffr_sandwich_auto_policy(12, 12, quasipoisson()), + refund:::pffr_sandwich_auto_policy(12, 12, poisson()) + ) + expect_identical( + refund:::pffr_sandwich_auto_policy(12, 12, quasipoisson()), + "cl2" + ) + expect_identical( + refund:::pffr_sandwich_auto_policy(12, 12, quasibinomial()), + "cl2" + ) + # contrast: an extended family with only the working-residual APPROXIMATION + expect_identical( + refund:::pffr_sandwich_auto_policy(12, 12, mgcv::nb()), + "cluster" + ) +}) + +test_that("the quasi score carries phi-hat exactly once", { + pair <- get_quasi_pair("count") + phi <- pair$quasi$sig2 + expect_gt(phi, 1.2) + expect_equal(pair$fixed$sig2, 1) + # same lambda, same IRLS solution + expect_equal( + unname(pair$quasi$coefficients), + unname(pair$fixed$coefficients), + tolerance = 1e-10 + ) + + strip <- function(b) { + class(b) <- setdiff(class(b), "pffr") + b + } + cid <- refund:::build_cluster_id(pair$fixed$pffr) + # the exact score path is taken: no working-residual disclosure fires + expect_no_warning( + refund:::gam_sandwich_cluster(strip(pair$quasi), cid) + ) + wq <- refund:::build_cl2_working_standard(strip(pair$quasi), cid) + wf <- refund:::build_cl2_working_standard(strip(pair$fixed), cid) + # per-cluster score sums U_g = sum_i s_i: phi enters the denominator once + Uq <- rowsum(wq$Xw * wq$z, cid) + Uf <- rowsum(wf$Xw * wf$z, cid) + expect_lt(rel_diff(Uq, Uf / phi), 1e-8) + # ... and the bread carries it once, in the numerator + expect_lt(rel_diff(pair$quasi$Vp, phi * pair$fixed$Vp), 1e-8) + expect_lt(rel_diff(pair$quasi$Ve, phi * pair$fixed$Ve), 1e-8) +}) + +test_that("quasipoisson CR1 sampling core equals the Poisson CR1 core", { + # (a): B M B with B ~ phi and M ~ phi^-2 => the core is phi^0, i.e. the two + # covariances are IDENTICAL, not rescaled. + pair <- get_quasi_pair("count") + phi <- pair$quasi$sig2 + Vq <- refund:::pffr_vcov(pair$quasi, sandwich = "cluster", freq = TRUE) + Vf <- refund:::pffr_vcov(pair$fixed, sandwich = "cluster", freq = TRUE) + expect_lt(rel_diff(Vq, Vf), 1e-8) + + # the full Bayesian sandwich differs only through the phi-scaled B2 term + Bq <- refund:::pffr_vcov(pair$quasi, sandwich = "cluster") + Bf <- refund:::pffr_vcov(pair$fixed, sandwich = "cluster") + B2_fixed <- pair$fixed$Vp - pair$fixed$Ve + expect_lt(rel_diff(Bq - Bf, (phi - 1) * B2_fixed), 1e-8) + expect_lt(rel_diff(Bq, Vf + phi * B2_fixed), 1e-8) +}) + +test_that("quasibinomial CR1 sampling core equals the binomial CR1 core", { + # (b) + pair <- get_quasi_pair("binary") + phi <- pair$quasi$sig2 + expect_false(isTRUE(all.equal(phi, 1, tolerance = 1e-3))) + Vq <- refund:::pffr_vcov(pair$quasi, sandwich = "cluster", freq = TRUE) + Vf <- refund:::pffr_vcov(pair$fixed, sandwich = "cluster", freq = TRUE) + expect_lt(rel_diff(Vq, Vf), 1e-8) + + Bq <- refund:::pffr_vcov(pair$quasi, sandwich = "cluster") + Bf <- refund:::pffr_vcov(pair$fixed, sandwich = "cluster") + B2_fixed <- pair$fixed$Vp - pair$fixed$Ve + expect_lt(rel_diff(Bq - Bf, (phi - 1) * B2_fixed), 1e-8) +}) + +test_that("CL2 works for quasi fits and its leverage is dispersion-free", { + # (d) + pair <- get_quasi_pair("count") + cl_q <- refund:::pffr_vcov(pair$quasi, sandwich = "cl2", freq = TRUE) + cl_f <- refund:::pffr_vcov(pair$fixed, sandwich = "cl2", freq = TRUE) + cr_q <- refund:::pffr_vcov(pair$quasi, sandwich = "cluster", freq = TRUE) + cr_f <- refund:::pffr_vcov(pair$fixed, sandwich = "cluster", freq = TRUE) + # leverage H_gg = Xw_g Vp Xw_g' is phi-free, so the CL2 inflation matches + expect_lt(rel_diff(cl_q, cl_f), 1e-8) + expect_lt( + max(abs(sqrt(diag(cl_q) / diag(cr_q)) - sqrt(diag(cl_f) / diag(cr_f)))), + 1e-8 + ) + expect_equal( + attr(cl_q, "max_obs_leverage"), + attr(cl_f, "max_obs_leverage"), + tolerance = 1e-8 + ) + + # coef(fit, sandwich = "cl2") returns finite SEs on a quasipoisson fit + cf <- suppressWarnings(coef(pair$quasi, sandwich = "cl2")) + ses <- unlist(lapply(cf$smterms, function(s) s$coef$se)) + expect_gt(length(ses), 0) + expect_true(all(is.finite(ses))) + expect_true(all(ses > 0)) +}) From 4e00a0ae0395e82a3911b6d23e321de2f0147bf1 Mon Sep 17 00:00:00 2001 From: fabian-s Date: Fri, 18 Sep 2026 11:20:26 +0200 Subject: [PATCH 4/5] Warn on cluster-robust intervals with too few clusters (plan S-C) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pffr() now emits a single warning of class "pffr_small_G_warning" (via warningCondition()) when the resolved sandwich is "cluster" or "cl2" and the number of clusters G is below 40 -- the threshold is the paper's own recommendation boundary for the refitting curve bootstrap pffr_coefboot() (PLAN-revision-2026-09-05.md, S-C amendment 2026-09-08: "Warn below G = 40, the paper's own recommendation boundary, not 20"), confirmed by the paper text itself (paper-C.qmd: "It is our recommendation below Gā‰ˆ40, where no plug-in interval is adequate"). The warning fires once, in apply_sandwich_correction() at fit time; coef.pffr()/plot.pffr()/predict.pffr() recompute the robust covariance via pffr_vcov() without calling apply_sandwich_correction() again, so it is not repeated. sandwich = "none"/"hc" never trigger it. Documented in ?pffr under the sandwich argument and in NEWS.md. Added tests/testthat/test-pffr-small-g.R (class fires/doesn't fire at small vs. large G, never for sandwich = "none", not repeated by coef()). Several existing small-G fixtures across test-pffr-ar.R, test-pffr-inference-workflow.R, test-pffr-s2-autopolicy.R, test-pffr-sandwich-ablation.R, test-pffr-sandwich-refit.R, test-pffr-sandwich-storage.R, test-pffr-satterthwaite.R and test-pffr.R deliberately fit at small G to test unrelated mechanics (AR(1) wiring, cache invalidation, autopolicy resolution, argument plumbing); muffled the new warning there via a shared quiet_pffr() helper (tests/testthat/helper-small-g.R) using withCallingHandlers(), or raised G to >=40 where the small size was incidental rather than the point of the test. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01QSoB6DyAfeyS9PytuzQs3x --- NEWS.md | 10 ++ R/pffr-core.R | 25 +++++ R/pffr.R | 10 ++ man/pffr.Rd | 10 ++ tests/testthat/helper-small-g.R | 17 +++ tests/testthat/test-pffr-ar.R | 10 +- tests/testthat/test-pffr-inference-workflow.R | 8 +- tests/testthat/test-pffr-s2-autopolicy.R | 15 ++- tests/testthat/test-pffr-sandwich-ablation.R | 2 +- tests/testthat/test-pffr-sandwich-refit.R | 18 ++- tests/testthat/test-pffr-sandwich-storage.R | 2 +- tests/testthat/test-pffr-satterthwaite.R | 2 +- tests/testthat/test-pffr-small-g.R | 103 ++++++++++++++++++ tests/testthat/test-pffr.R | 50 +++++---- 14 files changed, 236 insertions(+), 46 deletions(-) create mode 100644 tests/testthat/helper-small-g.R create mode 100644 tests/testthat/test-pffr-small-g.R diff --git a/NEWS.md b/NEWS.md index ba87a9aa..9c0004bf 100644 --- a/NEWS.md +++ b/NEWS.md @@ -72,6 +72,16 @@ leverage geometry, so it has nothing to monitor and stays silent. It is built from the same bread, though, so switching to it is not a remedy -- it only removes the diagnostic. Inspect and refit the model. +* `pffr()` now warns once, at fit time, when `sandwich` resolves to + `"cluster"` or `"cl2"` and the number of clusters `G` is below 40: "Only + G = clusters: cluster-robust intervals undercover at this size (paper + benchmark: CL2 ~0.77-0.79 at G = 20 under dependence). Consider the + refitting curve bootstrap `pffr_coefboot()` or wider nominal levels." The + warning has class `"pffr_small_G_warning"` (via `warningCondition()`) so it + can be muffled with `withCallingHandlers()`/`suppressWarnings(classes = + "pffr_small_G_warning")`; it is not repeated by `coef.pffr()`, + `plot.pffr()`, or `predict.pffr()`, and never fires for `sandwich = + "none"`/`"hc"`. * `ff(..., check.ident = TRUE)` (the default) now also warns when the effective rank of the functional covariate's covariance is below `1.5 * k_s`, where `k_s` is the marginal basis dimension along `s`. The diff --git a/R/pffr-core.R b/R/pffr-core.R index 146820a8..35a9195c 100644 --- a/R/pffr-core.R +++ b/R/pffr-core.R @@ -3180,6 +3180,31 @@ apply_sandwich_correction <- function( cluster_rank = if (!is.null(core)) core$diagnostics$rank else NULL, storage_format = PFFR_COV_STORAGE_FORMAT ) + # Small-cluster-count guard (plan S-C, amended 2026-09-08: warn below G = + # 40, the paper's own recommendation boundary for pffr_coefboot(), not the + # earlier G = 20 draft threshold). Only for the two cluster-robust + # estimators; "hc" and "none" never reach a meaningful G here. Fires once, + # at fit time in pffr() -- pffr_vcov() (coef/predict/plot) recomputes the + # robust covariance without calling this function again, so the warning is + # not repeated on every accessor call. + G_resolved <- gam_obj$pffr$sandwich_info$G + if ( + type %in% c("cluster", "cl2") && is.finite(G_resolved) && G_resolved < 40 + ) { + warning(warningCondition( + sprintf( + paste0( + "Only G = %d clusters: cluster-robust intervals undercover at ", + "this size (paper benchmark: CL2 ~0.77-0.79 at G = 20 under ", + "dependence). Consider the refitting curve bootstrap ", + "pffr_coefboot() or wider nominal levels." + ), + G_resolved + ), + class = "pffr_small_G_warning" + )) + } + # Keep the legacy CL2 leverage-cap diagnostic slot populated. gam_obj$pffr$cl2_n_capped <- if (type == "cl2") n_capped else NULL gam_obj$pffr$cl2_adjustment <- if (type == "cl2") resolved_adjustment else diff --git a/R/pffr.R b/R/pffr.R index 36c3933e..b4e8ef2b 100644 --- a/R/pffr.R +++ b/R/pffr.R @@ -192,6 +192,16 @@ #' experimental fitted-mean alternative. Its calibration and interval-width #' stability require separate validation, particularly at small G. #' +#' Small-\eqn{G} warning: when \code{sandwich} resolves to \code{"cluster"} +#' or \code{"cl2"} and the number of clusters \eqn{G} is below 40 (the +#' threshold below which the paper benchmark recommends the refitting curve +#' bootstrap instead of a plug-in Wald interval), \code{pffr()} emits a +#' single \code{\link{warning}} of class \code{"pffr_small_G_warning"}. It +#' fires once, at fit time; \code{\link{coef.pffr}}, \code{\link{plot.pffr}} +#' and \code{\link{predict.pffr}} do not repeat it. Muffle it explicitly +#' with \code{withCallingHandlers()}/\code{suppressWarnings(classes = +#' "pffr_small_G_warning")} if warranted. +#' #' Storage contract: the fit's model-based (Bayesian posterior) covariance #' matrices \code{$Vp}, \code{$Vc} and \code{$Ve} are \emph{always} left #' exactly as \code{\link[mgcv]{gam}} produced them; the robust covariance is diff --git a/man/pffr.Rd b/man/pffr.Rd index eb86154b..a51dd8ab 100644 --- a/man/pffr.Rd +++ b/man/pffr.Rd @@ -95,6 +95,16 @@ penalty.} experimental fitted-mean alternative. Its calibration and interval-width stability require separate validation, particularly at small G. + Small-\eqn{G} warning: when \code{sandwich} resolves to \code{"cluster"} + or \code{"cl2"} and the number of clusters \eqn{G} is below 40 (the + threshold below which the paper benchmark recommends the refitting curve + bootstrap instead of a plug-in Wald interval), \code{pffr()} emits a + single \code{\link{warning}} of class \code{"pffr_small_G_warning"}. It + fires once, at fit time; \code{\link{coef.pffr}}, \code{\link{plot.pffr}} + and \code{\link{predict.pffr}} do not repeat it. Muffle it explicitly + with \code{withCallingHandlers()}/\code{suppressWarnings(classes = + "pffr_small_G_warning")} if warranted. + Storage contract: the fit's model-based (Bayesian posterior) covariance matrices \code{$Vp}, \code{$Vc} and \code{$Ve} are \emph{always} left exactly as \code{\link[mgcv]{gam}} produced them; the robust covariance is diff --git a/tests/testthat/helper-small-g.R b/tests/testthat/helper-small-g.R new file mode 100644 index 00000000..6715098f --- /dev/null +++ b/tests/testthat/helper-small-g.R @@ -0,0 +1,17 @@ +#-------------------------------------- +# Helper: muffle the S-C small-cluster-count guard (pffr_small_G_warning) for +# fixtures across the suite that deliberately use few clusters to keep fits +# fast while testing mechanics unrelated to sandwich calibration (AR(1) +# wiring, cache invalidation, autopolicy resolution, argument plumbing, +# etc.). Any OTHER warning from the wrapped call still propagates normally. +# The guard's own behaviour is exercised directly in test-pffr-small-g.R, +# which installs its own (more specific, innermost) handler and is +# unaffected by this helper. +#-------------------------------------- + +quiet_pffr <- function(...) { + withCallingHandlers( + pffr(...), + pffr_small_G_warning = function(w) invokeRestart("muffleWarning") + ) +} diff --git a/tests/testthat/test-pffr-ar.R b/tests/testthat/test-pffr-ar.R index 3c7b685b..37a8fe74 100644 --- a/tests/testthat/test-pffr-ar.R +++ b/tests/testthat/test-pffr-ar.R @@ -7,7 +7,7 @@ test_that("pffr builds AR.start for dense data when rho is supplied", { sim <- get_ar_data() tgrid <- attr(sim, "yindex") - fit <- pffr( + fit <- quiet_pffr( Y ~ c(1), data = sim, yind = tgrid, @@ -51,7 +51,7 @@ test_that("unsupported AR settings throw informative errors", { ) sim_bin <- sim sim_bin$Y <- I(1L * (sim$Y > 0)) - fit <- pffr( + fit <- quiet_pffr( Y ~ c(1), data = sim_bin, yind = tgrid, @@ -92,7 +92,7 @@ test_that("pffr AR fits match mgcv::bam on stacked data", { sim <- get_ar_data() tgrid <- attr(sim, "yindex") - fit <- pffr( + fit <- quiet_pffr( Y ~ c(1), data = sim, yind = tgrid, @@ -135,7 +135,7 @@ test_that("pffr builds AR.start for sparse responses", { order(sim_sparse$ydata$.obs, sim_sparse$ydata$.index), ] - fit_sparse <- pffr( + fit_sparse <- quiet_pffr( Y ~ s(xsmoo), data = sim_sparse$data, ydata = ydata, @@ -166,7 +166,7 @@ test_that("binomial models can use rho when discrete sampling is enabled", { df <- data.frame(Y = I(binary_Y)) tgrid <- seq(0, 1, length.out = ny) - fit_binom <- pffr( + fit_binom <- quiet_pffr( Y ~ c(1), data = df, yind = tgrid, diff --git a/tests/testthat/test-pffr-inference-workflow.R b/tests/testthat/test-pffr-inference-workflow.R index b69b8244..6aa6d3f3 100644 --- a/tests/testthat/test-pffr-inference-workflow.R +++ b/tests/testthat/test-pffr-inference-workflow.R @@ -7,7 +7,7 @@ testthat::test_that("subject clustering and covariance survive coef predict plot tt <- seq(0, 1, length.out = D) dat <- list(Y = matrix(rnorm(n * D), n, D), x = rnorm(n), subject = subject) dat$Y <- dat$Y + outer(dat$x, sin(2 * pi * tt)) - fit <- suppressMessages(refund::pffr( + fit <- suppressMessages(quiet_pffr( Y ~ x, yind = tt, data = dat, @@ -237,7 +237,7 @@ testthat::test_that("an explicit dof_correction override is not served from cach tt <- seq(0, 1, length.out = D) dat <- list(Y = matrix(rnorm(G * D), G, D), x = rnorm(G)) dat$Y <- dat$Y + outer(dat$x, sin(2 * pi * tt)) - fit <- suppressMessages(refund::pffr( + fit <- suppressMessages(quiet_pffr( Y ~ x, yind = tt, data = dat, @@ -280,7 +280,7 @@ testthat::test_that("missing interval limits do not break plot or summary", { tt <- seq(0, 1, length.out = D) dat <- list(Y = matrix(rnorm(G * D), G, D), x = rnorm(G)) dat$Y <- dat$Y + outer(dat$x, sin(2 * pi * tt)) - fit <- suppressMessages(refund::pffr( + fit <- suppressMessages(quiet_pffr( Y ~ x, yind = tt, data = dat, @@ -461,7 +461,7 @@ testthat::test_that("one coef() call warns at most once about undefined df", { tt <- seq(0, 1, length.out = D) dat <- list(Y = matrix(rnorm(G * D), G, D), x = rnorm(G)) dat$Y <- dat$Y + outer(dat$x, sin(2 * pi * tt)) - fit <- suppressMessages(refund::pffr( + fit <- suppressMessages(quiet_pffr( Y ~ x, yind = tt, data = dat, diff --git a/tests/testthat/test-pffr-s2-autopolicy.R b/tests/testthat/test-pffr-s2-autopolicy.R index 94f91a13..0077a1ce 100644 --- a/tests/testthat/test-pffr-s2-autopolicy.R +++ b/tests/testthat/test-pffr-s2-autopolicy.R @@ -174,7 +174,12 @@ test_that("sandwich='auto' resolves at fit time and messages once", { skip_on_cran() d <- make_auto_data() expect_message( - fit <- pffr(Y ~ ff(X1), data = d$dat, yind = d$yind, sandwich = "auto"), + fit <- quiet_pffr( + Y ~ ff(X1), + data = d$dat, + yind = d$yind, + sandwich = "auto" + ), "sandwich='auto' resolved to 'cl2' \\(G=25, max D_g=20\\)" ) # the resolved type is stored and served @@ -186,10 +191,10 @@ test_that("auto-resolved cl2 equals an explicit cl2 fit; explicit values intact" skip_on_cran() d <- make_auto_data() fit_auto <- suppressMessages( - pffr(Y ~ ff(X1), data = d$dat, yind = d$yind, sandwich = "auto") + quiet_pffr(Y ~ ff(X1), data = d$dat, yind = d$yind, sandwich = "auto") ) fit_cl2 <- suppressMessages( - pffr(Y ~ ff(X1), data = d$dat, yind = d$yind, sandwich = "cl2") + quiet_pffr(Y ~ ff(X1), data = d$dat, yind = d$yind, sandwich = "cl2") ) expect_equal( fit_auto$pffr$Vsandwich, @@ -198,7 +203,7 @@ test_that("auto-resolved cl2 equals an explicit cl2 fit; explicit values intact" ) # explicit choices still work and are stored verbatim fit_cluster <- suppressMessages( - pffr(Y ~ ff(X1), data = d$dat, yind = d$yind, sandwich = "cluster") + quiet_pffr(Y ~ ff(X1), data = d$dat, yind = d$yind, sandwich = "cluster") ) expect_identical(fit_cluster$pffr$sandwich_info$type, "cluster") fit_none <- suppressMessages( @@ -216,7 +221,7 @@ test_that("an autopolicy that forces cluster is honored at fit time", { list(refund.pffr.autopolicy = list(G_max = 10)), { fit <- suppressMessages( - pffr(Y ~ ff(X1), data = d$dat, yind = d$yind, sandwich = "auto") + quiet_pffr(Y ~ ff(X1), data = d$dat, yind = d$yind, sandwich = "auto") ) expect_identical(fit$pffr$sandwich_info$type, "cluster") } diff --git a/tests/testthat/test-pffr-sandwich-ablation.R b/tests/testthat/test-pffr-sandwich-ablation.R index 29fa3315..3d9bef2d 100644 --- a/tests/testthat/test-pffr-sandwich-ablation.R +++ b/tests/testthat/test-pffr-sandwich-ablation.R @@ -22,7 +22,7 @@ make_ablation_fit <- local({ yind <- attr(dat, "yindex") cache <<- list( fit_none = pffr(Y ~ ff(X1), data = dat, yind = yind, sandwich = "none"), - fit_cluster = pffr( + fit_cluster = quiet_pffr( Y ~ ff(X1), data = dat, yind = yind, diff --git a/tests/testthat/test-pffr-sandwich-refit.R b/tests/testthat/test-pffr-sandwich-refit.R index 13f4ec63..e0afe33b 100644 --- a/tests/testthat/test-pffr-sandwich-refit.R +++ b/tests/testthat/test-pffr-sandwich-refit.R @@ -24,7 +24,12 @@ test_that("sandwich recomputation on a corrected fit uses model-based bread", { yind <- attr(dat, "yindex") fit_none <- pffr(Y ~ ff(X1), data = dat, yind = yind, sandwich = "none") - fit_cluster <- pffr(Y ~ ff(X1), data = dat, yind = yind, sandwich = "cluster") + fit_cluster <- quiet_pffr( + Y ~ ff(X1), + data = dat, + yind = yind, + sandwich = "cluster" + ) # $Vp/$Vc/$Ve are the model-based matrices on BOTH fits; the robust # covariance is stored separately with its metadata. @@ -82,10 +87,13 @@ test_that("sandwich recomputation on a corrected fit uses model-based bread", { # re-applying apply_sandwich_correction is idempotent: $Vp untouched, # identical robust matrices - twice <- refund:::apply_sandwich_correction( - fit_cluster, - "gam", - type = "cluster" + twice <- withCallingHandlers( + refund:::apply_sandwich_correction( + fit_cluster, + "gam", + type = "cluster" + ), + pffr_small_G_warning = function(w) invokeRestart("muffleWarning") ) expect_identical(twice$Vp, fit_cluster$Vp) expect_identical(twice$Ve, fit_cluster$Ve) diff --git a/tests/testthat/test-pffr-sandwich-storage.R b/tests/testthat/test-pffr-sandwich-storage.R index beb7245b..f3f3b941 100644 --- a/tests/testthat/test-pffr-sandwich-storage.R +++ b/tests/testthat/test-pffr-sandwich-storage.R @@ -29,7 +29,7 @@ make_storage_fits <- local({ yind = yind, sandwich = "none" ), - fit_cluster = pffr( + fit_cluster = quiet_pffr( Y ~ ff(X1), data = dat, yind = yind, diff --git a/tests/testthat/test-pffr-satterthwaite.R b/tests/testthat/test-pffr-satterthwaite.R index e0ed56ba..bd77f36f 100644 --- a/tests/testthat/test-pffr-satterthwaite.R +++ b/tests/testthat/test-pffr-satterthwaite.R @@ -198,7 +198,7 @@ test_that("summary() reports median/min Satterthwaite df for cluster-robust fits dat <- get_basic_pffr_data() s <- attr(dat, "xindex") t <- attr(dat, "yindex") - m_cl2 <- pffr( + m_cl2 <- quiet_pffr( Y ~ ff(X1, xind = s) + xlin, yind = t, data = dat, diff --git a/tests/testthat/test-pffr-small-g.R b/tests/testthat/test-pffr-small-g.R new file mode 100644 index 00000000..1dcc9b4c --- /dev/null +++ b/tests/testthat/test-pffr-small-g.R @@ -0,0 +1,103 @@ +#-------------------------------------- +# Small-cluster-count warning (plan S-C, amended 2026-09-08: threshold is +# G < 40, the paper's own recommendation boundary for pffr_coefboot(), not +# the earlier G < 20 draft). pffr() warns once, at fit time, when the +# resolved sandwich is "cluster" or "cl2" and the number of clusters G is +# below 40. The warning carries class "pffr_small_G_warning" so it can be +# muffled by class. +#-------------------------------------- + +make_smallg_dat <- function(n, seed = 5150) { + pffr_simulate( + Y ~ xlin, + n = n, + nygrid = 25, + SNR = 5, + effects = list(xlin = "dnorm"), + seed = seed + ) +} + +test_that("cl2 at G < 40 emits exactly one pffr_small_G_warning", { + skip_on_cran() + dat <- make_smallg_dat(n = 15) + yind <- attr(dat, "yindex") + + warnings_seen <- character(0) + fit <- withCallingHandlers( + pffr(Y ~ xlin, data = dat, yind = yind, sandwich = "cl2"), + pffr_small_G_warning = function(w) { + warnings_seen <<- c(warnings_seen, conditionMessage(w)) + invokeRestart("muffleWarning") + } + ) + expect_length(warnings_seen, 1) + expect_match(warnings_seen, "Only G = 15 clusters") + expect_match(warnings_seen, "pffr_coefboot") + expect_identical(fit$pffr$sandwich_info$G, 15L) +}) + +test_that("cluster (CR1) at G < 40 emits exactly one pffr_small_G_warning", { + skip_on_cran() + dat <- make_smallg_dat(n = 12) + yind <- attr(dat, "yindex") + + expect_warning( + fit <- pffr(Y ~ xlin, data = dat, yind = yind, sandwich = "cluster"), + class = "pffr_small_G_warning" + ) + expect_identical(fit$pffr$sandwich_info$G, 12L) +}) + +test_that("cl2 at G >= 40 emits no pffr_small_G_warning", { + skip_on_cran() + dat <- make_smallg_dat(n = 45) + yind <- attr(dat, "yindex") + + fired <- FALSE + withCallingHandlers( + pffr(Y ~ xlin, data = dat, yind = yind, sandwich = "cl2"), + pffr_small_G_warning = function(w) { + fired <<- TRUE + invokeRestart("muffleWarning") + } + ) + expect_false(fired) +}) + +test_that("sandwich = 'none' at small G never warns", { + skip_on_cran() + dat <- make_smallg_dat(n = 10) + yind <- attr(dat, "yindex") + + fired <- FALSE + withCallingHandlers( + pffr(Y ~ xlin, data = dat, yind = yind, sandwich = "none"), + pffr_small_G_warning = function(w) { + fired <<- TRUE + invokeRestart("muffleWarning") + } + ) + expect_false(fired) +}) + +test_that("coef() on an already-warned small-G fit does not repeat the warning", { + skip_on_cran() + dat <- make_smallg_dat(n = 15) + yind <- attr(dat, "yindex") + + fit <- withCallingHandlers( + pffr(Y ~ xlin, data = dat, yind = yind, sandwich = "cl2"), + pffr_small_G_warning = function(w) invokeRestart("muffleWarning") + ) + + fired <- FALSE + withCallingHandlers( + coef(fit), + pffr_small_G_warning = function(w) { + fired <<- TRUE + invokeRestart("muffleWarning") + } + ) + expect_false(fired) +}) diff --git a/tests/testthat/test-pffr.R b/tests/testthat/test-pffr.R index d5ea7dd9..6384cbc8 100644 --- a/tests/testthat/test-pffr.R +++ b/tests/testthat/test-pffr.R @@ -150,7 +150,7 @@ test_that("ff limits arg works", { set.seed(2121) data <- pffr_simulate( scenario = "ff", - n = 20, + n = 40, SNR = 100, limits = function(s, t) s < t ) @@ -198,7 +198,7 @@ test_that("weights and offset args work", { skip_on_cran() set.seed(112) - n <- 20 + n <- 40 nygrid <- 50 data <- sim_xlin_data(n = n, nygrid = nygrid, SNR = 100) t <- attr(data, "yindex") @@ -254,7 +254,7 @@ test_that("sff terms are working", { skip_on_cran() set.seed(2121) - data <- pffr_simulate(scenario = "ff", n = 20, SNR = 100) + data <- pffr_simulate(scenario = "ff", n = 40, SNR = 100) t <- attr(data, "yindex") s <- attr(data, "xindex") @@ -432,7 +432,7 @@ test_that("sff() term works with new simulation", { skip_on_cran() set.seed(48) - n <- 30 + n <- 40 nxgrid <- 25 nygrid <- 35 @@ -675,7 +675,7 @@ test_that("predict(type='terms') works for intercept-free models", { skip_on_cran() set.seed(58) - n <- 35 + n <- 40 nygrid <- 30 dat <- sim_xlin_data(n = n, nygrid = nygrid, SNR = 50) @@ -894,7 +894,7 @@ test_that("unwrapped te/ti/t2 terms work without explicit effects", { skip_on_cran() set.seed(80) - n <- 30 + n <- 40 nygrid <- 25 # Bug fix test: te without c() should use smooth preset (sine), not gaussian_2d @@ -945,7 +945,7 @@ test_that("pffrSim respects custom response name from formula", { skip_on_cran() set.seed(83) - n <- 30 + n <- 40 nygrid <- 25 # Use custom response name @@ -1175,8 +1175,10 @@ test_that("small n (n=10) still fits without error", { dat <- sim_xlin_data(n = n, nygrid = nygrid, SNR = 100) t <- attr(dat, "yindex") - # Should fit without error with small sample - m <- pffr(Y ~ xlin, yind = t, data = dat) + # Should fit without error with small sample (the point of this test is the + # small n itself, so the S-C small-G guard is expected and muffled here + # rather than raised away). + m <- quiet_pffr(Y ~ xlin, yind = t, data = dat) expect_s3_class(m, "pffr") expect_equal(dim(fitted(m)), c(n, nygrid)) @@ -1186,7 +1188,7 @@ test_that("model with single smooth term works correctly", { skip_on_cran() set.seed(111) - n <- 30 + n <- 40 nygrid <- 25 dat <- pffr_simulate( @@ -1217,7 +1219,7 @@ test_that("predict.pffr with ff limits works on new data", { skip_on_cran() set.seed(113) - n <- 30 + n <- 40 nxgrid <- 25 nygrid <- 30 @@ -1261,7 +1263,7 @@ test_that("coef.pffr works with pcre terms with 3 FPCs", { skip_on_cran() set.seed(115) - n <- 30 + n <- 40 n_groups <- 5 ny <- 40 t <- seq(0, 1, length = ny) @@ -1692,7 +1694,7 @@ test_that("pffr with sandwich='cluster' yields cluster-robust covariance", { t <- attr(dat, "yindex") m_std <- get_xlin_model() - m_cl <- pffr(Y ~ xlin, yind = t, data = dat, sandwich = "cluster") + m_cl <- quiet_pffr(Y ~ xlin, yind = t, data = dat, sandwich = "cluster") # Sandwich type stored as character expect_identical(m_std$pffr$sandwich, "none") @@ -1802,7 +1804,7 @@ test_that("pffr default sandwich is auto, resolving per the policy", { dat <- get_xlin_data() t <- attr(dat, "yindex") expect_message( - m_default <- pffr(Y ~ xlin, yind = t, data = dat), + m_default <- quiet_pffr(Y ~ xlin, yind = t, data = dat), "sandwich='auto' resolved to" ) G <- length(unique(refund:::build_cluster_id(m_default$pffr))) @@ -1814,7 +1816,7 @@ test_that("pffr default sandwich is auto, resolving per the policy", { family = m_default$family ) ) - m_cl2 <- pffr(Y ~ xlin, yind = t, data = dat, sandwich = "cl2") + m_cl2 <- quiet_pffr(Y ~ xlin, yind = t, data = dat, sandwich = "cl2") expect_equal(m_default$pffr$Vsandwich, m_cl2$pffr$Vsandwich) }) @@ -1825,8 +1827,8 @@ test_that("pffr with sandwich='cl2' yields leverage-adjusted covariance", { t <- attr(dat, "yindex") m_std <- get_xlin_model() - m_cl <- pffr(Y ~ xlin, yind = t, data = dat, sandwich = "cluster") - m_cl2 <- pffr(Y ~ xlin, yind = t, data = dat, sandwich = "cl2") + m_cl <- quiet_pffr(Y ~ xlin, yind = t, data = dat, sandwich = "cluster") + m_cl2 <- quiet_pffr(Y ~ xlin, yind = t, data = dat, sandwich = "cl2") expect_identical(m_cl2$pffr$sandwich, "cl2") expect_equal(coef(m_std, raw = TRUE), coef(m_cl2, raw = TRUE)) @@ -1893,7 +1895,7 @@ test_that("gam_sandwich_cluster_cl2 works for poisson and binomial", { families <- list(poisson(), binomial()) for (fam in families) { set.seed(if (fam$family == "poisson") 1001 else 1002) - dat <- sim_xlin_data(n = 30, nygrid = 25, SNR = 10, family = fam) + dat <- sim_xlin_data(n = 40, nygrid = 25, SNR = 10, family = fam) t <- attr(dat, "yindex") m <- pffr(Y ~ xlin, yind = t, data = dat, family = fam) @@ -1925,7 +1927,7 @@ test_that("sandwich backward compat: TRUE/FALSE still work", { t <- attr(dat, "yindex") # TRUE -> "cluster" - m_true <- pffr(Y ~ xlin, yind = t, data = dat, sandwich = TRUE) + m_true <- quiet_pffr(Y ~ xlin, yind = t, data = dat, sandwich = TRUE) expect_identical(m_true$pffr$sandwich, "cluster") # FALSE -> "none" @@ -1961,7 +1963,7 @@ test_that("coef.pffr recomputes when sandwich type differs from fit", { expect_true(all(is.finite(coef_cl_from_none$pterms[, "se"]))) # Fit with CL2, request cluster and HC to force recomputation paths. - m_cl2 <- pffr(Y ~ xlin, yind = t, data = dat, sandwich = "cl2") + m_cl2 <- quiet_pffr(Y ~ xlin, yind = t, data = dat, sandwich = "cl2") coef_cl2_stored <- coef(m_cl2, sandwich = "cl2") coef_cl_from_cl2 <- coef(m_cl2, sandwich = "cluster") coef_hc_from_cl2 <- coef(m_cl2, sandwich = "hc") @@ -2034,7 +2036,7 @@ test_that("gam_sandwich_cluster_cl2 works for gaulss family", { dat <- get_xlin_data() t <- attr(dat, "yindex") - m_fit_cl2 <- pffr( + m_fit_cl2 <- quiet_pffr( Y ~ xlin, yind = t, data = dat, @@ -2215,7 +2217,7 @@ test_that("dof_correction is stored and invalidates the coef.pffr cache", { # the public surface: storage, cache reuse, and cache invalidation. # (a) Default fit stores dof_correction = "none". - m0 <- pffr(Y ~ xlin, yind = t, data = dat, sandwich = "cluster") + m0 <- quiet_pffr(Y ~ xlin, yind = t, data = dat, sandwich = "cluster") expect_identical(m0$pffr$dof_correction, "none") expect_identical(m0$pffr$edf_type, "trace") @@ -2229,7 +2231,7 @@ test_that("dof_correction is stored and invalidates the coef.pffr cache", { expect_false(isTRUE(all.equal(unname(se_none), unname(se_edf)))) # (b) Fit with dof = "edf": stored as metadata. - m1 <- pffr( + m1 <- quiet_pffr( Y ~ xlin, yind = t, data = dat, @@ -2324,7 +2326,7 @@ test_that("gaulss CL2 whitening preserves the score under prior weights", { test_that("CL2 errors for unsupported custom family$sandwich", { skip_on_cran() - dat <- sim_xlin_data(n = 25, nygrid = 25, SNR = 10, family = mgcv::gaulss()) + dat <- sim_xlin_data(n = 40, nygrid = 25, SNR = 10, family = mgcv::gaulss()) t <- attr(dat, "yindex") m <- pffr(Y ~ xlin, yind = t, data = dat, family = mgcv::gaulss()) From b883dfa5a3d66b002a3b6138ac0038389a38eb75 Mon Sep 17 00:00:00 2001 From: fabian-s Date: Fri, 18 Sep 2026 12:10:08 +0200 Subject: [PATCH 5/5] Halve the moment-df cost with a Cholesky-factored residualization Gram The per-point working-model Satterthwaite df evaluates, per contrast a and per cluster pair, Gamma_gh = 1{g=h}||q_g||^2 - t_g' C t_h. Profiling a realistic Gaussian pffr fit (ff(X1) + xlin, p = 176, n_y = 60, bs.yindex k = 12) shows the O(p G^2) Gram, not the O(p^2 G) product C %*% T, takes 87% of that time: caching C T_g' per cluster -- the precompute the inference-core notes proposed -- measures only 1.1x, because it replaces one large efficient GEMM with G small inefficient ones. C = 2 V_p - V_p X'X V_p is algebraically V_p + V_p S V_p, hence positive definite for a genuine penalized bread. With C = R'R the Gram becomes crossprod(R T): a symmetric rank-k update at half the flops, and the p x p product disappears because R T_g' is cached on the influence core. The factorization is verified (R'R must reproduce C) rather than assumed, so a contrived or numerically inconsistent bread falls back to the general product instead of erroring, and the cached blocks are bounded by a byte budget (8 * p * sum_g rank_g; 7.9 MB at G = 200). Results are unchanged: max 6e-16 relative difference in df and in expected_sampling_variance against the previous implementation, over the package fixtures, a G = 100 pffr fit's full lpmatrix, both df_gram choices and chunk sizes 1/7/13/32/1000/10000. Measured single-threaded, medians of 3 (inst/benchmarks/df-timing.R, CSVs alongside it): stage G = 100 G = 200 coefficient grids (1801) 6.42 -> 3.18 s 10.29 -> 4.78 s E(Y) lpmatrix grid 20.81 -> 12.32 s 76.20 -> 36.21 s (6000 rows) (12000 rows) Core construction is unchanged within noise (one Cholesky, its verification and G triangular products). The benchmark also runs both df paths round robin in one process, which is immune to this machine's load drift, and attributes 1.43x (G = 100) / 1.53x (G = 200) to the factorization itself and the rest to dropping the per-contrast column assembly. New fixture: G = 45 > chunk size, unequal per-cluster ranks including an exactly rank-deficient cluster, checked against the dense reference for all three adjustments, plus the two fallbacks (zero memory budget and an indefinite C). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QSoB6DyAfeyS9PytuzQs3x --- DESCRIPTION | 2 +- NEWS.md | 16 ++ R/pffr-influence.R | 90 ++++++- inst/benchmarks/df-timing-after.csv | 11 + inst/benchmarks/df-timing-before.csv | 7 + inst/benchmarks/df-timing.R | 281 ++++++++++++++++++++++ man/pffr_influence.Rd | 6 +- man/pffr_influence_core.Rd | 10 +- man/pffr_influence_df.Rd | 7 + tests/testthat/test-pffr-inference-core.R | 65 +++++ 10 files changed, 478 insertions(+), 17 deletions(-) create mode 100644 inst/benchmarks/df-timing-after.csv create mode 100644 inst/benchmarks/df-timing-before.csv create mode 100644 inst/benchmarks/df-timing.R diff --git a/DESCRIPTION b/DESCRIPTION index a600274d..d159a5cb 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -172,4 +172,4 @@ Encoding: UTF-8 Config/roxygen2/version: 8.1.0 PffrInferenceCore: fixed-fit-core-2026-09-09 PffrBaseCommit: 07e09fffff08b1014068c2e948e2c2eb05a655b0 -PffrInferenceCoreRevision: 2026-09-17-integrated +PffrInferenceCoreRevision: 2026-09-18-precompute diff --git a/NEWS.md b/NEWS.md index ba87a9aa..4bd5070b 100644 --- a/NEWS.md +++ b/NEWS.md @@ -139,6 +139,22 @@ shortcut leverage weight `(I - H_gg)^{-1/2}`, so `"diagonal"` reproduces those historical numbers exactly only in combination with `cl2_adjustment = "shortcut"`. + - The per-point moment df is about **twice as fast**, with results unchanged + (agreement to 6e-16 relative against the previous implementation, same + `NA` pattern, same chunk invariance). `C = 2 V_p - V_p X'X V_p` equals + `V_p + V_p S V_p` and is therefore positive definite for a genuine + penalized bread, so the influence object now caches `R T_g'` per cluster + with `C = R'R` and evaluates the residualized Gram as a symmetric rank-k + update instead of a general triple product. Measured on a Gaussian + `ff(X1) + xlin` fit (p = 176, n_y = 60, `bs.yindex` k = 12, + single-threaded): the coefficient grids (1801 contrasts) go from 6.4 s to + 3.2 s at G = 100 and from 10.3 s to 4.8 s at G = 200; a full E(Y) grid goes + from 20.8 s to 12.3 s (6000 contrasts, G = 100) and from 76.2 s to 36.2 s + (12000 contrasts, G = 200). The cached blocks cost + `8 * p * sum_g rank_g` bytes (7.9 MB at G = 200) and are bounded by + `pffr_influence_core(df_precompute_bytes =)`; above that budget, or when + `C` is not usably positive definite, the df falls back to the previous + general path. See `inst/benchmarks/df-timing.R`. * AR(1) support improvements: `pffr()` now automatically switches to `algorithm = "bam"` and `method = "fREML"` when `rho` is supplied, and sets `discrete = TRUE` for non-Gaussian families. diff --git a/R/pffr-influence.R b/R/pffr-influence.R index 245914e6..605db342 100644 --- a/R/pffr-influence.R +++ b/R/pffr-influence.R @@ -13,6 +13,12 @@ #' @param leverage_cap Numerical cap strictly between zero and one. #' @param tol Positive shortcut eigenvalue floor. #' @param rank_tol Relative SVD tolerance; NULL uses dimension times machine epsilon. +#' @param df_precompute_bytes Memory budget for the per-cluster residualization +#' blocks \eqn{R T_g^\top} used by [pffr_influence_df()], where \eqn{C = R^\top R}. +#' They cost `8 * p * sum(rank_g)` bytes and let the df evaluate its Gram as a +#' symmetric rank-k update without any \eqn{p \times p} product. Above the +#' budget, or when `C` is not usably positive definite, the blocks are not +#' stored and the df falls back to the general product. #' @returns Influence object with separate sampling columns, B2 and geometry. #' Its `diagnostics` data frame carries the per-cluster hat-invariant monitors #' `max_leverage`/`min_hat_eig` (extreme eigenvalues of \eqn{H_{gg}}), @@ -27,7 +33,8 @@ pffr_influence_core <- function( adjustment = c("exact", "shortcut", "none"), leverage_cap = .999, tol = 1e-8, - rank_tol = NULL + rank_tol = NULL, + df_precompute_bytes = 2^28 ) { adjustment <- match.arg(adjustment) Xw <- as.matrix(Xw) @@ -80,6 +87,16 @@ pffr_influence_core <- function( rank_tol >= 1) ) stop("rank_tol must be in [0,1).", call. = FALSE) + if ( + length(df_precompute_bytes) != 1L || + !is.numeric(df_precompute_bytes) || + is.na(df_precompute_bytes) || + df_precompute_bytes < 0 + ) + stop( + "df_precompute_bytes must be a single nonnegative number.", + call. = FALSE + ) groups <- unique(cluster_id) G <- length(groups) if (G < 2L) @@ -155,6 +172,25 @@ pffr_influence_core <- function( K[, g] <- B %*% crossprod(T, A %*% crossprod(dec$u[, keep, drop = FALSE], z[idx])) } + # Residualization factor for the moment df. pffr_influence_df() needs the + # Gram t_g' C t_h for every cluster pair and every contrast. For a genuine + # penalized bread C = B + B S B is positive definite, so with C = R'R that + # Gram is crossprod(R T): a symmetric rank-k update, half the flops of the + # general product, and the per-contrast O(p^2 G) product C %*% T disappears + # because R T_g' is cached here, per cluster. The Gram, not that product, is + # what dominates the df (87% of it at G = 200), which is why caching C T_g' + # alone -- the obvious precompute -- buys about 1.1x while this buys about + # 2.1x; see inst/benchmarks/df-timing.R. + # The factorization is verified rather than assumed: a contrived or + # numerically inconsistent bread can make C indefinite, or so ill conditioned + # that R'R no longer reproduces it, and the df then falls back to the general + # product. The byte budget bounds the cached blocks (8 * p * sum_g r_g). + Rchol <- tryCatch(chol(C), error = function(e) NULL) + if (!is.null(Rchol) && max(abs(crossprod(Rchol) - C)) > 1e-10 * max(abs(C))) + Rchol <- NULL + df_precompute <- !is.null(Rchol) && 8 * p * sum(rank) <= df_precompute_bytes + if (df_precompute) + for (g in seq_len(G)) blocks[[g]]$RT <- Rchol %*% t(blocks[[g]]$T) structure( list( B = B, @@ -164,6 +200,7 @@ pffr_influence_core <- function( G = G, groups = groups, adjustment = adjustment, + df_precompute = df_precompute, correction = G / (G - 1), B2 = matrix(0, p, p), diagnostics = data.frame( @@ -261,6 +298,13 @@ pffr_influence_vcov <- function( #' is the diagonal moment df of the exact geometry and differs from the #' historical number (2e-5 to 2e-2 relative on the package fixtures; there is no #' bound on the difference in general). +#' +#' Per contrast the residualized Gram \eqn{T^\top C\,T} dominates the cost. When +#' the core carries the cached blocks \eqn{R T_g^\top} with \eqn{C = R^\top R} +#' (see [pffr_influence_core()]'s `df_precompute_bytes`) it is evaluated as +#' `crossprod(R T)`, a symmetric rank-k update at half the flops and without the +#' \eqn{O(p^2 G)} product `C %*% T`. Cores without the cached blocks use the +#' general path and return the same numbers. #' @param core Fixed-fit influence object. #' @param Xp Finite full-coefficient contrasts, one per row. #' @param chunk_size Positive number of contrasts per batch. @@ -296,21 +340,39 @@ pffr_influence_df <- function( expected <- numeric(n) if (!n) return(list(df = out, G = core$G, expected_sampling_variance = expected)) + p <- ncol(core$B) + G <- core$G + full <- df_gram == "full" + # With the cached per-cluster blocks R T_g' (pffr_influence_core()'s + # df_precompute, C = R'R) the residualized Gram is one symmetric rank-k + # update per contrast, and no p x p product is needed at all. Cores without + # them -- older cached objects, a bread whose C is not usably positive + # definite, or a geometry above the memory budget -- keep the general path. + factored <- full && isTRUE(core$df_precompute) for (start in seq.int(1L, n, by = as.integer(chunk_size))) { jj <- seq.int(start, min(n, start + as.integer(chunk_size) - 1L)) + nj <- length(jj) M <- core$B %*% t(Xp[jj, , drop = FALSE]) - q2 <- matrix(0, core$G, length(jj)) - ts <- lapply(seq_len(core$G), function(g) { + q2 <- matrix(0, G, nj) + # Column j of vmat holds the p x G matrix [v_1 ... v_G] for contrast jj[j], + # flattened column-major, with v_g = R t_g (factored) or v_g = t_g. + vmat <- if (full) matrix(0, p * G, nj) else NULL + for (g in seq_len(G)) { block <- core$blocks[[g]] q <- block$A %*% (block$T %*% M) - q2[g, ] <<- colSums(q^2) - if (df_gram == "full") crossprod(block$T, q) else NULL - }) - for (j in seq_along(jj)) { - if (df_gram == "full") { - T <- do.call(cbind, lapply(ts, function(x) x[, j])) - Gamma <- diag(q2[, j], nrow = core$G) - crossprod(T, core$C %*% T) - Gamma <- (Gamma + t(Gamma)) / 2 + q2[g, ] <- colSums(q^2) + if (full) + vmat[seq.int((g - 1L) * p + 1L, g * p), ] <- if (factored) + block$RT %*% q else crossprod(block$T, q) + } + for (j in seq_len(nj)) { + if (full) { + V <- matrix(vmat[, j], p, G) + gram <- if (factored) crossprod(V) else crossprod(V, core$C %*% V) + Gamma <- diag(q2[, j], nrow = G) - gram + # crossprod(V) is symmetric by construction; the general product is + # symmetric only up to rounding. + if (!factored) Gamma <- (Gamma + t(Gamma)) / 2 } else { # Historical diagonal shortcut: Gamma = diag(||q_g||^2), so # tr^2 / tr(Gamma^2) = (sum_g ||q_g||^2)^2 / sum_g ||q_g||^4. @@ -349,8 +411,10 @@ pffr_influence_df <- function( #' as `"none"` so the same object is reused whatever they are set to. #' @returns A `pffr_influence` object: the symmetrized penalized bread `B`, the #' residualization matrix `C`, the per-cluster residual influence columns `K`, -#' the compressed per-cluster geometry `blocks` (`T` and the leverage weight -#' `A`), the cluster count `G` and labels `groups`, the resolved `adjustment`, +#' the compressed per-cluster geometry `blocks` (`T`, the leverage weight `A` +#' and, when the df precompute applies, the residualization block `RT` = +#' \eqn{R T_g^\top}), the cluster count `G` and labels `groups`, +#' the resolved `adjustment`, #' the finite-sample `correction` (`G/(G-1)` times any CR1 dof factor), the #' Bayesian smoothing-bias term `B2`, the per-cluster `diagnostics` (see #' [pffr_influence_core()]) and the numerical settings. Cached on the fit diff --git a/inst/benchmarks/df-timing-after.csv b/inst/benchmarks/df-timing-after.csv new file mode 100644 index 00000000..f337d61c --- /dev/null +++ b/inst/benchmarks/df-timing-after.csv @@ -0,0 +1,11 @@ +"label","G","p","stage","contrasts","rows_timed","seconds","seconds_min","seconds_max","reps","seconds_full","sum_rank","max_rank" +"after",100,176,"core",NA,NA,3.582,3.267,3.996,3,3.582,2800,28 +"after",100,176,"coef_grid",1801,1801,3.175,2.559,3.57100000000001,3,3.175,2800,28 +"after",100,176,"ey_lpmatrix",6000,6000,12.323,10.427,13.132,3,12.323,2800,28 +"after",100,176,"ab_general_path",2000,2000,4.336,4.20699999999999,4.575,3,13.008,2800,28 +"after",100,176,"ab_factored_path",2000,2000,3.02500000000001,2.91800000000001,3.08,3,9.07500000000002,2800,28 +"after",200,176,"core",NA,NA,6.86999999999998,5.14700000000002,7.09700000000001,3,6.86999999999998,5600,28 +"after",200,176,"coef_grid",1801,1801,4.78400000000002,4.68899999999999,5.143,3,4.78400000000002,5600,28 +"after",200,176,"ey_lpmatrix",12000,12000,36.205,33.912,44.117,3,36.205,5600,28 +"after",200,176,"ab_general_path",2000,2000,11.945,11.622,12.499,3,71.67,5600,28 +"after",200,176,"ab_factored_path",2000,2000,7.82100000000003,6.68599999999998,9.125,3,46.9260000000002,5600,28 diff --git a/inst/benchmarks/df-timing-before.csv b/inst/benchmarks/df-timing-before.csv new file mode 100644 index 00000000..fa1f99a9 --- /dev/null +++ b/inst/benchmarks/df-timing-before.csv @@ -0,0 +1,7 @@ +"label","G","p","stage","contrasts","rows_timed","seconds","seconds_min","seconds_max","reps","seconds_full","sum_rank","max_rank" +"before",100,176,"core",NA,NA,3.455,3.156,3.745,3,3.455,2800,28 +"before",100,176,"coef_grid",1801,1801,6.424,5.721,6.728,3,6.424,2800,28 +"before",100,176,"ey_lpmatrix",6000,6000,20.805,19.049,21.175,3,20.805,2800,28 +"before",200,176,"core",NA,NA,5.417,5.04400000000001,6.29300000000001,3,5.417,5600,28 +"before",200,176,"coef_grid",1801,1801,10.287,9.107,13.009,3,10.287,5600,28 +"before",200,176,"ey_lpmatrix",12000,12000,76.195,75.069,76.67,3,76.195,5600,28 diff --git a/inst/benchmarks/df-timing.R b/inst/benchmarks/df-timing.R new file mode 100644 index 00000000..e3ff4961 --- /dev/null +++ b/inst/benchmarks/df-timing.R @@ -0,0 +1,281 @@ +# Timing benchmark for the fixed-fit cluster-influence degrees-of-freedom +# computation (`pffr_influence_core()` / `pffr_influence_df()`). +# +# Measures, on a realistic Gaussian pffr() fit (ff(X1) + xlin, n_y = 60, +# ff basis 12 x 12, bs.yindex k = 12): +# (a) influence-core construction (pffr_influence(), cold cache) +# (b) coef(sandwich = "cl2", cl2_adjustment = "exact", +# crit = "satterthwaite", ci = "pointwise") -- coefficient grids +# (c) df on an E(Y)-type contrast set: the working-design rows returned by +# predict(type = "lpmatrix"), through pffr_df_from_context() +# +# Run single-threaded; the timings are dominated by small dense BLAS calls. +# +# Usage: +# OPENBLAS_NUM_THREADS=1 OMP_NUM_THREADS=1 Rscript inst/benchmarks/df-timing.R \ +#