Skip to content

Commit f76cc42

Browse files
committed
feat(engine): default fitting backend = mgcv::bam + gam escape (v0.1.1 Feature 4)
Implements Feature 4 of the locked v0.1.1 plan (PLAN_v011_features.md §2.4) — the one non-byte-identical change in the release. Documented loudly in NEWS.md and @details. Engine dispatch * New `engine = c("bam", "gam")` argument, default "bam" on both janusplot() and janusplot_data(). bam is mgcv's "big additive model" backend — block-Lanczos / fREML / lower memory. ~3-10x wall-time speedup at janusplot's scale (k = 15-25 vars, 600+ pairwise fits per call). No new dependency. * `.fit_one_gam()` dispatch helper used at every fitting site (initial fit + refit-on-flagged-cells under auto_refit_k = TRUE) so the engine choice is consistent within a single call. * `bam` objects inherit from `gam` (class is c("bam","gam","glm", "lm")), so downstream code paths (k.check, predict, derivative LP-matrix arithmetic, shape-metric extractors) are unaffected. Method default * `method` argument default is now NULL, resolved per-engine at fit time: "fREML" for bam (mgcv's recommended at scale), "REML" for gam (v0.1.0 behaviour). Users who passed method explicitly in v0.1.0 see no behaviour change. * User-supplied method (e.g. "GCV.Cp") overrides the per-engine default — tested in test-engine.R. bam-only knobs * `discrete = FALSE` default. Opt-in to mgcv's covariate- discretisation. Further ~2-5x speedup at sub-pixel prediction shift cost. * `nthreads = 1L` default. Intra-fit threading. Default 1 to avoid CPU oversubscription when combined with parallel = TRUE (which fans out across pair-fits via future.apply). * Both ignored silently on engine = "gam". Provenance * `engine` + `method` columns appear in janusplot(..., with_data = TRUE)$data and on every janusplot_data()$pairs[[i]] entry. Paper figures can document exactly which backend produced their EDFs. Numerical-shift documentation * NEWS.md leads with a NUMERICAL-SHIFT NOTICE block ABOVE the per-feature entries — explicit callout of the ~1-3% EDF / asymmetry index shift between v0.1.0 (REML) and v0.1.1 (fREML). Recovery via engine = "gam" stated prominently. * @details on engine arg repeats the same. * @PARAM method documents the new NULL default + per-engine resolution. Backward-compat escape * Every v0.1.0 user can call janusplot(..., engine = "gam") and get byte-identical fit results vs v0.1.0. * The vdiffr visual-regression suite is now PINNED to engine = "gam" precisely so the snapshots track the v0.1.0 visual contract; suite acts as a regression gate on the backward-compat escape itself. Same pinning applied in test-derivatives.R for the d1 derivative snapshots. Tests: new tests/testthat/test-engine.R (10 expectations): - default engine surfaces "bam" + "fREML" in with_data - engine = "gam" surfaces "gam" + "REML" - both engines agree to <10% relative shift on linear DGP - both detect non-linearity on quadratic DGP (forward dir) - discrete = TRUE on bam renders, silently ignored on gam - explicit method overrides per-engine default - invalid engine errors via arg_match - janusplot_data carries engine + method per pair devtools::test(): 473 expectations pass (was 449 after Feature 3). R CMD check --no-manual: 0 errors / 0 warnings / 2 environmental NOTEs (HTML Tidy + 4-component dev version). lintr 0 lints. This completes the four-feature v0.1.1 plan. Next: vignette expansion + CRAN-comments draft + version bump to 0.1.1.
1 parent 151f261 commit f76cc42

8 files changed

Lines changed: 452 additions & 22 deletions

File tree

NEWS.md

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,57 @@
11
# janusplot (development version)
22

3+
### v0.1.1 NUMERICAL-SHIFT NOTICE (engine default change)
4+
5+
**v0.1.1 changes the default GAM fitting backend from `mgcv::gam`
6+
to `mgcv::bam`** (Feature 4 below). `bam` uses fREML estimation
7+
instead of REML, which differs by ~1-3% in effective degrees of
8+
freedom on identical data. **Existing v0.1.0 users will see
9+
slightly different EDFs, asymmetry indices, and per-cell colour
10+
fills when they upgrade.** This is the single non-byte-identical
11+
change in v0.1.1 — every other v0.1.1 feature is strictly additive.
12+
13+
**Recovery.** Set `engine = "gam"` on `janusplot()` or
14+
`janusplot_data()` to reproduce v0.1.0 numerical output verbatim.
15+
The package's vdiffr visual-regression suite pins
16+
`engine = "gam"` for exactly this purpose — every old snapshot
17+
remains valid under the backward-compat escape.
18+
19+
### v0.1.1 Feature 4 — Default fitting engine = `bam` (with `gam` escape)
20+
21+
* **New `engine = c("bam", "gam")` argument**, default `"bam"`.
22+
At janusplot's scale (k = 15-25 vars, 600+ fits per call)
23+
`mgcv::bam`'s block-Lanczos solve + fREML estimation delivers
24+
~3-10x wall-time speedup vs `mgcv::gam` without any new
25+
dependency (`mgcv` already exports both).
26+
* **fREML vs REML.** `bam` defaults to fREML (fast REML); `gam`
27+
defaults to REML. The two methods optimise the same penalty
28+
target via different paths — EDFs differ by ~1-3% on identical
29+
data. v0.1.1 surfaces this as the one numerical break with
30+
v0.1.0, documented prominently above.
31+
* **`method` argument default is now `NULL`**, resolved
32+
per-engine: `"fREML"` for bam, `"REML"` for gam. Users who
33+
passed `method` explicitly in v0.1.0 see no behaviour change.
34+
* **`discrete = FALSE` (bam-only)** — opt-in to mgcv's
35+
covariate-discretisation optimisation. Further ~2-5x speedup
36+
at sub-pixel prediction shift cost.
37+
* **`nthreads = 1L` (bam-only)** — intra-fit threading. Default
38+
1 to avoid oversubscription when combined with `parallel =
39+
TRUE` (which fans out across pair-fits already).
40+
* **`engine` + `method` provenance** — both columns now appear
41+
in `janusplot(..., with_data = TRUE)$data` and on every
42+
`janusplot_data()$pairs[[i]]` entry. Useful for paper figures
43+
whose methodology section needs to document which backend
44+
produced the EDFs they report.
45+
* **`bam` inherits from `gam`**`class(bam_fit)` is
46+
`c("bam", "gam", "glm", "lm")`, so `mgcv::k.check()`,
47+
`predict.gam()`, derivative LP-matrix arithmetic, and every
48+
shape-metric extractor work without modification. Engine is
49+
plumbing, not redesign.
50+
* **vdiffr suite pinned to `engine = "gam"`** so every legacy
51+
visual snapshot continues to validate under the v0.1.0
52+
backward-compat escape. Acts as a regression gate on the
53+
`engine = "gam"` recovery path.
54+
355
### v0.1.1 Feature 3 — Axes rendering modes + figure-to-file output
456

557
* **`axes`** rendering knob with four modes:

R/internal-fit.R

Lines changed: 66 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -412,14 +412,57 @@
412412
# Fit a single pairwise GAM and summarise
413413
# ---------------------------------------------------------------
414414

415+
# ---------------------------------------------------------------
416+
# Fitting-engine dispatch (Feature 4). `bam` is mgcv's "big additive
417+
# model" — same formula language as `gam`, but uses fREML (fast REML)
418+
# by default, block-Lanczos / discrete-method optimisations for the
419+
# basis-coefficient solve, lower memory, and a built-in `nthreads`
420+
# argument. bam objects inherit from gam (`class(b)` ==
421+
# c("bam", "gam", "glm", "lm")) so every downstream code path
422+
# (predict, summary, k.check, derivative LP-matrix arithmetic)
423+
# works without modification — engine is plumbing, not redesign.
424+
#
425+
# Default method-per-engine: `fREML` for bam (mgcv's recommended at
426+
# scale), `REML` for gam (v0.1.0 behaviour). A user-supplied `method`
427+
# overrides both.
428+
# ---------------------------------------------------------------
429+
430+
.engine_default_method <- function(engine) {
431+
if (identical(engine, "bam")) "fREML" else "REML"
432+
}
433+
434+
.fit_one_gam <- function(fml, dat, engine, method, discrete, nthreads, ...) {
435+
method <- if (is.null(method) || identical(method, "auto") ||
436+
identical(method, "default")) {
437+
.engine_default_method(engine)
438+
} else {
439+
method
440+
}
441+
if (identical(engine, "bam")) {
442+
mgcv::bam(
443+
formula = fml,
444+
data = dat,
445+
method = method,
446+
discrete = isTRUE(discrete),
447+
nthreads = as.integer(nthreads %||% 1L),
448+
...
449+
)
450+
} else {
451+
mgcv::gam(formula = fml, data = dat, method = method, ...)
452+
}
453+
}
454+
415455
.fit_pair <- function(x_name, y_name, data_full, adjust, method, k, bs,
416456
na_action, n_grid = 100L,
417457
derivatives = integer(),
418458
derivative_ci = "pointwise",
419459
derivative_ci_nsim = 1000L,
420460
k_check_thresholds = .default_k_thresholds(),
421461
auto_refit_k = FALSE,
422-
k_max_iter = 2L, ...) {
462+
k_max_iter = 2L,
463+
engine = "bam",
464+
discrete = FALSE,
465+
nthreads = 1L, ...) {
423466
k_val <- .resolve_k(k, x_name)
424467
if (na_action == "pairwise") {
425468
dat <- .complete_pair(data_full, x_name, y_name, adjust)
@@ -434,8 +477,16 @@
434477

435478
# Initial fit at user-requested k (mgcv resolves k=-1 internally).
436479
fml <- .build_formula(y_name, x_name, k_val, bs, adjust)
480+
resolved_method <- if (is.null(method) || identical(method, "auto") ||
481+
identical(method, "default")) {
482+
.engine_default_method(engine)
483+
} else {
484+
method
485+
}
437486
fit <- tryCatch(
438-
mgcv::gam(fml, data = dat, method = method, ...),
487+
.fit_one_gam(fml, dat,
488+
engine = engine, method = resolved_method,
489+
discrete = discrete, nthreads = nthreads, ...),
439490
error = function(e) e
440491
)
441492
if (inherits(fit, "error")) {
@@ -471,7 +522,9 @@
471522
k_new <- min(2 * k_cur, k_cap)
472523
fml_new <- .build_formula(y_name, x_name, k_new, bs, adjust)
473524
new_fit <- tryCatch(
474-
mgcv::gam(fml_new, data = dat, method = method, ...),
525+
.fit_one_gam(fml_new, dat,
526+
engine = engine, method = resolved_method,
527+
discrete = discrete, nthreads = nthreads, ...),
475528
error = function(e) e
476529
)
477530
if (inherits(new_fit, "error")) break
@@ -563,6 +616,8 @@
563616
dev_exp = dev_exp,
564617
n_used = n_used,
565618
error = NA_character_,
619+
engine = engine,
620+
method = resolved_method,
566621
k_check = list(
567622
k_prime = k_diag$k_prime,
568623
k_index = k_diag$k_index,
@@ -589,6 +644,7 @@
589644
raw = data.frame(),
590645
edf = NA_real_, pvalue = NA_real_, dev_exp = NA_real_,
591646
n_used = n_used, error = NA_character_,
647+
engine = NA_character_, method = NA_character_,
592648
k_check = list(
593649
k_prime = NA_real_,
594650
k_index = NA_real_,
@@ -618,7 +674,10 @@
618674
derivative_ci_nsim = 1000L,
619675
k_check_thresholds = .default_k_thresholds(),
620676
auto_refit_k = FALSE,
621-
k_max_iter = 2L, ...) {
677+
k_max_iter = 2L,
678+
engine = "bam",
679+
discrete = FALSE,
680+
nthreads = 1L, ...) {
622681
.check_parallel_plan(parallel)
623682
if (na_action == "complete") {
624683
data <- data[stats::complete.cases(
@@ -640,7 +699,9 @@
640699
derivative_ci_nsim = derivative_ci_nsim,
641700
k_check_thresholds = k_check_thresholds,
642701
auto_refit_k = auto_refit_k,
643-
k_max_iter = k_max_iter, ...
702+
k_max_iter = k_max_iter,
703+
engine = engine, discrete = discrete,
704+
nthreads = nthreads, ...
644705
)
645706
}
646707

R/janusplot.R

Lines changed: 66 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,37 @@
2525
#' `gam(y ~ s(x) + s(age) + s(site, bs = "re"))` for each pair.
2626
#' Default `NULL` fits unadjusted pairwise smooths.
2727
#' @param method Smoothing-parameter estimation method passed to
28-
#' [mgcv::gam()]. Default `"REML"` per mgcv recommendation.
28+
#' the chosen fitting backend. Default `NULL` resolves
29+
#' per-engine: `"fREML"` for `engine = "bam"` (mgcv's
30+
#' recommended at scale), `"REML"` for `engine = "gam"`
31+
#' (the v0.1.0 behaviour). Pass any valid mgcv method string
32+
#' to override.
33+
#' @param engine One of `"bam"` (default, **new in v0.1.1**) or
34+
#' `"gam"`. Selects mgcv's fitting backend:
35+
#' * `"bam"` — [mgcv::bam()]. Block-Lanczos solve + fREML
36+
#' estimation + lower memory. ~3-10x speedup at janusplot's
37+
#' scale (k = 15-25 vars, 600+ pairwise fits per call). The
38+
#' **default**, and the one non-byte-identical change in
39+
#' v0.1.1: fREML differs from REML by ~1-3% in EDF on
40+
#' identical data, so the asymmetry index may shift by
41+
#' similar amounts vs v0.1.0 output. Recoverable verbatim
42+
#' via `engine = "gam"`.
43+
#' * `"gam"` — [mgcv::gam()]. The v0.1.0 backend. Use for
44+
#' backward-compat reproduction, very small n (< 200) where
45+
#' bam's setup overhead exceeds its solve gain, or
46+
#' methodologically sensitive contexts that require REML
47+
#' rather than fREML.
48+
#' @param discrete Logical. `bam`-only opt-in to mgcv's
49+
#' covariate-discretisation optimisation. Further ~2-5x
50+
#' speedup at the cost of small (sub-pixel at typical
51+
#' janusplot resolution) prediction-shift. Default `FALSE`.
52+
#' Ignored when `engine = "gam"`.
53+
#' @param nthreads Integer. `bam`-only intra-fit threading.
54+
#' Default `1L` to avoid oversubscription when combined with
55+
#' `parallel = TRUE` (`future.apply` fans out pair fits
56+
#' across cores; nthreads > 1 within each pair would
57+
#' double-book CPUs). Raise above 1 only when `parallel =
58+
#' FALSE`. Ignored when `engine = "gam"`.
2959
#' @param k Integer, or named list mapping variable names to integers.
3060
#' Basis dimension for `s()`. Default `-1L` (mgcv's automatic choice).
3161
#' @param bs Basis type for `s()`. Default `"tp"` (thin plate).
@@ -354,9 +384,12 @@ janusplot <- function(
354384
data,
355385
vars = NULL,
356386
adjust = NULL,
357-
method = "REML",
387+
method = NULL,
358388
k = -1L,
359389
bs = "tp",
390+
engine = c("bam", "gam"),
391+
discrete = FALSE,
392+
nthreads = 1L,
360393
order = c("original", "hclust", "alphabetical"),
361394
show_data = TRUE,
362395
show_ci = TRUE,
@@ -404,6 +437,15 @@ janusplot <- function(
404437
na_action <- rlang::arg_match(na_action)
405438
compact <- rlang::arg_match(compact)
406439
axes <- rlang::arg_match(axes)
440+
engine <- rlang::arg_match(engine)
441+
if (!is.logical(discrete) || length(discrete) != 1L || is.na(discrete)) {
442+
cli::cli_abort("{.arg discrete} must be TRUE or FALSE.")
443+
}
444+
if (!is.numeric(nthreads) || length(nthreads) != 1L ||
445+
!is.finite(nthreads) || nthreads < 1L) {
446+
cli::cli_abort("{.arg nthreads} must be a single positive integer.")
447+
}
448+
nthreads <- as.integer(nthreads)
407449
glyph_style <- rlang::arg_match(glyph_style)
408450
labels <- rlang::arg_match(labels)
409451
diagonal <- rlang::arg_match(diagonal)
@@ -607,7 +649,8 @@ janusplot <- function(
607649
derivative_ci_nsim = derivative_ci_nsim,
608650
k_check_thresholds = k_thresholds,
609651
auto_refit_k = auto_refit_k,
610-
k_max_iter = k_max_iter, ...
652+
k_max_iter = k_max_iter,
653+
engine = engine, discrete = discrete, nthreads = nthreads, ...
611654
)
612655

613656
.summarise_k_check(fits, k_thresholds, auto_refit_k) # nolint: object_usage_linter.
@@ -826,6 +869,8 @@ janusplot <- function(
826869
k_final = kc$k_final,
827870
k_iterations = as.integer(kc$k_iterations %||% 0L),
828871
k_at_cap = isTRUE(kc$k_at_cap),
872+
engine = f$engine %||% NA_character_,
873+
method = f$method %||% NA_character_,
829874
stringsAsFactors = FALSE
830875
)
831876
})
@@ -1020,7 +1065,7 @@ janusplot_data <- function(
10201065
data,
10211066
vars = NULL,
10221067
adjust = NULL,
1023-
method = "REML",
1068+
method = NULL,
10241069
k = -1L,
10251070
bs = "tp",
10261071
na_action = c("pairwise", "complete"),
@@ -1034,9 +1079,21 @@ janusplot_data <- function(
10341079
k_check_thresholds = NULL,
10351080
auto_refit_k = FALSE,
10361081
k_max_iter = 2L,
1082+
engine = c("bam", "gam"),
1083+
discrete = FALSE,
1084+
nthreads = 1L,
10371085
...) {
10381086
na_action <- rlang::arg_match(na_action)
10391087
derivative_ci <- rlang::arg_match(derivative_ci)
1088+
engine <- rlang::arg_match(engine)
1089+
if (!is.logical(discrete) || length(discrete) != 1L || is.na(discrete)) {
1090+
cli::cli_abort("{.arg discrete} must be TRUE or FALSE.")
1091+
}
1092+
if (!is.numeric(nthreads) || length(nthreads) != 1L ||
1093+
!is.finite(nthreads) || nthreads < 1L) {
1094+
cli::cli_abort("{.arg nthreads} must be a single positive integer.")
1095+
}
1096+
nthreads <- as.integer(nthreads)
10401097
.validate_inputs(data, vars, adjust, na_action)
10411098
vars <- .resolve_vars(data, vars)
10421099
k_thresholds <- k_check_thresholds %||% .default_k_thresholds() # nolint: object_usage_linter.
@@ -1099,7 +1156,8 @@ janusplot_data <- function(
10991156
derivative_ci_nsim = derivative_ci_nsim,
11001157
k_check_thresholds = k_thresholds,
11011158
auto_refit_k = auto_refit_k,
1102-
k_max_iter = k_max_iter, ...
1159+
k_max_iter = k_max_iter,
1160+
engine = engine, discrete = discrete, nthreads = nthreads, ...
11031161
)
11041162

11051163
.summarise_k_check(fits, k_thresholds, auto_refit_k) # nolint: object_usage_linter.
@@ -1163,7 +1221,9 @@ janusplot_data <- function(
11631221
deriv_yx = f_yx$deriv,
11641222
deriv_xy = f_xy$deriv,
11651223
k_check_yx = f_yx$k_check,
1166-
k_check_xy = f_xy$k_check
1224+
k_check_xy = f_xy$k_check,
1225+
engine = f_yx$engine %||% f_xy$engine %||% NA_character_,
1226+
method = f_yx$method %||% f_xy$method %||% NA_character_
11671227
)
11681228
}
11691229
}

man/janusplot.Rd

Lines changed: 39 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)