Rceattle provides a full set of model diagnostics:
residuals(),
logLik(), and the likeThey all report the same way. Printing any of them opens with one line (the object, an overall status, and a verdict) before the detail:
<Rceattle convergence> status: OK
all checks OK
<Rceattle OSA diagnostics> status: WARN
7 of 7 source(s) outside a null interval; overall SDNR 1.82 (0.91-1.09)
source fleet n sdnr null
[WARN] index 4 36 2.52 0.77-1.23
...
<Rceattle retrospective> status: WARN
4 peel(s); 3 of 12 terminal Mohn's rho outside +/-0.2
quantity N Pollock Cod Arrowtooth flounder
[WARN] R 3 0.511 0.283 -0.221
<Rceattle jitter> status: OK
4 of 4 start(s) reached the best optimum (within 0.01)
<Rceattle self-test> status: NOTE
47 of 50 simulation(s) converged
convergence n
[OK ] OK 41
[NOTE] NOTE 6
observations redrawn, processes held at their fitted values: the spread is a lower bound
<Rceattle profile> status: WARN
sigmaR over 29 grid point(s); all converged
minimum -log L : 1274.8831 at slot_1 = 1.5
within 1.92 of the minimum : 1.3 to >=1.5
the minimum is at a grid edge (slot_1), so the grid does not bracket it, widen `values`
The severity words are the same four everywhere, OK,
NOTE, WARN, FAIL, and the overall
status is the worst present. The return values are
unchanged: each object is still the data frame or list it
always was, so retro$mohns, jit$nll,
diag$sdnr, prof$grid$slot_1 and
sims[["Sim_1"]] index exactly as before, and
c(sims, list(fit)) still gives a plain list of fits to
plot. Only the printing has an opinion.
Four of these hold a threshold you can set.
print(retro, band = ) is Mohn’s rho threshold; the default
0.2 is the rule of thumb below, and Hurtado-Ferro et
al. (2015) give asymmetric, life-history-dependent alternatives (−0.15
to 0.20 long-lived, −0.22 to 0.30 short-lived), which is why it is an
argument. print(jit, tol = ) is how close an objective must
be to count as having reached the same optimum.
print(sims, rate = ) is the fraction of simulations that
must converge before a self-test counts as clean.
print(prof, cutoff = ) bounds the reported interval; the
default 1.92 is the 95% profile-likelihood cutoff for one parameter,
half the 0.95 quantile of a chi-square on one degree of freedom.
Two of them report something the numbers alone cannot. A self-test
drops its non-converged runs before returning, so the length of what
comes back is not the number attempted; the header gives the rate
against nsim, and the last line says what generated the
replicates, since with processes held fixed the spread holds observation
error only. A profile whose minimum sits at the first or last grid value
has run out of grid rather than found the optimum; it plots as an
ordinary curve, so the print says so and asks for a wider
values.
library(Rceattle)
# Fit a single-species model for the 2022 Northern Rockfish assessment.
data("NorthernRockfish2022")
nrdata <- NorthernRockfish2022
nrdata$fleet_control$Proj_F_proportion <- 1
plot_data(nrdata)Fit the model.
model_1 <- Rceattle::fit_mod(
data_list = nrdata,
estimateMode = 0, # Estimate
initMode = 1, # Assume unfished equilibrium
M1Fun = build_M1(
updateM1 = TRUE,
M1_model = 1,
M1_use_prior = TRUE,
M_prior = 0.06,
M_prior_sd = 0.05),
fit_control = fit_control(phase = TRUE, verbose = 0)
)
summary(model_1)summary(model_1) # same compact summary
coef(model_1) # estimated fixed-effect parameters
logLik(model_1) # logLik with df attribute (AIC works)
AIC(model_1)
vcov(model_1) # fixed-effect covariance from sdreport
residuals(model_1) # response residuals, all data sources
residuals(model_1, type = "pearson") # Pearson residuals (glm-style `type`)
as.data.frame(model_1)After optimization, fit_mod() runs a battery of
convergence checks and attaches the result as
model$convergence. Each check is one record with a
severity ("OK", "NOTE",
"WARN", or "FAIL"); the object’s
status is the worst severity present. Non-OK checks are
reported via message() during the fit, and
print(model) shows the overall status. A non-converged
model is never turned into an error: the fit is always returned with its
diagnostics attached, so you decide whether to trust it.
model_1$convergence # overall status + any non-OK checks
print(model_1$convergence, all = TRUE) # show every check, including OK onesRe-run the battery on any fit with
convergence_diagnostics():
The checks cover:
max_gradient: maximum absolute
marginal gradient and the parameter holding it (WARN >
1e-3, FAIL > 1). When an sdreport of the
same parameters is available, the message also says how far the
estimates are from the optimum in standard errors. A quadratic
approximation puts the optimum a Newton step
-cov.fixed %*% gradient away, and each element is divided
by its own standard error. That size is comparable across log, logit and
natural-scale parameters, which the raw gradient is not: 0.02 means
finishing the optimization would move no estimate by more than 2% of its
standard error. The largest absolute value is in
$data$newton_step_se and the signed vector, which says
which way each estimate would move, in $data$step_se.
Reported only; the severity is still read on the gradient.
pdHess: whether the Hessian is
positive definite (FAIL if not).
sdreport_failed: FAIL
when an sdreport was requested but the Hessian could not be
inverted.
hessian_not_run: NOTE
when the fit was made with getsd = FALSE, so
pdHess, hessian_conditioning,
sdreport_failed and estimability had nothing
to read and never ran. It separates “every check passed” from “the
strongest checks were skipped”; refit with getsd = TRUE
before reading the status as convergence. Each refitting diagnostic
resolves its own getsd, so run_mse()’s refits
carry the note and a reweighted default fit does not.
zero_n_penalty: the “Zero n-at-age
penalty” likelihood row is non-zero for a species: its numbers-at-age,
Ricker intercept or (under an identity-link recruitment linkage)
recruitment sat on the 0.001 floor, so the fit is of a floored model
rather than the one specified. WARN when the
root-sum-square excursion is under 1 (a thousand fish),
FAIL above.
hessian_conditioning: how much
better the data pin down some parameter combinations than others.
Inverting the Hessian gives the covariance of the estimates;
standardising it by the standard errors gives their correlation, whose
eigenvalues are variances along directions in parameter space. The
condition number is the largest over the smallest. This is the
multi-parameter version of a 0.999 pairwise correlation, and catches
confounding spread over many parameters at once. On a fishery it usually
means F traded against selectivity: scale F up, shift selectivity down,
and predicted catch-at-age barely moves.
The message leads with the square root, a ratio of standard errors.
Because the matrix is scaled by those errors, the ratio is measured
against each parameter’s own: 13,634 means the least-determined
combination is 13,634 times more uncertain than the best-determined one,
in those units. WARN above 1e6, FAIL above
1e10.
Read on the correlation and not the covariance,
because the covariance condition number is not scale-invariant:
log_F sits near -2 and sel_inf near 10, so
rescaling a parameter would move it without changing the model.
Standardising costs sensitivity (the same three models read 4.6e4, 1.9e5
and 2.5e9 on the covariance against 2.0e4, 1.6e4 and 1.9e8 here) so a
model that is merely badly scaled no longer trips the check. The
covariance number is still reported, as
covariance_condition_number, for the numerical cost of
inverting the Hessian; pdHess and
sdreport_failed are what catch an inversion that actually
fails. The thresholds were held at 1e6/1e10 on three models:
BS2017SS sits at 2.0e4, roughly 1.7 orders below
WARN.
The parameters holding the top 90% of each block’s loading are then
named by fleet, bin and year, with a count: a ridge over ten years and
one over a single year read differently. Complements
TMBhelper::check_estimability, which gives a per-parameter
verdict, with a continuous severity and a direction.
parameters_on_bounds: parameters
that sit ON a configured build_bounds() limit (often
unidentified or mis-scaled). WARN: the edge of the
plausible range is still a well-posed optimum.
parameters_outside_bounds:
parameters PAST one, which is a different verdict and a
FAIL – the optimizer returned a value the model declared
implausible. fit_mod() passes the bounds to
nlminb, which respects them, but the Newton refinement
fit_control(newtonsteps) asks for afterwards is
unconstrained, so a parameter parked on a bound can be pushed through
it. Refit with newtonsteps = 0, the default.
variance_collapse: a deviation
standard deviation estimated to zero (WARN below 1e-3),
meaning the process it governs is not varying: a model configured as
time-varying has fitted something time-invariant. Covers the
recruitment, selectivity, catchability, M1 and linkage random-effect
standard deviations. WARN rather than FAIL
because a variance at the boundary is a well-posed optimum (a verdict
about the model, not the optimizer) and it can occur with a perfectly
clean gradient, which is the case nothing else catches.
stock_recruit: whether the fitted
curve is one the data informed. In a single-species model it is read
against the replacement line through steepness. Under predation
steepness does not exist (it needs spawning biomass per recruit), so the
curve is read over the observed SSB range instead: WARN
when it is flat there (recruitment independent of SSB), linear (no
density dependence), when a Ricker peaks below the lowest observed SSB,
when log alpha or log beta sits at its +/-30 bound, or when a log-scale
standard error exceeds
NOTE.exponential_q_near_one: a fleet
carrying an exponential catchability linkage whose FITTED
catchability is within 0.1% of 1. That link multiplies
log q, so at q = 1 the covariate moves the
index by nothing whatever the coefficient is, and the coefficient is
unidentified. WARN. Read it off the fitted base, not the
starting value: an intercept init,
fit_mod(inits = ) and a shared
Catchability_index group each make
Catchability_init something other than where
index_log_q began.
phasing: phases that ended with a
high gradient, localizing which parameter block is hard to fit.
estimability: surfaces
TMBhelper::check_estimability when it ran.
TMB names every element of obj$par after its parameter
block, so on their own max_gradient,
estimability, the two bounds checks and
hessian_conditioning could say sel_coff_dev
was flagged but not which of its 392 elements. Each now reports the
fleet, sex, age, bin and year instead, from
[parameter_index()], and follows the block name with the
quantity it estimates, from [parameter_dictionary()]:
[WARN] max_gradient Maximum absolute marginal gradient = 0.00222 on log_M1 (M1): Pollock, age 1.
[WARN] hessian_conditioning Condition number = 2.4e+06; loads on: log_F (F): 67% + rec_dev (recruitment deviations): 26%.
log_F (F) Hake_fishery, 38 years in 1980-2021 (38 of 44 parameters; 67% of the direction)
rec_dev (recruitment deviations) 24 years in 1981-2013 (24 of 44 parameters; 26% of the direction)
A scattered set of years or ages is counted against its span (“38 years in 1980-2021”); a consecutive run prints as a range. The count in parentheses is how many of the block’s parameters carry 90% of its loading.
The checks do not all count positions in the same vector.
max_gradient, estimability and the two bounds
checks are captured from the hindcast optimization;
hessian_conditioning reads the sdreport, which
under an estimating HCR belongs to the projection fit and holds only
log_Ftarget / log_Flimit. Each check therefore
verifies that the index describes the vector it is about to label, and
prints block names alone when it does not: a coordinate naming the wrong
parameter is worse than no coordinate.
parameter_index() itself describes fit$obj,
which under any HCR but "NoFishing" is that projection
object. Pass a fit run with estimateMode = "Hindcast" to
browse the hindcast parameters directly.
plot_comp() draws composition Pearson-residual bubbles
and overlays the observed (shaded area) and predicted (line) age or
length compositions for every fleet, both annually and aggregated across
years. Joint-sex data are drawn on a single bin axis with females above
and males below zero.
plot_index() shows observed vs. predicted survey
biomass; pass log = TRUE for the log scale. The predicted
line is the median of the fitted observation distribution, which is
where the lognormal likelihood is centred, so about half the
observations should fall below it. plot_index_residual()
plots the Pearson residual – observed minus predicted, standardized by
the sd the row was fitted with – a useful first check for time trends or
heteroscedasticity. A positive residual means the survey saw more than
the model predicted. These return ggplot objects.
plot_catchability() draws the other half of the same
fit: the catchability the model scaled each survey by, faceted by fleet.
It reads the realized q, so one figure covers an estimated
or fixed mean, annual deviations under Time_varying_q, an
environmental regression, a q linkage such as
rw(1 | Year), and the closed-form "Analytical"
value. A fleet with a time-invariant q draws a flat line.
Hindcast years only, the model does not project catchability.
Pearson residuals on composition data are not standard normal even when the model is correct: the age/length bins are correlated (more fish in one bin means fewer in another), and random effects induce correlation across years. They are at least standardized by the variance the fleet’s own likelihood assumes: the composition weight enters as an effective sample size under a multinomial, and a Dirichlet-multinomial’s overdispersion is in the denominator, so a weighted or DM fleet is not inflated on that account alone. One-step-ahead (OSA) residuals remove both problems. Each observation is residualized conditional on the observations added before it, integrating out the random effects, so under a correctly specified model the residuals are iid standard normal (Thygesen et al. 2017; Trijoulet et al. 2023). This makes them suitable for objective goodness-of-fit testing.
osa_residuals() computes them post hoc for any
combination of the fitted data types, survey indices
("index"), fishery catch ("catch"), age/length
composition ("comp"), conditional age-at-length
("caal"), predator diet ("diet"), and, where a
linkage holds an observed state-space covariate, the covariate
observation itself ("ecov"). Composition data use the
conditional binomial / beta-binomial decomposition. Because OSA
re-optimizes the random effects for each observation it is expensive, so
run it on a converged fit (estimateMode < 3). All
sources are available from any fit; osa_residuals() builds
the required observation data on demand and skips a requested source the
model has no observations for (it errors only if none of them
do). "diet" is the one source not in the default set, it
applies only to multispecies models and can be slow, so pass
source = "all" to include it. It needs a predicted
diet, which means msmMode > 0 and
estimated suitability (suitMode 2, 4 or 6); under empirical
suitability the model reads suitability from diet_data
rather than predicting stomach proportions, and the diet diagnostics
refuse rather than report every observation as a complete misfit.
# Aggregate (catch + index) residuals
osa <- osa_residuals(model_1, source = c("index", "catch"))
head(osa)
# Statistical diagnostics (Stewart & Monnahan 2025): SDNR and lower/upper tail
# statistics, each with the interval expected under the standard-normal null.
osa_diagnostics(osa)
# Q-Q plot (with SDNR / tail annotation) + residual-by-year
plot(osa)
# residuals() also exposes them in the common residual schema (`source` selects
# the data, like `type` in stats::residuals.glm()):
residuals(model_1, type = "osa", source = c("index", "catch"))
# Composition OSA residuals, no special fit required
plot_comp(model_1, residual_type = "osa") # Q-Q + signed bubbles
residuals(model_1, type = "osa", source = "comp")A triangle marks an outlier. On the OSA panel the cut is
qnorm(1 - 0.05 / (2 n)) for the panel’s n
residuals, so under the model a panel shows 0.05 triangles on average
whatever its size. The Pearson panel keeps |resid| > 3:
those residuals are sum-constrained and overdispersed, not N(0, 1).
method selects how the conditional distribution is read.
The default, "oneStepGaussianOffMode" (WHAM’s and SAM’s),
approximates it as Gaussian: the observation is treated as a free
variable, the mode of the conditional density is found in that
coordinate, and the observation is standardized against it.
method = "cdf" instead asks the model for the
conditional CDF and returns qnorm(F(x)). For a composition
bin the conditional is a binomial, written through the regularized
incomplete beta so it is defined at the fractional counts composition
data hold; for the aggregate series it is pnorm. On a
fixed-effect model nothing is approximated; with random effects see
below.
The two do not agree on composition data, and the disagreement is
measurable. Simulating observations from a fitted model and
residualizing them at the parameters that generated them makes the right
answer exactly standard normal, so the methods can be scored
(tools/verify/verify-osa-cdf.R, BS2017SS, 20 replicates of
4538 composition bins):
| method | mean | sd | lag-1 acf within a composition | KS rejects |
|---|---|---|---|---|
oneStepGaussianOffMode |
+0.103 | 0.918 | +0.060 | every replicate |
cdf, discrete = FALSE |
+0.610 | 1.262 | +0.434 | every replicate |
cdf, discrete = TRUE |
−0.007 | 0.995 | +0.001 | none |
The null standard error on the autocorrelation is 0.015, so only the
last row is consistent with the residuals being iid standard normal. A
composition bin holds a count, so its conditional CDF is a step function
and qnorm(F(x)) inherits the step; the randomized quantile
residual qnorm(F(x) − U f(x)) (Dunn and Smyth 1996, as
Trijoulet et al. 2023 prescribe) removes it. That is why
discrete defaults to TRUE under
"cdf", and why "cdf" with
discrete = FALSE is the worst of the three, not the best.
On the aggregate index and catch series, which really are Gaussian, all
three agree to 5e-5 wherever the 8.04 ceiling below does not bind.
The step it randomizes over is the model’s, not the
data’s: the counts the likelihood reads are
proportion × Sample_size, so on a fleet whose sample size
has been tuned they are not whole fish. That is the right choice: an OSA
residual asks whether the observation is consistent with the model as
specified, but it is worth knowing that a heavily down-weighted fleet is
being residualized against a multinomial it was never sampled from.
osa_cdf <- osa_residuals(model_1, source = c("index", "comp"), method = "cdf")
osa_diagnostics(osa_cdf)Three things to know before switching:
predicted is NA on every
row. The method never forms a conditional mean, which is the
point, under the Gaussian methods that mean is an expected bin count
that can come back negative on a sparse bin, and the residual on those
rows is then biased positive. Fitted values are in
model_1$quantities and in
residuals(model_1, type = "pearson"). (sd is
NA under the default method as well; only
method = "oneStepGaussian" returns one.)
A Dirichlet-multinomial fleet cannot use it. Its
conditional is a beta-binomial, which has no closed-form CDF and cannot
be summed at a fractional count. Those fleets are residualized with
"oneStepGaussianOffMode", announced in a message, and
recorded in the object’s method attribute. Their
predicted is blanked too, so the column keeps one meaning
across the object.
|residual| is censored at 8.04, in both
directions. The upper end is forced by double precision,
F is recovered as 1/(1 + exp(.)), which
saturates at the last double below one. The lower end is not: that same
expression takes a small F down to about 1e-308. It is
censored to match anyway, because an asymmetric ceiling would show as a
long left tail against a wall on the right, which is what skewness in
the residuals looks like. The cost is real,
osa_diagnostics() computes SDNR and the tail statistics on
the censored values, so it bites hardest on a short series where one
observation holds the statistic. osa_residuals() warns when
any residual sits there. Which method to reach for then is measured, not
obvious (tools/verify/verify-osa-cdf-accuracy.R, a 12-year
survey with one observation multiplied by 200):
| method | min | max | non-finite | SDNR |
|---|---|---|---|---|
oneStepGaussianOffMode |
−11.77 | 0.19 | 1 | NA |
oneStepGaussian |
−11.77 | 38.98 | 0 | 12.89 |
oneStepGeneric |
−3.95 | 3.33 | 0 | 2.21 |
cdf |
−8.04 | 8.04 | 0 | 4.59 |
Only "oneStepGaussian" reports the uncensored value
here: the package default returns NaN on that row, so its
SDNR is unusable rather than merely large, and
"oneStepGeneric" compresses the outlier harder than the
ceiling does. The default’s failure is magnitude-dependent (at a ×20
outlier it is finite and matches "oneStepGaussian") so
reach for that method specifically, and on the fleet in question: it
costs an nlminb per observation.
"cdf" is exact only on a fixed-effect model. With random
effects oneStepPredict() integrates the CDF over the latent
states by Laplace, and that integrand is a Gaussian times a sigmoid
rather than a density, where, for a Gaussian observation, the Gaussian
methods integrate a density and are exact. Measured against the exact
Kalman innovations of a linear-Gaussian state space model, as the latent
state becomes more informative relative to the observation (reproduced
by tools/verify/verify-osa-cdf-accuracy.R):
| latent/observation sd | fullGaussian |
oneStepGaussian |
cdf |
|---|---|---|---|
| 0.56 | 4e-16 | 3e-14 | 7e-04 |
| 1.12 | 1e-15 | 4e-14 | 6e-03 |
| 2.24 | 1e-15 | 3e-14 | 4e-02 |
That is a result about a linear-Gaussian model, and it does
not transfer wholesale. The Gaussian methods are exact when the
one-step-ahead predictive is Gaussian, which needs the model to
be linear in the random effects, and Rceattle’s index and catch are
exp() of cumulated log recruitment deviations pushed
through the population dynamics. fullGaussian and
oneStepGaussian cannot differ for a Gaussian conditional,
so their disagreement measures the departure: on a 17-deviation fixture
they differ by 0.091 on both index and catch, while "cdf"
differs from oneStepGaussian by 0.017 on index. No
method is exact for index or catch under random effects.
"ecov" is the exception, and there a Gaussian
method is right. Its conditional (a Gaussian measurement of an
AR1 latent, every other data term unconditional) genuinely is
linear-Gaussian: the two Gaussian methods agree to 4e-14 on the QAR1
fixture, while "cdf" sits 0.139 away, about a quarter of
the residual sd. It does not reverse for compositions: their conditional
is discrete and skewed, which is what the Gaussian methods get wrong,
and by much more. Simulating from a 22-random-effect model with the
recruitment deviations redrawn and residualizing at the generating
parameters (1680 residuals), oneStepGaussianOffMode gives
mean +0.513, sd 0.404 and KS rejection in every one of 120 replicates,
against +0.006, sd 1.002 and 6 of 120, the nominal 5%, for
"cdf" with discrete = TRUE.
What limits "cdf" on compositions is the depth of the
conditioning rather than the random effects themselves. On
BS2017SS with random_rec = TRUE (159 random
effects, 4538 composition bins) it returns 1879 non-finite residuals,
against 0 for "oneStepGaussianOffMode" on the same fit, and
takes hours; the same rows residualized on their own come back clean,
and the 22-random-effect model above is residualized correctly. So try
it, read the warning it issues, and where the residuals come back
non-finite in bulk fall back to a Gaussian method for that source,
remembering that its composition residuals are the under-dispersed ones
in the comparison above.
Both halves of that split are what the SAM authors do, in two
packages. stockassessment::residuals.sam() names no method,
so it takes oneStepPredict()’s
"oneStepGaussianOffMode" default with
discrete = FALSE, on a model that is random effects
throughout, whose observations are Gaussian on the log scale. Their
composition package compResidual::resMulti(), the
implementation Trijoulet et al. (2023) cite, instead hardcodes
method = "cdf", discrete = TRUE. Rceattle fits both kinds
of data, so it makes the same choice per observation type inside one
call rather than across two packages.
One caveat that applies to every method:
Estimate_index_sd = "Analytical",
Estimate_catch_sd = "Analytical" and the analytical
Catchability forms concentrate their parameter out using
all the observations, including the one being
residualized, so the conditioning is not strictly one-step-ahead and the
residuals are approximate – by an amount that shrinks as the series
lengthens.
Where a fleet folds its composition tails into a boundary bin
(Comp_accum_young / Comp_accum_old), both the
OSA and the Pearson residuals describe the bins the likelihood actually
fit, not the raw bins in comp_data. A fleet on 10 ages with
Comp_accum_young = 3 reports bins 3 to 10: bin 3 holds ages
1 to 3 combined, and the accumulated column
(Accumulated from residuals()) marks it, since
it stands for a range rather than the single age it is named for. Expect
fewer residual bins than raw age bins for such a fleet.
The two differ in one place. The one-step-ahead decomposition drops each composition’s last bin, which is fixed by the sum-to-sample-size constraint. Under an old-tail accumulation that dropped bin is the upper accumulated one, so the fleet’s upper boundary bin appears in the Pearson residuals and not in the OSA residuals.
Following Stewart and Monnahan (2025), a practical workflow is:
plot_comp(model_1)): systematic lack of fit here points to
structural misspecification (e.g. the wrong selectivity shape). Counts
are pooled across the fitted hindcast years, and the interval holds 95%
of the data the model predicts, not a confidence interval on the mean,
so observations outside it are the ones to explain. The panel is
annotated with the input sample size, the effective sample size the
likelihood assumed (ESS (likelihood)), and the one this
fit’s residuals imply (ESS (McAllister-Ianelli)). The gap
between those two is the reweighting decision: they agree when the fleet
is already weighted the way its residuals suggest.osa_diagnostics()): a value outside the interval flags
over- or under-dispersion, often a data-weighting or
effective-sample-size issue.lower_r / upper_r name which one, and
lower_p / upper_p the nominal probability it
sits at, which is near but not exactly 2.5%, against the exact
Beta(r, n - r + 1) null. The statistic and its interval
must come from the same estimator, so osa_diagnostics() no
longer simulates and its nsim / seed arguments
are ignored. The residuals themselves remain randomized-quantile
residuals: pass a seed to osa_residuals() to control
those.plot_comp(model_1), the default): patterns in the sign of
residuals across age/year can identify where lack of fit
occurs, Stewart and Monnahan recommend keeping these alongside OSA
residuals.When the diagnosis is a weighting problem rather than a structural
one, reweight_comps() runs the McAllister-Ianelli (1997)
tuning loop: it refits the model the way it was fitted, updating each
fleet’s Comp_weights until the implied weights settle.
tuned <- reweight_comps(model_1, n_iter = 10, tol = 0.01)
tuned$reweight$history # weight per fleet, per iterationRestrict it to particular fleets with fleets =, taking
either Fleet_code values or names matching
fleet_control$Fleet_name, give ids or names, not a mix,
since R coerces c(1, "Survey") to character.
Only multinomial fleets are tuned. A Dirichlet-multinomial fleet estimates its own weight inside the likelihood, so tuning it externally would compete with that estimate; those fleets are named and skipped.
The alternatives are Francis (2011) reweighting, applied by hand, and
estimating the weights directly with a Dirichlet-multinomial likelihood
(Comp_distribution = "DirichletMultinomial"), which avoids
the iteration entirely, note that under that likelihood
Comp_weights is read as the log of the
starting weight, so the model default of 1 is a starting
weight of e, and a weight of 1 is written as
0.
Because OSA residuals for discrete composition data are randomized
quantile residuals, they are stochastic; osa_residuals()
takes a seed for reproducibility.
process_residuals() is the complementary check on the
process model. It standardizes the model’s random-effect
deviations (recruitment, initial abundance, catchability) against their
process prior, drawing once from the joint posterior (SAM’s
procres; Nielsen and Berg 2014). Under a correct process
model these too are approximately iid standard normal.
A retrospective analysis removes the most recent years of data one peel at a time and re-fits the model. Mohn’s rho summarizes how the terminal estimates shift across peels; for SSB, a rho outside ±0.2 generally signals a problem worth investigating.
model_1_retro <- retrospective(model_1, peels = 5)
# Mohn's rho for each quantity
model_1_retro$mohns
# Plot historical trajectories across peels
plot_biomass(model_1_retro$Rceattle_list)
# Include the projection period to see how the forecast changes
plot_biomass(model_1_retro$Rceattle_list, incl_proj = TRUE)Each peel holds its own terminal year as
data_list$endyr, so the first plot draws every peel only as
far as it was fit and the peels fan out.
A peel still estimates the years it dropped: they are its
retrospective forecast, fit to the observed catch with recruitment
projected at the single deviation
sample_rec(sample_rec = FALSE) uses, computed over the
peel, and the survey and composition data withheld. Fishing mortality is
estimated through the unpeeled terminal year and the harvest control
rule takes over after it, so each peel has three years:
| Field | Meaning |
|---|---|
endyr, endyr_peel |
the peel’s terminal year, what it was fit through |
endyr_full |
the unpeeled terminal year, where the retrospective forecast ends |
projyr |
the end of the projection |
incl_proj = TRUE plots the forecast and projection
years. Take the forecast years as
endyr_peel + seq_len(endyr_full - endyr_peel), empty for
the unpeeled model, where (endyr_peel + 1):endyr_full would
count down instead.
The retrospective() function returns a list with two
elements:
| Element | Description |
|---|---|
Rceattle_list |
List of fitted models, ordered from most-peeled to full run, each
named for its own terminal year (Year_2013, …,
Year_2017) |
mohns |
Data frame with columns Object (quantity,
e.g. "Biomass", "SSB"),
Forecast year (0 = terminal year bias; 1+ = forecast
skill), N (number of peels), and one column per species
with mean relative error (Mohn’s rho) |
The nyrs_forecast argument (default 3) also evaluates
rho for years projected beyond each terminal peel, so a single call
quantifies forecast skill alongside retrospective bias.
A peel that did not converge is dropped and a warning reports how
many, so Rceattle_list can be shorter than
peels + 1. Index it by name rather than position, with a
peel missing, Rceattle_list[[3]] is not the 3-year peel. If
no peel converges, Mohn’s rho is NaN and the call
warns.
A separate warning means something different: under
getsd = TRUE a peel whose standard errors could not be
rebuilt is kept, with its point estimates intact and no
sdrep at all, so its uncertainty band is absent rather than
drawn at zero width. Mohn’s rho reads point estimates only and is
unaffected. print() reports how many peels that happened
to, and each carries the reason in
attr(peel, "sd_report_error").
Jitter testing re-fits the model from many randomly perturbed starting values and checks whether the optimizer keeps returning the same minimum negative log-likelihood (NLL). A spread of NLL values across jitters points to multiple local minima, so the results should be treated with caution.
jitters <- jitter(model_1, njitter = 10, phase = TRUE) # default njitter = 50
# Histogram of NLL differences relative to the best run
hist(log(jitters$nll - min(jitters$nll)),
main = "Jitter NLL spread (log scale)",
xlab = "log(NLL - min NLL)")
# Overlay biomass trajectories, tight overlap indicates a stable optimum
plot_biomass(jitters$Rceattle_list)
# Number of runs that converged
length(jitters$Rceattle_list)Non-converging runs are dropped from Rceattle_list and
reported in a message, so if length(jitters$Rceattle_list)
is well below njitter, convergence is fragile. Read that
count as part of the result, not as a nuisance: the dropped starts are
the ones that wandered off, so a jitter test that returns only its
successes understates the spread.
A run is dropped when its maximum absolute gradient exceeds 1 (the
same threshold convergence_diagnostics() calls
FAIL) or when an sdreport that was requested
did not come back.
retrospective() and jitter() both fit peels
/ starts in parallel by default. Pass cores = 1 to force
sequential execution; otherwise the call uses a FORK cluster where the
platform allows one, PSOCK on Windows, sized at
parallel::detectCores() - 6 (capped at 2 under
R CMD check).
self_test() simulates nsim datasets from a
fitted model (holding its estimated parameters fixed) and re-fits the
model to each. If the refit estimates cluster around the values used to
simulate them, the model is at least self-consistent: it can recover its
own estimates from data it generated. Persistent bias or a wide spread
in a quantity means the data are not informative about it.
sims <- self_test(model_1, nsim = 10) # default nsim = 50
# Number of simulations that converged (non-converged runs are dropped)
length(sims)
# Overlay biomass / SSB trajectories across simulations, the original fit's
# trajectory should sit inside the spread of the refits.
plot_biomass(c(list(model_1), sims), model_names = c("fit", names(sims)))
plot_ssb(c(list(model_1), sims), model_names = c("fit", names(sims)))Each simulation uses seed + i as its RNG seed, so
results are reproducible whether or not cores > 1. Set
simulate = FALSE to refit against the model’s
expected values (no observation error) rather than draws from
the observation likelihood, useful for confirming that the estimator
returns the generating parameters in the noise-free limit.
cores behaves the same way as in
retrospective() and jitter().
Every refit starts from the fitted model’s starting values,
not its estimates, so it covers the same ground the original fit did.
phase therefore defaults to whatever the input model was
fit with, pass phase = TRUE explicitly if you fit the model
some other way and the refits are being dropped, since a model that
needed phasing once needs it for every simulated data set too.
start = "estimated" starts from the fitted estimates
instead: much faster, but the optimizer then begins inside the basin
containing the generating values and recovery is close to guaranteed by
construction, so read it as optimistic about recovery rather than simply
less powerful.
Three things to keep in mind when reading the result. Non-converged
refits are dropped and reported in a message, and they are dropped
because they behaved badly (so the returned replicates are a
favourable subset, and any spread or bias computed over them (including
compare_sim()’s CV) understates the truth. By default only
the observations are redrawn: every replicate shares the operating
model’s single realization of recruitment and mortality, so the spread
across replicates is observation error alone) a lower bound on
estimation uncertainty in SSB and recruitment, not comparable to the
model’s own uncertainty bands.
Third, watch for a truncation warning on a natural-scale index. A
fleet with Index_distribution = "Normal",
"MVN" or "MVNORM" is drawn on the natural
scale with an absolute sd, so a draw can come out negative (impossible
for an index, and rejected by data_check(). Those draws are
redrawn, which samples the fleet from a normal truncated at
zero rather than the untruncated normal the likelihood maximises.
Occasional truncation is harmless; a lot of it is not. An acoustic index
observed at 0.70 with an absolute sd of 0.80 rejects a fifth of its
draws and the truncated mean sits 39% above the untruncated one, so the
self-test is measuring recovery against a data-generating process the
estimator never assumed. sim_mod() warns when any row puts
more than 2% of its probability at or below zero)
Phi(-mu/sd), read off the fit rather than counted from the
draw, so it does not vary between replicates.
The fix for a Normal fleet is
Index_distribution = "TruncatedNormal", which fits the
truncated density, renormalized by log Φ(µ/σ) over the
(0, ∞) the data can actually occupy, and draws from it by
inverse CDF. Draw and likelihood then agree exactly and no draw is ever
rejected. "Normal" is kept untruncated because it
reproduces the AMAK avo_like / cpue_like term
for term, which is what an ADMB bridge compares against; use it there
and "TruncatedNormal" elsewhere.
MVN/MVNORM have no such option: a multivariate
normal truncated to the positive orthant has no closed-form sampler, so
rejection is the only route and the gap stays. Either way the underlying
issue is the same, an absolute sd that large relative to the index means
the natural-scale normal was a poor observation model to begin with.
"TruncatedNormal" OSA residuals need one thing said
about them. The truncation constant is a function of the prediction, not
of the observation, so it drops out of any method that reads the
curvature of the density in the observation, including
oneStepPredict()’s default
oneStepGaussianOffMode, which would return the untruncated
(obs - µ)/σ. osa_residuals() therefore
residualizes this family in its own call, integrating the density over
(0, ∞), so the residual is qnorm(F(x)) for the
truncated F. The correction is the size of the truncated
mass: on a fleet predicting 100 with an absolute sd of 150, an
observation exactly at the prediction moves from a residual of 0 to
−0.44. You do not need to pass anything for this; method is
overridden for those rows only.
Under method = "cdf" none of that applies: the model
supplies the truncated F in closed form, so the family is
residualized in the main call, exactly, and the separate integration
(with its cost and its occasional failure to converge on a
random-effects model) is not needed.
process widens the question. The default
(FALSE) asks whether the estimator recovers its own
parameters from new observations; naming a process redraws that process
too, and asks whether it recovers something it was not shown.
# Recruitment deviations redrawn as well as the observations
sims <- self_test(model_1, nsim = 10, process = "recruitment")
# "dynamics" covers recruitment, M and growth; "observation" covers
# catchability and selectivity; TRUE covers everything.
sims <- self_test(model_1, nsim = 10, process = "dynamics")Recruitment covers the initial age structure as well as the hindcast
deviations, since the initial numbers-at-age are recruitment from before
styr. Natural mortality covers the M1_re
random effects, and growth, catchability and selectivity are redrawn
wherever they are written as a random linkage (see
vignette("environmental-linkages-and-priors")). A process
the model gives no distribution to (asking for M when
M1_re = 0, say) is left alone and reported in a warning
rather than silently returned unchanged.
Recruitment has one such case worth knowing about. Under
srr_fun = 0 with srr_pred_fun > 0 (the
AMAK/Ianelli configuration, where recruitment is estimated annually and
the stock-recruit curve is fitted as a penalty on those same
deviations) the deviations are scored by two densities at once.
Two terms on one quantity do not compose into a distribution to draw
from. Drawing at R_sd from the first alone ignores the
second, and the second is not a density on the deviations alone either:
it couples them to the stock-recruit parameters, which are estimated
too. Either way the self-test would measure recovery against a process
the model does not assume, so the deviations are not redrawn and
sim_mod() says why. A random linkage on a recruitment
parameter is a separate latent and is still drawn. Set
srr_fun to the stock-recruit function itself to redraw the
deviations.
The fitted deviations are no longer what generated the data, so the values that did come back attached to each replicate, each with a mask of the cells the draw touched:
Compare estimates against those, not against the operating model. The
mask matters: the process draws cover the hindcast only, and
beta_linkage_re spans every random linkage whether or not
its process was asked for, so a correlation taken over the whole array
scores fitted values as though they had been simulated. For the same
reason compare_sim(), which compares against the operating
model, is not valid for process-drawn replicates.
One caveat before reading a redrawn M as validation. On
BS2017SS with a year-varying M random effect, refits
recover the simulated deviations (correlation around 0.5, positive in
every replicate) but recover their standard deviation poorly, roughly
70% low, and collapsing to zero in a third of replicates. That is the
usual behaviour of a variance component estimated from little
information, not a fault in the simulation, and a year-varying M is
weakly informed because it trades off against selectivity and
catchability. Read a self-test that redraws M as a test of deviation
recovery rather than of variance recovery.
When the returned list is shorter than nsim,
debug = TRUE returns every simulation so the
failures can be read directly rather than inferred:
profile() re-fits the model across a grid of fixed
values for a chosen parameter and returns the NLL surface. A flat
profile means the data say little about the parameter; a minimum away
from the MLE means the fit has not truly settled there. It supports the
recruitment standard deviation, the stock–recruit parameters, and
natural mortality.
Specify the parameter with a natural-scale alias:
"sigmaR" (the standard deviation of the recruitment
deviations, which are on the log scale, so this is not a CV of
recruitment in thousands of fish), "M1" (natural
mortality), or the stock–recruit parameters "R0",
"alpha", and "beta". Aliases take
natural-scale values directly. slots gives the cell(s) to
profile (usually just the species index) and values gives
the grid of values for each. Supplying more than one slot profiles over
the full grid of combinations.
# 1-D profile: sigmaR for species 1 (natural-scale alias)
prof_sigmaR <- profile(
fitted = model_1,
param = "sigmaR",
slots = list(1),
values = list(seq(0.1, 1.5, by = 0.1))
)
plot(prof_sigmaR) # components, with the total overlaid; see below
# 1-D profile: SRR alpha for species 1
# (alias fills in the rec_pars column; slot is just the species index)
prof_alpha <- profile(
fitted = model_1,
param = "alpha",
slots = list(1),
values = list(seq(2, 80, length.out = 20))
)
# 2-D cross-profile: M1 across sex for species 1, age 1
prof_M_sex <- profile(
fitted = model_1,
param = "M1",
# One slot per sex on a two-sex model. This one is single-sex, so sex 1 is
# the only slot there is -- asking for sex 2 is out of bounds for log_M1.
slots = list(c(1, 1, 1)),
values = list(seq(0.10, 0.40, by = 0.05))
)slots cross, so k slots give a
k-dimensional profile. That is wrong for a parameter supplied
as a vector, an empirical age-based M profiled over 13 values across ten
ages would be 13^10 fits. joint puts every
named cell on one grid instead: "multiply"
scales each cell’s current value, preserving the shape of the schedule
(1 is the fitted model), and "add" shifts it,
preserving the increments (0 is the fitted model).
param = "q" profiles a fleet’s base catchability, and
slots takes a fleet name. Fleets sharing a
Catchability_index share one q, so naming any member
profiles the whole group; the others are added with a message.
prof_q <- profile(
fitted = model_1,
param = "q",
slots = list("Bottom_trawl"),
values = list(seq(0.5, 2.0, by = 0.1))
)profile() returns Rceattle_list (one fit
per grid row, NULL where the fit failed), grid
(the user-scale value grid, a multiplier or offset under
joint), nll (joint NLL aligned with
grid, NA for non-converged fits),
alias (the natural-scale name you profiled under, if you
used one), joint, and echoes of param and
slots. To cross-profile across multiple species, supply one
slot per species (e.g. slots = list(1, 2, 3) with
param = "sigmaR"); to cross-profile M1 across sex, supply
one slot per sex as in the third example above. cores
behaves the same way as in the other diagnostic functions.
The total says how well the data determine the parameter, not whether
the data sources agree about it, a smooth quadratic total can be two
surveys pulling in opposite directions. plot_profile()
draws the components separately, in the layout
r4ss::SSplotProfile() uses.
prof_M <- profile(
fitted = model_1,
param = "M1",
slots = list(c(1, 1, 1)),
values = list(seq(0.20, 0.45, by = 0.025))
)
plot_profile(prof_M, xlab = "M at age 1")Read it by where each curve bottoms out, not by how deep it is: every series is re-zeroed at its own minimum and a point marks it, so the spread of those points along the x axis is the disagreement. The heavy black line is the total.
When one component dwarfs the rest the others flatten onto the axis
and where they prefer the parameter cannot be read.
relative = "scaled" puts every curve on 0 to 1 so the
minima can be compared directly; it discards magnitude, so raise
minfraction alongside it.
profile_components() returns the same numbers, one row
per grid point per component, each labelled with the fleet or species it
belongs to.
Under random_rec = TRUE the total is the
Laplace-approximated marginal likelihood while the components are the
inner joint negative log-likelihood, so they will not sum; compare the
shapes, not the sums. See ?plot_profile for
relative, minfraction, model overlays, and
which components weighted = FALSE changes.
Overlaying sensitivity runs is itself a useful diagnostic, large divergences in biomass or mortality deserve scrutiny.
Everything above returns its own shape. report_tables()
gathers them into one set of tidy tables, the fit, the likelihood
decomposition, the time series with uncertainty, the reference points,
the fits to index and catch, and whichever diagnostics you pass it:
tabs <- report_tables(model_1,
retro = model_1_retro, jitter = jitters, osa = osa)
tabs
tabs$retrospective # Mohn's rho by species and forecast year
tabs$osa # SDNR and tail statistics per fleetIt never refits, so a diagnostic you have not run is simply an absent
section. Check the basis column of
tabs$reference_points before quoting a reference point:
quantities the fit never estimated are returned as NA with
the reason, rather than as the value left in the array.
standard_output() relabels the result for the
stockplotr and asar reporting packages.