3. Model diagnostics

Rceattle provides a full set of model diagnostics:

  • convergence diagnostics: attached automatically to every fit
  • S3 methods: residuals(), logLik(), and the like
  • fit plots: compare predicted to observed data
  • retrospective analysis: peel off recent years to detect systematic bias
  • jitter testing: perturb starting values to check the optimizer found a global minimum
  • self-testing (simulation–estimation), check the model recovers its own estimates from simulated data
  • likelihood profiling: check how informative the data are about a given parameter

They 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.

Setup and plotting data

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)

S3 Methods

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)

Convergence diagnostics

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 ones

Re-run the battery on any fit with convergence_diagnostics():

convergence_diagnostics(model_1)

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

    1. A curve held at its inputs is a 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.

Naming the parameter, not just the block

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.

Fit plots

Composition data

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_comp(model_1)

Survey indices

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_index(model_1)
plot_index(model_1, log = TRUE)
plot_index_residual(model_1)

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.

plot_catchability(model_1)
plot_catchability(model_1, log = TRUE)

Catch

plot_catch(model_1)

One-step-ahead (OSA) residuals

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).

Choosing the one-step-ahead method

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.

With random effects, the choice reverses for the aggregate series

"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.

Fleets with tail accumulation

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:

  1. Inspect the aggregate fit across years (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.
  2. Compare the SDNR to its null interval (from osa_diagnostics()): a value outside the interval flags over- or under-dispersion, often a data-weighting or effective-sample-size issue.
  3. Compare the lower/upper tail statistics to their null intervals: these localize departures to the tails of the residual distribution. They are reported as order statistics, 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.
  4. Inspect Pearson bubble plots (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.
  5. Revise the model or data weighting where misspecification is found, using this together with the other diagnostics rather than as an accept/reject test.

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 iteration

Restrict 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.

tuned <- reweight_comps(model_1, fleets = c("Bottom_trawl", "Fishery"))

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

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.

pr <- process_residuals(model_1, process = "recruitment")
osa_diagnostics(pr)
plot(pr)

# residuals() exposes them too:
residuals(model_1, type = "process")

Retrospective analysis

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

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 (simulation–estimation)

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.

Redrawing process error

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:

truth <- attr(sims, "process_sim")[["Sim_1"]]
rec   <- truth$rec_dev[truth$rec_dev_drawn]

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:

sims <- self_test(model_1, nsim = 10, debug = TRUE)

failed <- names(which(!attr(sims, "converged")))
sims[[failed[1]]]$convergence          # which check failed, and on which parameter

Likelihood profile

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))
)

Moving a set of cells together

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).

sp <- 1
prof_M_all <- profile(
  fitted = model_1,
  param  = "M1",
  slots  = lapply(seq_len(model_1$data_list$nages[sp]),
                  function(a) c(sp, 1, a)),   # every age bin of species 1
  values = list(seq(0.7, 1.3, by = 0.05)),
  joint  = "multiply"
)

plot_profile(prof_M_all)

Catchability

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.

Where the data disagree

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.

comps <- profile_components(prof_M)
head(comps)

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.

Comparing runs

Overlaying sensitivity runs is itself a useful diagnostic, large divergences in biomass or mortality deserve scrutiny.

mod_list  <- list(model_1, model_1_retro$Rceattle_list$Year_2019)
mod_names <- 1:length(mod_list)

plot_biomass(Rceattle = mod_list, model_names = mod_names)
plot_recruitment(Rceattle = mod_list, model_names = mod_names, add_ci = TRUE)
plot_ssb_depletion(Rceattle = mod_list, model_names = mod_names)

Collecting it into report tables

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 fleet

It 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.

Model average

To average estimates across model variants, see ?model_average.

mod_avg <- model_average(list(model_1, model_1), weights = c(1,1))
plot_biomass(mod_avg)