Skip to content

Add one-step-ahead & process residuals; rework residual/plot API (v4.6.0)#97

Open
grantdadams wants to merge 23 commits into
mainfrom
dev
Open

Add one-step-ahead & process residuals; rework residual/plot API (v4.6.0)#97
grantdadams wants to merge 23 commits into
mainfrom
dev

Conversation

@grantdadams

@grantdadams grantdadams commented Jun 11, 2026

Copy link
Copy Markdown
Owner

Summary

Releases v4.6.0. Adds one-step-ahead (OSA) and process residuals for model validation, reworks the residual and composition-plotting API into a consistent, glm-style interface, and makes OSA composition diagnostics fast enough to leave on during simulation testing. This release also adds likelihood and growth corrections that intentionally change the fitted objective for affected models.

The new OSA machinery itself (the obsvec/keep/osa_mode data path) leaves normal fitting byte-identical; the objective changes come only from the explicit corrections below.

22 commits; 61 files changed.

New features — model validation residuals

  • osa_residuals() — one-step-ahead residuals (Thygesen et al. 2017; Trijoulet et al. 2023) for all fitted observation types: survey index, fishery catch, age/length composition, conditional age-at-length, and predator diet. Composition uses the conditional binomial / beta-binomial decomposition (a direct port of WHAM's age_comp_osa.hpp); the oneStepPredict() call is split by observation type so continuous (lognormal) index/catch and (optionally discrete) composition are residualized correctly. A parallel argument (default TRUE) runs the per-observation loop via parallel::mclapply — a near-linear speedup for random-effects models.
  • process_residuals() — SAM procres-style residuals on the model's random-effect deviations (recruitment, initial abundance, catchability). Exact for iid priors; warns when a catchability deviate prior is correlated (random-walk/AR1).
  • osa_diagnostics() — SDNR and lower/upper tail statistics with their standard-normal null intervals (Stewart & Monnahan 2025; Francis 2014).
  • plot() for rceattle_osa — Q-Q (with SDNR/tail annotation) plus signed OSA and Pearson bubble panels, styled after NOAA-AFSC afscOSA. Aggregate and composition are separate figures; ages and lengths are separate columns; joint-sex bins share one axis (females above, males below); panel headers use fleet names.

Residual / plotting API (glm-style)

  • residuals() follows stats::residuals.glm(): type selects the residual kind ("response", "pearson", "osa", "process") and source selects the data source ("index"/"catch"/"comp"/"caal"/"diet"/"all"), plus a species filter. Pearson residuals are now available for index/catch and for diet. .
  • plot_comp() re-implemented in ggplot2: composition Pearson bubbles plus observed-vs-fitted annual and aggregated figures. Fixes a histogram bug where the polygon extended past the first/last bin; zero observed proportions are retained (only NA dropped); joint-sex is drawn on one axis. residual_type = "osa" routes to the OSA diagnostics.
  • plot_diet_comp() now draws its diet residuals from residuals(source = "diet") — a single source of truth for residual math.

fit_control / performance

  • fit_control(osa = FALSE) (new, default off): a fit skips assembling the composition OSA observation data, so repeated refits stay fast (run_mse(), self_test(), jitter(), retrospective()). Set osa = TRUE to compute composition OSA residuals (~150 ms → ~5 ms per fit TRUE -> FALSE).
  • fit_control(comp_offset = NULL) (new): the small proportion offset added to age/length-composition and CAAL bins before the multinomial / Dirichlet-multinomial. NULL inherits data_list$comp_offset (default 1e-5). Stored on data_list so internal re-fits inherit it, and applied identically in the fitting likelihood and the OSA observation vector.
  • projection_uncertainty moved from fit_mod() into fit_control() (deprecation-forwarded).

Likelihood & growth corrections (change results for affected models)

These bring Rceattle into exact agreement with the WHAM growth fork at the MLE and fix statistically incorrect behavior.

  • Recruitment / initial-abundance bias correction now centers the deviations at −σ²/2 (was +σ²/2), so E[R] = R0 (mean-unbiased). Woops!
  • Full multinomial composition (Comp_loglike = "Multinomial") is now evaluated through the OSA conditional-binomial decomposition, which normalizes the predicted proportions to sum to 1 (matching WHAM); the previous plain dmultinom used un-normalized proportions after the offset. OJO We may want to revisit this....
  • Growth plus group: the plus-group SD is pinned to the upper anchor exp(sd_Linf), and the within-year plus-group mean length is pinned at its Jan-1 value for Richards as well as von Bertalanffy — so the plus-group convention is consistent across growth families.

Bug fixes

  • CAAL unweighted log-likelihood was recorded in the composition slot of unweighted_jnll_comp instead of the CAAL slot (diagnostic only; not the fitted objective).
  • init_dev was added to the random-effect vector even when fully mapped off (initMode = "Equilibrium"), producing an NA/NaN gradient under random_rec; it is now treated as random only when at least one element is estimated.
  • est_phase = 0 intercept linkages now map the base parameter off as well (honoring the documented "fix at init" contract), so a fixed-at-init linkage truly fixes the parameter.

Backward compatibility & verification

  • The obsvec/keep/osa_mode machinery feeds TMB::oneStepPredict() while leaving normal fitting (osa_mode == 0) byte-identical — confirmed by a jnll-identical regression against the pre-change template.
  • The likelihood/growth corrections were validated by matching Rceattle's MLE objective to the WHAM growth fork (GiancarloMCorrea/wham, ref growth) to ~1e-10, which in turn enables exact OSA cross-validation (aggregate residuals identical under fixed effects; order-dependent once random effects couple the observations).
caal osa Len_comp_OSA
  • Composition decomposition verified line-for-line against upstream WHAM; process-residual methodology verified against SAM (fishfollower/SAM) procres.
  • Verified on single-species (GOApollock), two-sex (GOAatf), and multispecies + diet (BS2017MS / make_msm_test_data) models.

Testing

Full testthat suite passes, with new tests for OSA construction, end-to-end OSA / process / diet residuals (including a diet decomposition-invariant check), the residual-kind API, the composition/diet plots, and growth plus-group pinning for VB and Richards. Golden-jnll regression updated to the corrected objective (1537036.2876293703 on BS2017SS). (Recommend re-running R CMD check --as-cran on the final branch before merge.)

Known follow-ups (documented, not blocking)

  • Process residuals for selectivity and natural-mortality deviations (and correlated catchability) — best via a C++ ADREPORT of standardized residuals.
  • Optionally flip the eval = FALSE OSA chunks in the model-diagnostics vignette once a converged CAAL example is available.

grantdadams and others added 22 commits June 9, 2026 21:21
First phase of one-step-ahead (OSA) residual support via TMB::oneStepPredict,
covering the aggregate catch and index series.

C++ (ceattle_v01_11.cpp):
- Add DATA_VECTOR(obsvec) + DATA_VECTOR_INDICATOR(keep, obsvec), per-row index
  maps (catch_obsvec_idx / index_obsvec_idx), and an osa_mode flag (unused this
  phase; reserved for composition/diet).
- Index (slot 0) and catch (slot 1) likelihoods read the observation from
  obsvec and gate it with keep. With keep == 1 (normal fitting) the joint nll
  is bit-identical to the pre-change model (verified against a baseline DLL:
  max |jnll_comp| diff = 0.0e+00).

R:
- build_osa_data() (R/0-osa_data.R) assembles obsvec, the obs_ctl position-map
  metadata, and the per-type obsvec index vectors in rearrange_data(), after
  the data.frame->matrix coercion so obs_ctl stays a data frame.
- osa_residuals() + osa_diagnostics() (SDNR + simulated tail null intervals,
  Stewart & Monnahan 2025) + rceattle_osa S3 class with a plot method (Q-Q and
  residual-by-year, afscOSA styling). fit_mod() carries obs_ctl onto the fit.
- residuals.Rceattle() gains type = "osa".

Tests (tests-Likelihoods/test-osa-residuals.R): obsvec construction,
obs_ctl-is-data-frame guard, finite-nll smoke, and an end-to-end
osa_residuals() check on GOApollock.
Extends one-step-ahead residuals to age/length composition (comp) and
conditional age-at-length (caal) data via a keep-aware conditional
decomposition.

C++:
- New src/TMB/comp_osa.hpp: keep-aware dmultinom_osa / ddirmultinom_osa that
  decompose a composition into conditional binomials / beta-binomials
  (Trijoulet et al. 2023; ported from WHAM age_comp_osa.hpp), with osa_order()
  and osa_squeeze() helpers. With keep == 1 the conditional log-densities sum to
  the joint density.
- comp (slot 2) and caal (slot 3) gain an osa_mode branch. osa_mode == 0 is the
  original weighted fitting code, byte-identical to before (verified: reported
  jnll_comp diff = 0.0e+00 vs a baseline DLL, with composition data). osa_mode
  == 1 reads bin counts from obsvec and uses the unweighted keep-gated density;
  AFSC MultinomialAFSC fleets are residualized under the full multinomial.
- Adds comp_obsvec_idx / caal_obsvec_idx start-position maps.
- Fixes a pre-existing CAAL bug: the unweighted slot-3 likelihood was written
  into slot 2 (comp); corrected to slot 3. Affects unweighted_jnll_comp only,
  not the fitted objective.

R:
- build_osa_data() appends comp and caal bin counts ((proportion + 1e-5) * N) to
  obsvec, flags each composition's final (sum-to-N) bin, and records the
  conditioning length for caal.
- osa_residuals() supports types "comp"/"caal", rebuilds the model in osa_mode=1
  at the fitted parameters, residualizes only non-last bins, defaults to all
  available types, propagates the caal conditioning length, and warns on
  non-finite residuals (poor convergence / sparse compositions).
- plot.rceattle_osa() gains an afscOSA-style composition bubble panel.

Tests: comp and caal obsvec construction, plus comp OSA end-to-end on
GOApollock (837 residuals from 930 bins; deterministic; aggregate unaffected).
Extends one-step-ahead residuals to predator diet (stomach-content)
composition for multispecies models with estimated suitability.

C++ (ceattle_v01_11.cpp):
- The diet likelihood (slot 18) gains an osa_mode branch. osa_mode == 0 is the
  original weighted per-stomach code, byte-identical to before (verified:
  reported jnll_comp diff = 0.0e+00 vs a baseline DLL, with the diet likelihood
  ACTIVE via suitMode = 4). osa_mode == 1 reads each stomach's prey counts from
  obsvec and uses the unweighted keep-gated dmultinom_osa / ddirmultinom_osa
  decomposition (shared with comp/caal). The "other prey" category is the last
  bin, fixed by the sum-to-1 constraint and dropped.
- Adds the per-stomach diet_obsvec_idx start-position map (length n_stomach_obs).

R:
- build_osa_data() appends, per stomach, the prey counts plus the "other prey"
  residual category ((proportion + 1e-5) normalized, scaled to the stomach
  sample size), matching the TMB likelihood. Inclusion mirrors the template
  guard: predator suitMode > 0 and >= 1 prey item. suitMode is set from the
  fit_mod argument before rearrange_data, so the R and C++ inclusion agree.
- osa_residuals() supports types = "diet" (opt-in; not in the default set since
  it applies only to multispecies models and is expensive).

Tests: a direct build_osa_data() diet unit test (stomach grouping, "other prey"
last bin, count values). End-to-end diet OSA verified on a multispecies model
(suitMode = 4): 9300 diet positions -> 9000 finite residuals, deterministic.
Adds process_residuals(), a complement to the observation-based
osa_residuals() that validates the model's random-effect process deviations
(Nielsen and Berg 2014).

Each deviation set carries a Gaussian process prior. The posterior mode is
shrunk toward that prior, so a single draw is taken from the joint posterior of
the deviations (from the joint precision when they are random effects, or the
fixed-effect covariance when they are penalized fixed effects) and standardized
by the process SD; under a correct process these are approximately iid N(0,1).
A random draw is used, so results are stochastic -- a seed is exposed.

A generic registry maps any supported deviation parameter to its estimated
elements (via the map and arrayInd), prior mean/SD, and species/fleet/year/age
labels. Supported processes: "recruitment" (rec_dev), "initial" (init_dev), and
"catchability" (index_q_dev); process = "all" returns every supported process
present. Selectivity and natural-mortality deviations use random-walk / 2D-AR1
priors and are not yet supported (they need full prior-covariance
decorrelation).

- residuals.Rceattle() gains type = "process".
- Results use the rceattle_osa class, so osa_diagnostics() and the plot method
  apply directly.
- Adds Matrix to Imports (Matrix::solve on the joint precision).

Verified on GOApollock: 49 recruitment residuals (one per hindcast year,
correctly mapped, deterministic, SDNR within the null interval); process =
"all" returns recruitment + initial + catchability (154 finite residuals).
Adds a residual_type argument to the existing diagnostic plotters so OSA
residuals are available through the familiar functions (Stewart & Monnahan
2025), not only via plot() on an rceattle_osa object:

- plot_comp(fit, residual_type = "osa") draws the OSA Q-Q plot (with SDNR and
  tail-statistic annotation) and signed composition bubbles.
- plot_indexresidual(fit, residual_type = "osa") draws the OSA Q-Q and
  residual-by-year panels for the aggregate catch/index series.

The default residual_type = "pearson" keeps the legacy Pearson-residual and
aggregate-fit plots unchanged; Stewart & Monnahan recommend retaining Pearson
bubbles alongside OSA residuals. All four plot paths (aggregate, comp, index
residual, process) verified to render.
Review pass over the OSA feature (correctness + readability):

- process_residuals(): replace a silent fallback that could mislabel
  deviations (assigning the wrong species/year/age) when the estimated set is
  not a contiguous leading block -- e.g. partially time-varying catchability --
  with a clear error, so residuals are never silently mislabeled.
- build_osa_data(): factor the near-identical comp and caal row loops through a
  shared append_composition() helper, keeping each type's column extraction
  explicit. No behavior change.

Docs:
- NEWS.md: document osa_residuals(), osa_diagnostics(), process_residuals(), the
  residuals() type = "osa"/"process" options, the plot_comp()/plot_indexresidual()
  residual_type = "osa" options, and the CAAL unweighted-slot bug fix.
- model-diagnostics vignette: a new OSA-residuals section with the Stewart &
  Monnahan (2025) 5-step diagnostic workflow, plus process residuals.
- _pkgdown.yml: index the new diagnostic and plot functions.
- Bump version 4.5.0 -> 4.6.0
- Split NEWS: OSA residuals under a new 4.6.0 section; the 4.5.0 convergence
  diagnostics feature stays under 4.5.0
- Fix stale "later phase" comments left over from the incremental
  development (the obsvec DATA block in ceattle_v01_11.cpp, build_osa_data(),
  and plot.rceattle_osa()) so they describe the finished implementation
  (comp / caal / diet all supported). Comment-only; no change to results.
Consolidates projection_uncertainty with the other optimizer / reporting
controls in fit_control(). Passing it directly to fit_mod() still works via
the existing deprecation path (warns and forwards into fit_control), the same
backward-compatible mechanism used for phase, getsd, and the other former
fit_mod() control arguments.
Driven by simulation-testing performance and a residual-math review:

- fit_control(osa = FALSE) (new, default off): skip building the composition
  OSA observation data during a fit. The aggregate index/catch obsvec the TMB
  template always reads is still built; only the costly comp/caal/diet metadata
  is gated, so the fitted objective is unchanged. Simulation drivers (run_mse,
  self_test, jitter, retrospective) inherit the fast default; set osa = TRUE to
  compute composition OSA residuals. build_osa_data() also assembles obsvec /
  obs_ctl in a single pass instead of growing them in a loop.

- residuals() now follows stats::residuals.glm(): type selects the residual
  kind (response / pearson / osa / process) and a new source argument selects
  the data source(s); all applicable sources are returned by default. Pearson
  residuals are now available for index/catch (standardized by the realized
  observation log-SD). index/catch residuals are restricted to genuine
  observations (projection / NA / non-positive rows dropped). The legacy
  type = <source> form still works with a deprecation warning.

- process_residuals() warns when catchability uses a correlated deviate prior
  (random walk or AR1), where standardizing by the marginal SD is only
  approximate; it is exact for the iid prior and for recruitment / initial.

NEWS, model-diagnostics vignette, and tests updated; R CMD check clean.
plot.rceattle_osa() now draws up to two separate figures:
- Aggregate (index / catch): a Q-Q panel only (no age/length bin -> no bubbles).
- Composition (comp / caal): a Q-Q panel plus signed OSA-residual and
  Pearson-residual bubble panels, with age-based bins (age composition and
  conditional age-at-length) in the left column and length-based bins in the
  right column, each on its own bin axis.

Panel headers are two rows -- fleet name (from fleet_control) over the data
type. osa_residuals() now carries a fleet_name column and, for composition
types, attaches the matching Pearson residuals (the "pearson" attribute) so
plot() can show OSA and Pearson bubbles together. Adds a plot smoke test;
NEWS and docs updated; R CMD check clean.
- plot.rceattle_osa() gains source / species / combine arguments: source and
  species subset the figures (mirroring residuals()), and combine = FALSE draws
  the age and length composition as separate figures instead of side-by-side
  columns.
- osa_residuals(): rename the types argument to source (now also accepts "all"
  and "diet"), so residuals(), osa_residuals(), and plot() select data sources
  with one consistent vocabulary. type stays reserved for the residual kind
  (response / pearson / osa / process), per stats::residuals.glm().
- residuals.Rceattle() gains a species filter.
- Update internal callers (plot_comp, plot_indexresidual, dev scripts), tests,
  the model-diagnostics vignette, NEWS, and docs. R CMD check clean.
…siduals

- plot_comp() rewritten in ggplot2 (consistent with plot.rceattle_osa):
  composition Pearson bubbles plus observed-vs-fitted annual and aggregated
  figures. geom_area spans only the observed bins (no boundary overshoot), zero
  observed proportions are kept (only NA dropped), joint-sex is drawn on one
  age/length axis (females above, males below zero), and a species filter is
  added. .comp_resid_long() reuses residuals(type = "pearson").
- plot.rceattle_osa(): joint-sex composition bins are re-based onto a shared
  age/length axis and faceted by sex (OSA and Pearson bubbles); osa_residuals()
  now carries nages/nlengths for this.
- osa_residuals(): split the oneStepPredict() call by observation type so
  'discrete' is applied per type -- aggregate index/catch stay continuous, while
  composition can be discrete (randomized quantile via the generic method).
- residuals(): add source = "diet" (predator/prey schema, returned on its own)
  and extract .pearson_proportion() shared by the comp/caal/diet branches;
  plot_diet_comp() now draws its diet residuals from residuals(source = "diet").
- Add diet residual tests (mock + end-to-end make_msm_test_data fit) and a
  plot_comp smoke test. NEWS and docs updated; R CMD check clean.
Reflects the ggplot2 rewrite: Pearson-residual bubbles plus observed (shaded
area) vs predicted (line) composition, annual and aggregated, with joint-sex
drawn on one bin axis.
…rison

- tests: add a deterministic 'fitted objective unchanged' golden test (BS2017SS
  total jnll at the initial parameters, estimateMode = 3) that guards the OSA
  obsvec/keep machinery leaving normal fitting byte-identical.
- R/dev: remove the now-redundant OSA verification scratch scripts
  (osa_phase4_check, osa_plot_check, osa_caal_construct) -- their checks are
  covered by the testthat suite. The jnll-identical regression
  (osa_phase1-3_check) and the test runner are kept.
- tests/comparison/WHAM-growth-comparison.R: add a Rceattle-vs-WHAM OSA residual
  comparison (aggregate index/catch, plus a composition template) using the
  growth fork's make_osa_residuals(); the fork's age_comp_osa.hpp decomposition
  matches Rceattle's comp_osa.hpp.
…s, OSA speedup, diet tests

comp_offset is configurable via fit_control(comp_offset=) and stored on
data_list (default 1e-5; 0 reproduces a WHAM-style multinomial). It lives on
data_list so internal re-fits inherit it, is applied identically in the fitting
likelihood and the OSA observation vector, and is read by the TMB template as a
DATA_SCALAR. switch_check() fills it silently (set_default gains msg = NULL);
build_osa_data() and rearrange_data() read it from data_list.

osa_residuals() gains parallel (default TRUE): the per-observation one-step-ahead
loop runs via parallel::mclapply(), a near-linear speedup for random-effects
models. It probes TMB::openmp() and falls back to serial when several TMB models
are loaded (e.g. alongside WHAM, or the full test suite). The discrete
randomized-quantile path stays serial for reproducibility.

Likelihood / growth fixes validated against the WHAM growth fork:
- recruitment / initial-abundance bias correction centered at -sigma^2/2
- plus-group growth SD pinned in the second growth builder
- within-year plus-group mean length now pinned at Jan-1 for Richards too (was
  von-Bertalanffy-only), so the plus-group convention is consistent across
  growth families
- est_phase = 0 intercept linkages map the base parameter off (fix-at-init)
- init_dev added to the random vector only when actually estimated

Tests and docs: golden jnll = 1537036.2876293703 (default comp_offset); new diet
OSA tests (decomposition invariant + end-to-end); plus-group within-year test now
asserts pinning for VB and Richards; recruitment test uses the -sigma^2/2 mean;
composition-likelihood test uses the renormalized multinomial; WHAM comparison
scripts pinned to comp_offset = 0; NEWS, model-diagnostics vignette, Rd docs.
…s, OSA speedup, diet tests

comp_offset is configurable via fit_control(comp_offset=) and stored on
data_list (default 1e-5; 0 reproduces a WHAM-style multinomial). It lives on
data_list so internal re-fits inherit it, is applied identically in the fitting
likelihood and the OSA observation vector, and is read by the TMB template as a
DATA_SCALAR. switch_check() fills it silently (set_default gains msg = NULL);
build_osa_data() and rearrange_data() read it from data_list.

osa_residuals() gains parallel (default TRUE): the per-observation one-step-ahead
loop runs via parallel::mclapply(), a near-linear speedup for random-effects
models. It probes TMB::openmp() and falls back to serial when several TMB models
are loaded (e.g. alongside WHAM, or the full test suite). The discrete
randomized-quantile path stays serial for reproducibility.

Likelihood / growth fixes validated against the WHAM growth fork:
- recruitment / initial-abundance bias correction centered at -sigma^2/2
- plus-group growth SD pinned in the second growth builder
- within-year plus-group mean length now pinned at Jan-1 for Richards too (was
  von-Bertalanffy-only), so the plus-group convention is consistent across
  growth families
- est_phase = 0 intercept linkages map the base parameter off (fix-at-init)
- init_dev added to the random vector only when actually estimated

Tests and docs: golden jnll = 1537036.2876293703 (default comp_offset); new diet
OSA tests (decomposition invariant + end-to-end); plus-group within-year test now
asserts pinning for VB and Richards; recruitment test uses the -sigma^2/2 mean;
composition-likelihood test uses the renormalized multinomial; WHAM comparison
scripts pinned to comp_offset = 0; NEWS, model-diagnostics vignette, Rd docs.
`residuals()` now follows stats::residuals.glm() strictly: `type` selects the
residual kind only ("response"/"pearson"/"osa"/"process"); data sources are
selected with `source`. Passing a source name via `type` previously rerouted it
to `source` with a one-time warning; that back-compat path and its warning are
removed (match.arg now errors on an unknown `type`). Test and NEWS updated.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants