EBS Pollock FIMS Implementation

Overview

This document summarizes a simplified FIMS implementation for EBS pollock based on inputs from the main 2024 EBS pollock stock assessment (NPFMC SAFE 2024 EBS pollock assessment). The model is a single-population catch-at-age assessment with one fishery fleet and four index series (BTS, ATS, AVO, and CPUE). Length compositions and seasonal timing are omitted.

The current FIMS configuration should be interpreted as a reduced implementation of the 2024 assessment rather than a one-for-one reproduction. Several assessment features are simplified or not yet represented here:

  • In the 2024 assessment, age-1 observations from the acoustic-trawl survey and bottom trawl survey are treated as separate age-1 abundance indices, while the remaining ages are aggregated into biomass indices for those surveys. The FIMS implementation currently uses the survey biomass index series directly and handles age information through age-composition observations, rather than splitting age-1 survey signals into separate index fleets.
  • The 2024 assessment links AVO selectivity to the ATS selectivity pattern. In this FIMS implementation, AVO is represented with fixed double-logistic selectivity parameters rather than sharing the estimated ATS selectivity curve.
  • The 2024 assessment allows time variation in the BTS selectivity inflection point for ages 2 and older. The baseline FIMS run uses time-invariant logistic BTS selectivity.
  • The 2024 assessment fishery selectivity is represented with non-parametric coefficients and regularity penalties. The baseline FIMS run uses a parametric logistic fishery selectivity curve, while the TVselex exploratory run uses a double-logistic fishery curve with annual variation only in the ascending inflection point.
  • The 2024 assessment uses age-specific natural mortality. This FIMS implementation now fixes natural mortality by age, constant over years, at (M_1 = 0.9), (M_2 = 0.45), and (M_{3+} = 0.3).
  • Other apparent simplifications include omitting length-composition data, omitting seasonal/subannual timing structure, using empirical stock-level weight-at-age inputs, and keeping recruitment variance treatment simpler than the production assessment configuration.

Data Inputs

The index series include ATS (Acoustic-trawl survey), BTS (Bottom trawl survey), CPUE (catch per unit effort), and AVO (Acoustic Vessels of Opportunity; opportunistic backscatter data collection and processing).

Show code
doc_dir <- tryCatch(dirname(knitr::current_input()), error = function(e) NA_character_)
if (is.na(doc_dir) || doc_dir == ".") doc_dir <- getwd()
project_root <- if (file.exists(file.path(doc_dir, "data"))) doc_dir else file.path(doc_dir, "..")
project_root <- normalizePath(project_root, mustWork = FALSE)
input_path <- file.path(project_root, "data", "ebs_fims_data.rds")
if (!file.exists(input_path)) {
  stop(
    "Missing input data at: ",
    normalizePath(input_path, mustWork = FALSE),
    "\nRun: Rscript scripts/01_build_data.R"
  )
}

payload <- readRDS(input_path)

data_ebs <- payload$data_ebs
years <- payload$years
ages <- payload$ages

The input summary is shown in Table 1, and data availability by fleet and data type is shown in Table 2.

Show code
# Basic input summary
summary_tbl <- tibble::tibble(
  item = c(
    "Years",
    "Ages",
    "Fleets",
    "Data types"
  ),
  value = c(
    paste0(min(years), "–", max(years), " (", length(years), ")"),
    paste0(min(ages), "–", max(ages), " (", length(ages), ")"),
    paste(sort(unique(data_ebs$name)), collapse = ", "),
    paste(sort(unique(data_ebs$type)), collapse = ", ")
  )
)

summary_tbl |>
  gt_report() |>
  tab_header(title = "FIMS Input Summary")
Table 1: Summary of the EBS pollock FIMS input data.
FIMS Input Summary
item value
Years 1964–2024 (61)
Ages 1–15 (15)
Fleets ats, avo, bts, cpue, fishery, ssb
Data types age_comp, index, landings, weight_at_age
Show code
# Data availability by fleet/type
availability <- data_ebs |>
  filter(value != -999) |>
  group_by(name, type) |>
  summarize(
    n_obs = n(),
    years = paste0(min(timing, na.rm = TRUE), "–", max(timing, na.rm = TRUE)),
    .groups = "drop"
  )

availability |>
  gt_report() |>
  tab_header(title = "Data Availability by Fleet")
Table 2: Available observations by fleet and data type.
Data Availability by Fleet
name type n_obs years
ats age_comp 285 1994–2024
ats index 19 1994–2024
avo index 18 2006–2024
bts age_comp 630 1982–2024
bts index 42 1982–2024
cpue index 12 1965–1976
fishery age_comp 900 1964–2023
fishery landings 61 1964–2024
ssb weight_at_age 930 1964–2025

Model Implementation

Structure

  • Population: single stock
  • Fleets: fishery, BTS, ATS, AVO, CPUE
  • Recruitment: Beverton-Holt with fixed-effect deviations and fixed recruitment log-SD
  • Natural mortality: fixed by age and constant over years, with (M_1 = 0.9), (M_2 = 0.45), and (M_{3+} = 0.3)
  • Selectivity: asymptotic logistic for fishery and BTS; estimated double logistic for ATS; fixed double logistic for AVO
  • Selectivity sharing: CPUE shares fishery selectivity
  • Growth: empirical WAA from stock-level SSB weights
  • Maturity: logistic

Run Definitions

Two model runs are defined in this implementation:

Run Description Selectivity treatment
run0 Initial FIMS run used for the current fitted results. Fishery and BTS use time-invariant logistic selectivity; ATS uses time-invariant double-logistic selectivity; AVO double-logistic selectivity is fixed.
TVselex Time-varying fishery selectivity run. Fishery selectivity is changed to a double-logistic form and only the ascending inflection point is expanded over model years; AVO double-logistic selectivity is fixed.

The requested TVselex run is specified with the FIMS DoubleLogisticSelectivity module because the installed FIMS version used here (0.9.2) exposes logistic and double-logistic selectivity classes, but not a double-normal selectivity class.

The run0 parameter specifications are adjusted from the FIMS defaults before fitting as follows.

cfg <- create_default_configurations(data_4_model) |>
  tidyr::unnest(cols = data) |>
  dplyr::rows_update(
    tibble::tibble(
      module_name = "Selectivity",
      fleet_name = c("fishery", "bts", "ats", "avo"),
      module_type = c("Logistic", "Logistic", "DoubleLogistic", "DoubleLogistic")
    ),
    by = c("module_name", "fleet_name")
  ) |>
  tidyr::nest(.by = c(model_family, module_name, fleet_name))

pars <- create_default_parameters(cfg, data_4_model) |>
  tidyr::unnest(cols = data) |>
  dplyr::rows_update(
    tibble::tibble(
      module_name = "Recruitment",
      label = c(
        rep("log_devs", length((get_start_year(data_4_model) + 1):get_end_year(data_4_model))),
        "log_sd"
      ),
      time = c(
        (get_start_year(data_4_model) + 1):get_end_year(data_4_model),
        NA_real_
      ),
      value = c(
        rep(0, length((get_start_year(data_4_model) + 1):get_end_year(data_4_model))),
        0.1
      ),
      estimation_type = c(
        rep("fixed_effects", length((get_start_year(data_4_model) + 1):get_end_year(data_4_model))),
        "constant"
      )
    ),
    by = c("module_name", "label", "time")
  ) |>
  dplyr::mutate(
    value = dplyr::case_when(
      module_name == "Population" & label == "log_M" & age == 1 ~ log(0.9),
      module_name == "Population" & label == "log_M" & age == 2 ~ log(0.45),
      module_name == "Population" & label == "log_M" & age >= 3 ~ log(0.3),
      TRUE ~ value
    ),
    estimation_type = dplyr::case_when(
      module_name == "Population" & label == "log_M" ~ "constant",
      TRUE ~ estimation_type
    )
  ) |>
  dplyr::rows_update(
    tibble::tibble(
      module_name = "Selectivity",
      fleet_name = "avo",
      label = c(
        "inflection_point_asc",
        "slope_asc",
        "inflection_point_desc",
        "slope_desc"
      ),
      value = c(1.5, 2.0, 8.0, 0.1),
      estimation_type = "constant"
    ),
    by = c("module_name", "fleet_name", "label")
  ) |>
  dplyr::mutate(
    selectivity_shared_with = dplyr::case_when(
      module_name == "Selectivity" & fleet_name == "cpue" ~ "fishery",
      TRUE ~ NA_character_
    )
  )

The TVselex run starts from the same data and recruitment settings, but changes fishery selectivity to DoubleLogistic and makes only the ascending inflection point parameter time-varying by creating one row for that parameter in each model year. The remaining fishery double-logistic parameters are retained as time-invariant fixed effects.

cfg_tvselex <- create_default_configurations(data_4_model) |>
  tidyr::unnest(cols = data) |>
  dplyr::rows_update(
    tibble::tibble(
      module_name = "Selectivity",
      fleet_name = c("fishery", "bts", "ats", "avo"),
      module_type = c("DoubleLogistic", "Logistic", "DoubleLogistic", "DoubleLogistic")
    ),
    by = c("module_name", "fleet_name")
  ) |>
  tidyr::nest(.by = c(model_family, module_name, fleet_name))

pars_tvselex_base <- create_default_parameters(cfg_tvselex, data_4_model) |>
  tidyr::unnest(cols = data) |>
  dplyr::rows_update(
    tibble::tibble(
      module_name = "Recruitment",
      label = c(
        rep("log_devs", length((get_start_year(data_4_model) + 1):get_end_year(data_4_model))),
        "log_sd"
      ),
      time = c(
        (get_start_year(data_4_model) + 1):get_end_year(data_4_model),
        NA_real_
      ),
      value = c(
        rep(0, length((get_start_year(data_4_model) + 1):get_end_year(data_4_model))),
        0.1
      ),
      estimation_type = c(
        rep("fixed_effects", length((get_start_year(data_4_model) + 1):get_end_year(data_4_model))),
        "constant"
      )
    ),
    by = c("module_name", "label", "time")
  ) |>
  dplyr::mutate(
    value = dplyr::case_when(
      module_name == "Population" & label == "log_M" & age == 1 ~ log(0.9),
      module_name == "Population" & label == "log_M" & age == 2 ~ log(0.45),
      module_name == "Population" & label == "log_M" & age >= 3 ~ log(0.3),
      TRUE ~ value
    ),
    estimation_type = dplyr::case_when(
      module_name == "Population" & label == "log_M" ~ "constant",
      TRUE ~ estimation_type
    )
  ) |>
  dplyr::rows_update(
    tibble::tibble(
      module_name = "Selectivity",
      fleet_name = "avo",
      label = c(
        "inflection_point_asc",
        "slope_asc",
        "inflection_point_desc",
        "slope_desc"
      ),
      value = c(1.5, 2.0, 8.0, 0.1),
      estimation_type = "constant"
    ),
    by = c("module_name", "fleet_name", "label")
  )

fishery_tvselex <- pars_tvselex_base |>
  dplyr::filter(
    module_name == "Selectivity",
    fleet_name == "fishery",
    label == "inflection_point_asc"
  ) |>
  dplyr::select(-time) |>
  tidyr::crossing(time = years) |>
  dplyr::mutate(estimation_type = "fixed_effects")

pars_tvselex <- pars_tvselex_base |>
  dplyr::filter(
    !(
      module_name == "Selectivity" &
        fleet_name == "fishery" &
        label == "inflection_point_asc"
    )
  ) |>
  dplyr::bind_rows(fishery_tvselex) |>
  dplyr::mutate(
    selectivity_shared_with = dplyr::case_when(
      module_name == "Selectivity" & fleet_name == "cpue" ~ "fishery",
      TRUE ~ NA_character_
    )
  )

input_tvselex <- initialize_fims(pars_tvselex, data_4_model)

Results

Fit to run0

The current fitted results are for run0. The fitted spawning biomass trajectory is shown in Figure 1. Fishery observed and predicted landings are compared in Figure 2, while survey and CPUE index fits are summarized in Figure 3 and residual patterns are shown in Figure 4. These index series are shown on their native arithmetic scales with separate facet scales because the magnitudes differ across surveys and CPUE, and the residuals are standardized by the input observation standard deviations on the arithmetic scale. Expected age-composition outputs for fishery, BTS, and ATS are shown in Figure 5, Figure 6, and Figure 7, respectively, while selectivity is shown in Figure 8. The time-invariant selectivity assumptions used to initialize each gear are shown for selected years in Figure 9. A diagnostics overview, including model status and the fitted SSB and recruitment trajectories, is shown in Table 5 and Figure 11. The estimated stock-recruitment relationship for both fitted models is shown in Figure 12. Likelihood diagnostics and key parameter summaries are reported in Table 6, Table 7, and Table 8.

Show code
if (!exists("doc_dir")) {
  doc_dir <- tryCatch(dirname(knitr::current_input()), error = function(e) NA_character_)
  if (is.na(doc_dir) || doc_dir == ".") doc_dir <- getwd()
}
if (!exists("project_root")) {
  project_root <- if (file.exists(file.path(doc_dir, "data"))) doc_dir else file.path(doc_dir, "..")
}
fit_path <- file.path(project_root, "outputs", "fims_fit_summary.rds")
if (!file.exists(fit_path)) {
  stop(
    "Missing model fit summary at: ",
    normalizePath(fit_path, mustWork = FALSE),
    "\nRun: Rscript scripts/02_fit_model.R"
  )
}

fit <- readRDS(fit_path)
est <- fit$estimates
rep <- fit$report

# Map year index to actual years (robust to 0- or 1-based index)
map_year <- function(year_i) {
  years[year_i]
}
Show code
ssb <- est |>
  filter(label == "spawning_biomass") |>
  mutate(year = map_year(year_i))

ggplot(ssb, aes(x = year, y = estimated)) +
  geom_line(color = "#1b9e77", linewidth = 0.8) +
  scale_y_continuous(limits = c(0, NA)) +
  labs(x = "Year", y = "SSB", title = "Spawning Biomass")
Figure 1: Estimated spawning biomass over time from the fitted EBS pollock FIMS model.

Landings Fit

Show code
fishery_landings_expected <- rep$landings_expected[[which.max(vapply(rep$landings_expected, sum, numeric(1), na.rm = TRUE))]]

land <- tibble::tibble(
  year = years,
  expected = fishery_landings_expected,
  observed = data_ebs |>
    filter(type == "landings", name == "fishery") |>
    arrange(timing) |>
    pull(value)
)

ggplot(land, aes(x = year)) +
  geom_line(aes(y = expected), color = "#377eb8", linewidth = 0.8) +
  geom_point(aes(y = observed), color = "#377eb8", size = 1.2, alpha = 0.7) +
  scale_y_continuous(limits = c(0, NA)) +
  labs(x = "Year", y = "Landings", title = "Fishery Landings: Observed vs Expected")
Figure 2: Observed fishery landings and predicted fishery landings from the fitted model.

Fits by fleet

Show code
obs_index <- data_ebs |>
  filter(type == "index", value != -999) |>
  transmute(series = toupper(name), year = timing, observed = value, uncertainty = uncertainty)

pred_index <- purrr::map2_dfr(
  rep$index_expected,
  seq_along(rep$index_expected),
  ~tibble::tibble(series_id = .y, year = years, predicted = .x)
)

score_map <- function(pred_tbl, obs_tbl) {
  obs_names <- sort(unique(obs_tbl$series))
  pred_ids <- sort(unique(pred_tbl$series_id))
  perms <- expand.grid(rep(list(pred_ids), length(obs_names)))
  perms <- perms[apply(perms, 1, function(x) length(unique(x)) == length(x)), , drop = FALSE]

  scores <- purrr::map_dfr(seq_len(nrow(perms)), function(i) {
    perm <- as.integer(perms[i, ])
    total_score <- 0
    for (j in seq_along(obs_names)) {
      obs_j <- obs_tbl |>
        filter(series == obs_names[j]) |>
        select(year, observed)
      pred_j <- pred_tbl |>
        filter(series_id == perm[j]) |>
        select(year, predicted)
      joined <- left_join(obs_j, pred_j, by = "year")
      total_score <- total_score + sqrt(mean((joined$observed - joined$predicted)^2, na.rm = TRUE))
    }
    tibble::tibble(row_id = i, total_score = total_score)
  })

  best <- perms[scores$row_id[which.min(scores$total_score)], ]
  tibble::tibble(series = obs_names, series_id = as.integer(best[1, ]))
}

index_map <- score_map(pred_index, obs_index)

fits <- pred_index |>
  inner_join(index_map, by = "series_id") |>
  left_join(obs_index, by = c("series", "year")) |>
  mutate(series = factor(series, levels = c("BTS", "ATS", "AVO", "CPUE")))

ggplot(fits, aes(x = year)) +
  geom_line(aes(y = predicted), color = "#377eb8", linewidth = 0.8) +
  geom_point(aes(y = observed), color = "#377eb8", size = 1.1, alpha = 0.7, na.rm = TRUE) +
  facet_wrap(~series, scales = "free_y") +
  scale_y_continuous(limits = c(0, NA)) +
  labs(x = "Year", y = "Index", title = "Survey and CPUE Fits on Arithmetic Scale")
Figure 3: Observed and predicted index series for BTS, ATS, AVO, and CPUE shown on arithmetic scales with separate facet axes.

Residuals

Show code
log_sd_to_sd <- function(mu, log_sd) {
  mu * sqrt(exp(log_sd^2) - 1)
}

resid_df <- fits |>
  mutate(
    obs_sd = log_sd_to_sd(observed, uncertainty),
    std_residual = ifelse(is.finite(obs_sd) & obs_sd > 0, (observed - predicted) / obs_sd, NA_real_)
  )

ylim_zero <- function(x) {
  c(min(0, min(x, na.rm = TRUE)), max(0, max(x, na.rm = TRUE)))
}

ggplot(resid_df, aes(x = year, y = std_residual)) +
  geom_hline(yintercept = 0, color = "gray50", linewidth = 0.4) +
  geom_point(size = 1.1, alpha = 0.7) +
  facet_wrap(~series, scales = "free_y") +
  scale_y_continuous(limits = ylim_zero(resid_df$std_residual)) +
  labs(x = "Year", y = "Standardized residual", title = "Survey and CPUE Standardized Residuals")
Figure 4: Standardized arithmetic residuals for BTS, ATS, AVO, and CPUE. Residuals are divided by the input observation standard deviation on the arithmetic scale.

Age composition fits

Show code
agecomp_counts <- vapply(rep$agecomp_expected, function(x) sum(x > 0, na.rm = TRUE), numeric(1))
agecomp_map <- c(
  fishery = which(agecomp_counts == 900)[1],
  bts = which(agecomp_counts == 630)[1],
  ats = which(agecomp_counts == 285)[1]
)

normalize_agecomp <- function(data, value_col) {
  value_col <- rlang::ensym(value_col)

  data |>
    group_by(year) |>
    mutate(
      .total = sum(!!value_col, na.rm = TRUE),
      !!value_col := ifelse(.total > 0, !!value_col / .total, 0)
    ) |>
    ungroup() |>
    select(-.total)
}

make_agecomp_df <- function(series_id) {
  tibble::tibble(
    year = rep(years, each = length(ages)),
    age = rep(ages, times = length(years)),
    expected = rep$agecomp_expected[[series_id]]
  ) |>
    group_by(year) |>
    filter(any(expected > 0, na.rm = TRUE)) |>
    ungroup() |>
    normalize_agecomp(expected)
}

make_obs_agecomp_df <- function(fleet_name) {
  data_ebs |>
    filter(type == "age_comp", name == fleet_name, value != -999) |>
    transmute(year = timing, age, observed = value) |>
    normalize_agecomp(observed)
}

agecomp_fishery <- make_agecomp_df(agecomp_map[["fishery"]])
obs_agecomp_fishery <- make_obs_agecomp_df("fishery")

ggplot(agecomp_fishery, aes(x = age, y = expected)) +
  geom_line(linewidth = 0.5, color = "#4daf4a") +
  geom_point(
    data = obs_agecomp_fishery,
    aes(x = age, y = observed),
    inherit.aes = FALSE,
    color = "#1f78b4",
    size = 0.9,
    alpha = 0.8
  ) +
  facet_wrap(~year, ncol = 5, dir = "v") +
  scale_y_continuous(limits = c(0, NA)) +
  labs(x = "Age", y = "Proportion", title = "Fishery Age Composition Fits")
Figure 5: Expected fishery age-composition patterns, faceted by year.
Show code
agecomp_bts <- make_agecomp_df(agecomp_map[["bts"]])
obs_agecomp_bts <- make_obs_agecomp_df("bts")

ggplot(agecomp_bts, aes(x = age, y = expected)) +
  geom_line(linewidth = 0.5, color = "#4daf4a") +
  geom_point(
    data = obs_agecomp_bts,
    aes(x = age, y = observed),
    inherit.aes = FALSE,
    color = "#1f78b4",
    size = 0.9,
    alpha = 0.8
  ) +
  facet_wrap(~year, ncol = 4, dir = "v") +
  scale_y_continuous(limits = c(0, NA)) +
  labs(x = "Age", y = "Proportion", title = "BTS Age Composition Fits")
Figure 6: Observed and predicted BTS age-composition patterns, faceted by year.
Show code
agecomp_ats <- make_agecomp_df(agecomp_map[["ats"]])
obs_agecomp_ats <- make_obs_agecomp_df("ats")

ggplot(agecomp_ats, aes(x = age, y = expected)) +
  geom_line(linewidth = 0.5, color = "#4daf4a") +
  geom_point(
    data = obs_agecomp_ats,
    aes(x = age, y = observed),
    inherit.aes = FALSE,
    color = "#1f78b4",
    size = 0.9,
    alpha = 0.8
  ) +
  facet_wrap(~year, ncol = 3, dir = "v") +
  scale_y_continuous(limits = c(0, NA)) +
  labs(x = "Age", y = "Proportion", title = "ATS Age Composition Fits")
Figure 7: Observed and predicted ATS age-composition patterns, faceted by year.

Selectivity curves

Show code
logistic_selectivity <- function(age, inflection_point, slope) {
  1 / (1 + exp(-slope * (age - inflection_point)))
}

double_logistic <- function(age, inflection_point_asc, slope_asc, inflection_point_desc, slope_desc) {
  asc <- 1 / (1 + exp(-slope_asc * (age - inflection_point_asc)))
  desc <- 1 / (1 + exp(slope_desc * (age - inflection_point_desc)))
  asc * desc
}

selectivity_template <- tibble::tribble(
  ~fleet,     ~module_type,
  "ats",      "DoubleLogistic",
  "avo",      "DoubleLogistic",
  "bts",      "Logistic",
  "fishery",  "Logistic"
)

selectivity_parameters <- est |>
  filter(module_name == "Selectivity") |>
  distinct(parameter_id, module_type, label, estimated) |>
  arrange(parameter_id) |>
  mutate(block_id = cumsum(c(TRUE, diff(parameter_id) != 1L))) |>
  group_by(block_id) |>
  mutate(module_type = first(module_type)) |>
  ungroup() |>
  distinct(block_id, module_type, label, estimated) |>
  group_by(block_id, module_type) |>
  summarize(values = list(stats::setNames(estimated, label)), .groups = "drop") |>
  left_join(
    selectivity_template |>
      mutate(block_id = row_number()),
    by = c("block_id", "module_type")
  )

selectivity_curves <- selectivity_parameters |>
  rowwise() |>
  mutate(
    curve = list({
      pars <- values
      sel <- if (module_type == "Logistic") {
        logistic_selectivity(ages, pars[["inflection_point"]], pars[["slope"]])
      } else {
        double_logistic(
          ages,
          pars[["inflection_point_asc"]],
          pars[["slope_asc"]],
          pars[["inflection_point_desc"]],
          pars[["slope_desc"]]
        )
      }

      tibble(age = ages, selectivity = sel)
    })
  ) |>
  ungroup() |>
  select(fleet, curve) |>
  unnest(curve)

ggplot(selectivity_curves, aes(x = age, y = selectivity)) +
  geom_line(color = "#984ea3", linewidth = 0.8) +
  facet_wrap(~fleet) +
  scale_y_continuous(limits = c(0, NA)) +
  labs(x = "Age", y = "Selectivity", title = "Selectivity at Age")
Figure 8: Estimated selectivity-at-age curves by fleet. Curves are reconstructed from the fitted fleet-specific selectivity parameter blocks.
Show code
selected_years <- c(min(years), years[ceiling(length(years) / 2)], max(years))

sel_assumptions <- tidyr::crossing(
  year = selected_years,
  gear = c("fishery", "bts", "ats", "avo"),
  age = ages
) |>
  mutate(
    selectivity = dplyr::case_when(
      gear %in% c("fishery", "bts") ~ logistic_selectivity(
        age = age,
        inflection_point = 2,
        slope = 1
      ),
      TRUE ~ double_logistic(
        age = age,
        inflection_point_asc = dplyr::if_else(gear == "avo", 1.5, 2),
        slope_asc = dplyr::if_else(gear == "avo", 2.0, 1),
        inflection_point_desc = dplyr::if_else(gear == "avo", 8.0, 4),
        slope_desc = dplyr::if_else(gear == "avo", 0.1, 1)
      )
    ),
    gear = factor(gear, levels = c("fishery", "bts", "ats", "avo"))
  )

ggplot(sel_assumptions, aes(x = age, y = selectivity, color = factor(year))) +
  geom_line(linewidth = 0.8) +
  facet_wrap(~gear) +
  scale_y_continuous(limits = c(0, 1)) +
  labs(
    x = "Age",
    y = "Assumed selectivity",
    color = "Year",
    title = "Selected Years of Selectivity Assumptions by Gear"
  )
Figure 9: Illustrative selectivity assumptions by gear for selected years. Fishery and BTS are initialized with asymptotic logistic selectivity, ATS uses an estimated double-logistic curve, and AVO uses a fixed double-logistic curve. In the current implementation these assumptions are time-invariant, so the curves are identical across years.

Time-varying selectivity run

The TVselex run was attempted as a time-varying fishery selectivity alternative to run0. This model increases flexibility by estimating the fishery double-logistic ascending inflection point by year, while retaining time-invariant values for the other fishery selectivity parameters. In the current attempt, this expanded the model to 213 fixed effects, including 61 time-varying fishery selectivity rows.

The current TVselex formulation completed with a maximum gradient of 0.00324, total NLL of 4976.88, and terminal spawning biomass of 5563.89.

Show code
run_status_comparison |>
  gt_report() |>
  tab_header(title = "Run Status Comparison")
Table 3: Status summary comparing run0 and the attempted TVselex model run.
Run Status Comparison
metric run0 TVselex
Run status fit completed fit completed
FIMS version 0.9.2 0.9.2
Fixed effects 151 213
Random effects 0 0
Time-varying fishery selectivity rows 0 61
Max gradient 0.00251 0.00324
Total NLL 5521.16 4976.88
Terminal SB 6426.76 5563.89
Interpretation run0 completed and appears well converged by gradient threshold. TVselex completed and appears well converged by gradient threshold.
Show code
if (!is.null(tvselex) && !is.null(tvselex$gradient_diagnostics)) {
  tvselex$gradient_diagnostics |>
    slice_max(abs_gradient, n = 15, with_ties = FALSE) |>
    transmute(
      parameter_index,
      module = module_name,
      fleet = dplyr::coalesce(fleet_name, ""),
      label,
      year = time,
      estimate = estimated,
      gradient,
      abs_gradient
    ) |>
    gt_report() |>
    fmt_number(columns = any_of(c("estimate", "gradient", "abs_gradient")), decimals = 3) |>
    tab_header(title = "Largest TVselex Gradients")
} else {
  tibble::tibble(note = "Gradient diagnostics were not saved with the current TVselex output.") |>
    gt_report() |>
    tab_header(title = "Largest TVselex Gradients")
}
Table 4: Largest absolute gradients from the TVselex TMB objective, matched back to the fixed-effect parameter table.
Largest TVselex Gradients
parameter_index module fleet label year estimate gradient abs_gradient
11 Selectivity cpue slope NA −0.079 0.003 0.003
8 Selectivity bts slope NA 0.732 −0.001 0.001
9 Fleet bts log_q NA 0.523 0.001 0.001
175 Selectivity fishery inflection_point_asc 1986 0.563 −0.001 0.001
188 Selectivity fishery inflection_point_asc 1999 0.929 −0.001 0.001
139 Population log_init_naa NA 0.947 0.001 0.001
5 Fleet ats log_q NA −1.168 −0.001 0.001
201 Selectivity fishery inflection_point_asc 2012 7.431 0.001 0.001
141 Population log_init_naa NA 0.709 0.000 0.000
200 Selectivity fishery inflection_point_asc 2011 8.114 0.000 0.000
176 Selectivity fishery inflection_point_asc 1987 0.115 0.000 0.000
140 Population log_init_naa NA 0.283 0.000 0.000
81 Recruitment log_devs 1968 −0.965 0.000 0.000
142 Population log_init_naa NA 0.552 0.000 0.000
7 Selectivity bts inflection_point NA 6.139 0.000 0.000
Show code
if (!is.null(tvselex) && identical(tvselex$status, "fit completed")) {
  tv_sel_est <- tvselex$estimates |>
    filter(module_name == "Selectivity", module_type == "DoubleLogistic")

  tv_inflection_asc <- tv_sel_est |>
    filter(label == "inflection_point_asc", parameter_id >= 545) |>
    arrange(parameter_id) |>
    transmute(year = years[seq_len(n())], inflection_point_asc = estimated)

  tv_fishery_static <- tv_sel_est |>
    filter(parameter_id %in% c(546, 547, 548)) |>
    select(label, estimated) |>
    distinct() |>
    tibble::deframe()

  tvselex_selectivity <- tv_inflection_asc |>
    tidyr::crossing(age = ages) |>
    mutate(
      selectivity = double_logistic(
        age = age,
        inflection_point_asc = inflection_point_asc,
        slope_asc = tv_fishery_static[["slope_asc"]],
        inflection_point_desc = tv_fishery_static[["inflection_point_desc"]],
        slope_desc = tv_fishery_static[["slope_desc"]]
      )
    )

  ggplot(tvselex_selectivity, aes(x = year, y = age, fill = selectivity)) +
    geom_tile() +
    scale_fill_viridis_c(limits = c(0, 1), name = "Selectivity") +
    scale_y_continuous(breaks = ages) +
    labs(
      x = "Year",
      y = "Age",
      title = "TVselex Fishery Selectivity Over Time"
    )
} else {
  plot.new()
  text(
    0.5, 0.5,
    "TVselex fit output not available.\nRun scripts/05_fit_tvselex.R to populate this figure.",
    cex = 1
  )
}
Figure 10: Fishery selectivity-at-age over time from the TVselex run. The ascending inflection point varies annually; the remaining fishery double-logistic parameters are time-invariant.

Diagnostics

This section summarizes basic model-status diagnostics for the fitted models and provides a place to evaluate a 5-peel retrospective case when retrospective output is available.

Show code
model_status_tbl <- tibble::tibble(
  metric = c(
    "FIMS version",
    "Max gradient",
    "Fixed effects",
    "Random effects",
    "Retrospective output",
    "Interpretation"
  ),
  value = c(
    as.character(fit$version),
    sprintf("%.4f", fit$max_gradient),
    as.character(fit$number_of_parameters[["fixed_effects"]]),
    as.character(fit$number_of_parameters[["random_effects"]]),
    paste(
      "run0",
      ifelse(file.exists(file.path(project_root, "outputs", "retro_5_peel_summary.rds")), "available", "not yet run"),
      "; TVselex",
      ifelse(file.exists(file.path(project_root, "outputs", "tvselex_retro_5_peel_summary.rds")), "available", "not yet run")
    ),
    ifelse(
      fit$max_gradient <= 0.01,
      "Mode appears well converged by gradient threshold.",
      "Mode should be treated as provisional; gradient is larger than a typical convergence target."
    )
  )
)

model_status_tbl |>
  gt_report() |>
  tab_header(title = "Model Status Diagnostics")
Table 5: Summary diagnostics for the current fitted model.
Model Status Diagnostics
metric value
FIMS version 0.9.2
Max gradient 0.0025
Fixed effects 151
Random effects 0
Retrospective output run0 available ; TVselex available
Interpretation Mode appears well converged by gradient threshold.
Show code
make_diag_series <- function(estimates, model_name) {
  recruitment_values <- estimates |>
    filter(label == "expected_recruitment") |>
    mutate(year = map_year(year_i)) |>
    transmute(model = model_name, source = "FIMS", year, value = estimated, quantity = "Recruitment")

  ssb_values <- estimates |>
    filter(label == "spawning_biomass") |>
    mutate(year = map_year(year_i)) |>
    transmute(model = model_name, source = "FIMS", year, value = estimated / 2, quantity = "SSB")

  bind_rows(ssb_values, recruitment_values)
}

diag_series <- make_diag_series(est, "run0")

if (!is.null(tvselex) && identical(tvselex$status, "fit completed")) {
  diag_series <- bind_rows(
    diag_series,
    make_diag_series(tvselex$estimates, "TVselex")
  )
}

derived_dir <- file.path(dirname(project_root), "results", "derived")

read_derived_diag_series <- function(file_name, model_name) {
  path <- file.path(derived_dir, file_name)
  if (!file.exists(path)) return(NULL)

  derived <- readRDS(path)
  if (is.null(derived$timeseries)) return(NULL)

  derived$timeseries |>
    filter(quantity %in% c("SSB", "Recruit", "Recruit_age1")) |>
    transmute(
      model = model_name,
      source = "ADMB 2024",
      year = as.integer(year),
      value = value,
      quantity = dplyr::case_when(
        quantity %in% c("Recruit", "Recruit_age1") ~ "Recruitment",
        TRUE ~ quantity
      )
    )
}

derived_diag_series <- bind_rows(
  read_derived_diag_series("admb_2024.rds", "ADMB 2024")
)

if (nrow(derived_diag_series) > 0) {
  diag_series <- bind_rows(diag_series, derived_diag_series)
}

model_colors <- c(
  "run0" = "#1b9e77",
  "TVselex" = "#d95f02",
  "ADMB 2024" = "#1f78b4"
)

ggplot(diag_series, aes(x = year, y = value, color = model, group = model)) +
  geom_line(data = filter(diag_series, source == "FIMS"), linewidth = 0.9) +
  geom_line(data = filter(diag_series, source == "ADMB 2024"), linewidth = 0.9, linetype = "22") +
  geom_point(data = filter(diag_series, source == "ADMB 2024"), size = 0.75, alpha = 0.65) +
  facet_wrap(~quantity, scales = "free_y", ncol = 1) +
  scale_color_manual(values = model_colors) +
  scale_y_continuous(limits = c(0, NA)) +
  labs(
    x = "Year",
    y = NULL,
    color = "Model",
    title = "Spawning Biomass and Recruitment by Model"
  )
Figure 11: Fitted spawning biomass and expected recruitment trajectories from run0 and TVselex, with the ADMB 2024 derived output overlaid for comparison. FIMS SSB is divided by 2 before plotting.
Show code
make_stock_recruitment <- function(estimates, model_name) {
  ssb_values <- estimates |>
    filter(label == "spawning_biomass") |>
    transmute(year = map_year(year_i), spawning_biomass = estimated)

  recruitment_values <- estimates |>
    filter(label == "expected_recruitment") |>
    transmute(year = map_year(year_i), recruitment = estimated)

  inner_join(ssb_values, recruitment_values, by = "year") |>
    mutate(model = model_name)
}

stock_recruitment <- make_stock_recruitment(est, "run0")

if (!is.null(tvselex) && identical(tvselex$status, "fit completed")) {
  stock_recruitment <- bind_rows(
    stock_recruitment,
    make_stock_recruitment(tvselex$estimates, "TVselex")
  )
}

ggplot(
  stock_recruitment,
  aes(
    x = spawning_biomass,
    y = recruitment,
    label = year,
    color = model
  )
) +
  geom_text(size = 2.6, alpha = 0.85) +
  scale_x_continuous(limits = c(0, NA)) +
  scale_y_continuous(limits = c(0, NA)) +
  labs(
    x = "Spawning biomass",
    y = "Expected recruitment",
    color = "Model",
    title = "Estimated Stock-Recruitment Relationship"
  )
Figure 12: Estimated stock-recruitment relationship for run0 and TVselex. Text labels show model years, with spawning biomass on the x-axis and expected recruitment on the y-axis.
Show code
plot_retro_model(retro, "run0")
Figure 13: Five-peel retrospective trajectories for spawning biomass and recruitment from the run0 configuration. Failed peels are omitted from the plotted trajectories.
Show code
plot_retro_model(retro, "TVselex")
Figure 14: Five-peel retrospective trajectories for spawning biomass and recruitment from the TVselex configuration. Failed peels are omitted from the plotted trajectories.
Show code
diag_tbl <- est |>
  filter(!is.na(likelihood)) |>
  group_by(module_name, label) |>
  summarize(
    n = n(),
    mean_ll = mean(likelihood, na.rm = TRUE),
    .groups = "drop"
  )

diag_tbl |>
  gt_report() |>
  tab_header(title = "Likelihood Diagnostics by Component")
Table 6: Likelihood diagnostics by model component.
Likelihood Diagnostics by Component
module_name label n mean_ll
Fleet agecomp_expected 2745 -22.844518
Fleet index_expected 244 -3.832566
Fleet landings_expected 61 -5.450711

SparseNUTS Scaffold

The repository includes a first-pass scaffold for Bayesian sampling with SparseNUTS using the existing FIMS/TMB objective. The main entry points are scripts/sparsenuts_framework.R, which builds the FIMS objects and exposes helper functions for extracting the TMB objective, and scripts/03_fit_sparse_nuts.R, which runs a conditional mode fit followed by SparseNUTS sampling.

The intended MCMC workflow is to use the maximum-likelihood fit as an initialization and preconditioning step, then sample the joint posterior for estimable fixed effects with SparseNUTS. In this application, that would allow posterior summaries for quantities such as spawning biomass, recruitment, fishing mortality, selectivity parameters, and survey catchability, while retaining the same core FIMS/TMB model structure used for the deterministic fit.

At present this should be treated as an implementation scaffold rather than a validated Bayesian assessment workflow. The code path for building the model object and passing it to SparseNUTS is in place, but full end-to-end sampling has not yet been verified for this application, and posterior diagnostics such as divergences, effective sample size, split-(), and prior sensitivity have not yet been evaluated. In practice, additional work will likely be needed on parameter blocking, scaling, priors, and possibly on how random effects or transformed parameters are exposed to the sampler.

Once operational, the expected MCMC outputs would include posterior draws saved to outputs/sparsenuts_fit.rds, from which trace plots, marginal posterior summaries, posterior intervals for SSB and recruitment, and posterior predictive checks could be added to this report.

An example command is shown below.

Rscript scripts/03_fit_sparse_nuts.R \
  --num-samples=250 \
  --num-warmup=250 \
  --chains=4 \
  --cores=1 \
  --metric=diag \
  --seed=123

By default this writes outputs/sparsenuts_fit.rds.

References

Ianelli, J. N., Fissel, B., Holsman, K., Honkalehto, T., Kotwicki, S., Monnahan, C., Siddon, E., and Stienessen, S. 2024. Assessment of the Walleye Pollock Stock in the Eastern Bering Sea. North Pacific Fishery Management Council SAFE report. https://files.npfmc.org/SAFE/2024/EBSpollock.pdf.

NOAA-FIMS. 2026. Fisheries Integrated Modeling System (FIMS). Version 0.9.2. GitHub organization and FIMS development repository. Accessed April 24, 2026. https://github.com/NOAA-FIMS.

Monnahan, C. SparseNUTS: Sparse No-U-Turn MCMC Sampling for Template Model Builder. R package version 1.0.2. Accessed April 24, 2026. https://noaa-afsc.github.io/SparseNUTS.

Tables

This section contains result tables. Input-only data tables are provided in the Appendix.

Show code
param_tbl <- est |>
  filter(label %in% c("log_rzero", "logit_steep")) |>
  transmute(run = "run0", module_name, label, estimated) |>
  distinct()

if (!is.null(tvselex) && identical(tvselex$status, "fit completed")) {
  param_tbl <- bind_rows(
    param_tbl,
    tvselex$estimates |>
      filter(label %in% c("log_rzero", "logit_steep")) |>
      transmute(run = "TVselex", module_name, label, estimated) |>
      distinct()
  )
}

param_tbl |>
  tidyr::pivot_wider(names_from = run, values_from = estimated) |>
  gt_report() |>
  fmt_number(columns = any_of(c("run0", "TVselex")), decimals = 3) |>
  tab_header(title = "Key Recruitment Parameters")
Table 7: Estimated recruitment parameters retained in the fitted model summary.
Key Recruitment Parameters
module_name label run0 TVselex
Recruitment logit_steep 0.788 0.788
Recruitment log_rzero 10.112 10.141
Show code
fleet_module_map <- tibble::tibble(
  module_id = 1:5,
  fleet = c("ats", "avo", "bts", "cpue", "fishery")
)

q_tbl <- est |>
  filter(label == "log_q") |>
  mutate(q = exp(estimated)) |>
  transmute(run = "run0", module_id, q)

if (!is.null(tvselex) && identical(tvselex$status, "fit completed")) {
  q_tbl <- bind_rows(
    q_tbl,
    tvselex$estimates |>
      filter(label == "log_q") |>
      mutate(q = exp(estimated)) |>
      transmute(run = "TVselex", module_id, q)
  )
}

q_tbl |>
  left_join(fleet_module_map, by = "module_id") |>
  select(module_id, fleet, run, q) |>
  tidyr::pivot_wider(names_from = run, values_from = q) |>
  arrange(module_id) |>
  gt_report() |>
  fmt_number(columns = any_of(c("run0", "TVselex")), decimals = 3) |>
  tab_header(title = "Survey Catchability (q)")
Table 8: Estimated survey catchability coefficients.
Survey Catchability (q)
module_id fleet run0 TVselex
1 ats 3.336 0.311
2 avo 0.000 0.000
3 bts 1.932 1.688
4 cpue 0.752 21.540
5 fishery 1.000 1.000
Show code
make_naa_table <- function(estimates, run_name) {
  estimates |>
    filter(label == "numbers_at_age") |>
    transmute(
      year = map_year(year_i),
      age = age_i,
      run = run_name,
      value = estimated
    ) |>
    group_by(run) |>
    filter(year %in% range(year, na.rm = TRUE)) |>
    ungroup() |>
    mutate(period = ifelse(year == min(year, na.rm = TRUE), "initial", "terminal")) |>
    select(period, age, run, value)
}

naa_comparison <- make_naa_table(est, "run0")

if (!is.null(tvselex) && identical(tvselex$status, "fit completed")) {
  naa_comparison <- bind_rows(
    naa_comparison,
    make_naa_table(tvselex$estimates, "TVselex")
  )
}

naa_comparison |>
  tidyr::pivot_wider(names_from = run, values_from = value) |>
  arrange(factor(period, levels = c("initial", "terminal")), age) |>
  gt_report() |>
  fmt_number(columns = any_of(c("run0", "TVselex")), decimals = 3) |>
  tab_header(title = "Initial and Terminal Numbers-at-Age")
Table 9: Initial and terminal estimated numbers-at-age by model run.
Initial and Terminal Numbers-at-Age
period age run0 TVselex
initial 1 2,865.365 3,845.201
initial 2 2,340.982 3,340.189
initial 3 1,233.476 1,687.843
initial 4 265.015 315.868
initial 5 88.791 96.900
initial 6 159.303 173.162
initial 7 68.770 75.161
initial 8 12.774 14.036
initial 9 0.000 0.000
initial 10 0.000 0.000
initial 11 0.000 0.000
initial 12 0.000 0.000
initial 13 0.000 0.000
initial 14 0.000 0.000
initial 15 0.000 0.000
terminal 1 62,906.523 58,464.456
terminal 2 9,130.393 9,222.236
terminal 3 3,397.981 3,269.876
terminal 4 3,240.423 2,790.219
terminal 5 3,337.105 2,273.076
terminal 6 7,235.390 5,135.349
terminal 7 509.077 515.782
terminal 8 124.146 180.699
terminal 9 65.869 135.286
terminal 10 109.538 252.119
terminal 11 180.179 307.837
terminal 12 106.004 140.076
terminal 13 13.573 17.752
terminal 14 8.417 13.607
terminal 15 21.386 42.119
Show code
make_catch_table <- function(estimates, run_name) {
  estimates |>
    filter(label == "landings_numbers_at_age") |>
    mutate(year = map_year(year_i)) |>
    filter(year == max(year, na.rm = TRUE)) |>
    transmute(
      age = age_i,
      run = run_name,
      value = dplyr::coalesce(estimated, expected)
    ) |>
    group_by(age, run) |>
    summarize(value = sum(value, na.rm = TRUE), .groups = "drop")
}

catch_comparison <- make_catch_table(est, "run0")

if (!is.null(tvselex) && identical(tvselex$status, "fit completed")) {
  catch_comparison <- bind_rows(
    catch_comparison,
    make_catch_table(tvselex$estimates, "TVselex")
  )
}

catch_comparison |>
  tidyr::pivot_wider(names_from = run, values_from = value) |>
  arrange(age) |>
  gt_report() |>
  fmt_number(columns = any_of(c("run0", "TVselex")), decimals = 3) |>
  tab_header(title = "Terminal-Year Fishery Catch-at-Age")
Table 10: Estimated terminal-year fishery catch-at-age by model run.
Terminal-Year Fishery Catch-at-Age
age run0 TVselex
1 20.624 3,161.337
2 21.618 789.567
3 47.820 311.831
4 187.838 267.317
5 402.915 217.715
6 1,065.858 491.119
7 77.849 49.190
8 19.106 17.150
9 10.148 12.734
10 16.879 23.392
11 27.765 27.869
12 16.335 12.167
13 2.092 1.440
14 1.297 0.990
15 3.296 2.599

Appendix

Model Equations and Assumptions

The fitted runs use a standard catch-at-age model. Key fixed assumptions and structural simplifications are summarized in Table 11.

Show code
assumptions_tbl <- tibble::tibble(
  component = c(
    "Natural mortality",
    "Recruitment",
    "Recruitment variability",
    "Fishery selectivity",
    "BTS selectivity",
    "ATS selectivity",
    "AVO selectivity",
    "CPUE selectivity",
    "Growth",
    "Maturity",
    "Length composition",
    "Seasonal timing",
    "Landings uncertainty"
  ),
  current_fims_setting = c(
    "Age- and year-indexed `log_M` rows are created; values are fixed over years at `M1 = 0.9`, `M2 = 0.45`, and `M3+ = 0.3`.",
    "Beverton-Holt stock-recruitment with estimated `log_rzero`; steepness fixed at `h = 0.6875`.",
    "Annual recruitment deviations are fixed effects for 1965-2024; recruitment `log_sd` fixed at 0.1.",
    "`run0` uses time-invariant logistic selectivity; `TVselex` uses double-logistic selectivity with annual ascending inflection points.",
    "Time-invariant logistic selectivity.",
    "Time-invariant double-logistic selectivity.",
    "Fixed double-logistic selectivity: ascending inflection 1.5, ascending slope 2.0, descending inflection 8.0, descending slope 0.1.",
    "Shares fishery selectivity.",
    "Empirical weight-at-age from ATS WAA, filled by age-specific means where needed.",
    "Fixed maturity-at-age schedule.",
    "Omitted.",
    "Omitted; annual time step only.",
    "Fixed log-SD of 0.05."
  ),
  contrast_with_2024_assessment = c(
    "Matches the production-assessment feature of age-specific natural mortality more closely than the previous scalar-M implementation.",
    "Same general stock-recruitment family, but this implementation keeps the variance treatment simpler.",
    "Simpler than the production assessment treatment of recruitment variability and uncertainty.",
    "Production model uses non-parametric fishery selectivity coefficients with regularity penalties.",
    "Production model allows time variation in the BTS selectivity inflection point for ages 2 and older.",
    "Closer to the production model structure than the other survey selectivity simplifications.",
    "Production model links AVO selectivity to ATS; this implementation fixes AVO independently.",
    "Simplified sharing assumption retained in this implementation.",
    "Uses empirical stock-level inputs rather than a full growth process.",
    "Uses a fixed schedule rather than estimating maturity.",
    "Production assessment includes length information; this implementation does not.",
    "Production assessment includes more detailed timing structure.",
    "Simplified placeholder uncertainty."
  )
)

assumptions_tbl |>
  gt_report() |>
  fmt_markdown(columns = everything()) |>
  tab_header(title = "Key FIMS Model Assumptions and Fixed Parameters")
Table 11: Key model assumptions and fixed parameters in the current FIMS implementation.
Key FIMS Model Assumptions and Fixed Parameters
component current_fims_setting contrast_with_2024_assessment
Natural mortality Age- and year-indexed log_M rows are created; values are fixed over years at M1 = 0.9, M2 = 0.45, and M3+ = 0.3. Matches the production-assessment feature of age-specific natural mortality more closely than the previous scalar-M implementation.
Recruitment Beverton-Holt stock-recruitment with estimated log_rzero; steepness fixed at h = 0.6875. Same general stock-recruitment family, but this implementation keeps the variance treatment simpler.
Recruitment variability Annual recruitment deviations are fixed effects for 1965-2024; recruitment log_sd fixed at 0.1. Simpler than the production assessment treatment of recruitment variability and uncertainty.
Fishery selectivity run0 uses time-invariant logistic selectivity; TVselex uses double-logistic selectivity with annual ascending inflection points. Production model uses non-parametric fishery selectivity coefficients with regularity penalties.
BTS selectivity Time-invariant logistic selectivity. Production model allows time variation in the BTS selectivity inflection point for ages 2 and older.
ATS selectivity Time-invariant double-logistic selectivity. Closer to the production model structure than the other survey selectivity simplifications.
AVO selectivity Fixed double-logistic selectivity: ascending inflection 1.5, ascending slope 2.0, descending inflection 8.0, descending slope 0.1. Production model links AVO selectivity to ATS; this implementation fixes AVO independently.
CPUE selectivity Shares fishery selectivity. Simplified sharing assumption retained in this implementation.
Growth Empirical weight-at-age from ATS WAA, filled by age-specific means where needed. Uses empirical stock-level inputs rather than a full growth process.
Maturity Fixed maturity-at-age schedule. Uses a fixed schedule rather than estimating maturity.
Length composition Omitted. Production assessment includes length information; this implementation does not.
Seasonal timing Omitted; annual time step only. Production assessment includes more detailed timing structure.
Landings uncertainty Fixed log-SD of 0.05. Simplified placeholder uncertainty.

Recruitment (Beverton-Holt):

\[ R_t = \frac{0.8 R_0 h S_{t-1}}{0.2 R_0 \phi_0 (1-h) + S_{t-1}(h-0.2)} \exp(\epsilon_t) \]

with deviations (_t) estimated as fixed effects.

Numbers-at-age dynamics:

\[ N_{a,t} = N_{a-1,t-1} \exp(-Z_{a-1,t-1}) \]

with a plus-group at the maximum age.

Initial numbers at age:

\[ N_{a,1} = R_0 \exp\left(-\sum_{j=1}^{a-1} M_j\right), \quad N_{A,1} = \frac{R_0 \exp\left(-\sum_{j=1}^{A-1} M_j\right)}{1 - \exp(-M_A)} \]

Total mortality:

\[ Z_{a,t} = M_a + \sum_f F_{f,t} s_{f,a} \]

where (M_a) is fixed at 0.9 for age 1, 0.45 for age 2, and 0.3 for ages 3 and older, and (s_{f,a}) is fleet selectivity at age.

Spawning biomass:

\[ SSB_t = \sum_a N_{a,t} w_{a} m_{a} \]

Likelihood components:

Lognormal (landings and indices):

\[ \ell_{\text{lnorm}} = \sum_t \left[-\frac{\left(\log y_t - \log \hat{y}_t\right)^2}{2\sigma_t^2} - \log y_t - \log(\sigma_t\sqrt{2\pi})\right] \]

Multinomial (age compositions):

\[ \ell_{\text{mult}} = \sum_{t} \log\left(\frac{N_t!}{\prod_a n_{a,t}!}\right) + \sum_t \sum_a n_{a,t} \log p_{a,t} \]

where (y_t) and (t) are observed and expected landings or index, (t) is the lognormal SD, (n{a,t}) are age-composition counts (or effective sample sizes), and (p{a,t}) are predicted age proportions.

Data Tables

Supporting input data tables are provided in Table 12, Table 13, and Table 14. Result tables are reported in the main Tables section.

Show code
waa_tbl <- data_ebs |>
  filter(type == "weight_at_age") |>
  filter(timing == max(timing, na.rm = TRUE)) |>
  select(age, value) |>
  arrange(age)

waa_tbl |>
  gt_report() |>
  tab_header(title = "Weight-at-Age Input (Most Recent Year)")
Table 12: Input weight-at-age values used in the most recent model year.
Weight-at-Age Input (Most Recent Year)
age value
1 0.08488167
2 0.19586813
3 0.32509454
4 0.45798763
5 0.59419260
6 0.73103666
7 0.86602468
8 0.99890339
9 1.12812595
10 1.24523634
11 1.36042703
12 1.46363679
13 1.55309726
14 1.62864399
15 1.71116883
Show code
mat_tbl <- tibble::tibble(
  age = ages,
  value = c(
    0, 0.008, 0.289, 0.641, 0.842,
    0.901, 0.947, 0.963, 0.97, 1,
    1, 1, 1, 1, 1
  )
) |>
  arrange(age)

mat_tbl |>
  gt_report() |>
  fmt_number(columns = value, decimals = 3) |>
  tab_header(title = "Maturity-at-Age Input")
Table 13: Input maturity-at-age schedule used in the model.
Maturity-at-Age Input
age value
1 0.000
2 0.008
3 0.289
4 0.641
5 0.842
6 0.901
7 0.947
8 0.963
9 0.970
10 1.000
11 1.000
12 1.000
13 1.000
14 1.000
15 1.000
Show code
survey_agecomp <- data_ebs |>
  filter(type == "age_comp", name %in% c("bts", "ats")) |>
  filter(value != -999) |>
  group_by(name) |>
  filter(timing == max(timing, na.rm = TRUE)) |>
  ungroup() |>
  transmute(fleet = name, year = timing, age, proportion = value) |>
  group_by(fleet, year) |>
  mutate(proportion = proportion / sum(proportion, na.rm = TRUE)) |>
  ungroup() |>
  select(-year) |>
  arrange(fleet, age)

survey_agecomp |>
  gt_report(groupname_col = "fleet") |>
  tab_header(title = "Survey Age-Composition Inputs (Most Recent Year)")
Table 14: Observed survey age-composition input proportions in the most recent year with data.
Survey Age-Composition Inputs (Most Recent Year)
age proportion
ats
1 4.467808e-01
2 1.009245e-01
3 5.991764e-02
4 3.339721e-02
5 7.220255e-02
6 2.187775e-01
7 3.961346e-02
8 9.666944e-03
9 7.576829e-03
10 6.137727e-03
11 3.324444e-03
12 1.222797e-03
13 2.990288e-04
14 9.644549e-05
15 6.216900e-05
bts
1 9.863753e-02
2 6.647721e-02
3 5.911436e-02
4 4.017357e-02
5 1.260374e-01
6 4.127247e-01
7 9.149940e-02
8 2.851859e-02
9 2.688500e-02
10 2.419694e-02
11 1.653010e-02
12 6.222237e-03
13 1.969535e-03
14 7.510520e-04
15 2.624653e-04

Parameter Estimates

The full run0 parameter listing is shown in Table 15. A standard-deviation column is included for parameter standard errors, but the current saved FIMS summary does not populate finite standard errors; blank entries indicate that uncertainty output was not available for that parameter in the current saved fit.

Show code
parameter_appendix <- est |>
  filter(estimation_type %in% c("fixed_effects", "random_effects")) |>
  distinct(
    module_name,
    module_type,
    fleet,
    label,
    year_i,
    age_i,
    parameter_id,
    estimated,
    uncertainty,
    estimation_type
  ) |>
  mutate(
    fleet = dplyr::coalesce(fleet, ""),
    year = dplyr::if_else(!is.na(year_i), map_year(year_i), NA_real_),
    age = age_i,
    sd = dplyr::if_else(is.finite(uncertainty), uncertainty, NA_real_)
  ) |>
  select(
    parameter_id,
    module_name,
    module_type,
    fleet,
    label,
    year,
    age,
    estimation_type,
    estimate = estimated,
    sd
  ) |>
  arrange(module_name, fleet, label, year, age, parameter_id)

parameter_appendix |>
  gt_report() |>
  fmt_number(columns = any_of(c("estimate", "sd")), decimals = 3) |>
  tab_header(title = "Appendix: run0 Parameter Estimates")
Table 15: Appendix table of fitted run0 parameter estimates and standard deviations, where available.
Appendix: run0 Parameter Estimates
parameter_id module_name module_type fleet label year age estimation_type estimate
548 Fleet NA NA log_Fmort 1964 NA fixed_effects −0.089
556 Fleet NA NA log_Fmort 1965 NA fixed_effects 0.032
557 Fleet NA NA log_Fmort 1966 NA fixed_effects −0.206
558 Fleet NA NA log_Fmort 1967 NA fixed_effects 0.385
559 Fleet NA NA log_Fmort 1968 NA fixed_effects 0.179
560 Fleet NA NA log_Fmort 1969 NA fixed_effects −0.052
561 Fleet NA NA log_Fmort 1970 NA fixed_effects 0.094
562 Fleet NA NA log_Fmort 1971 NA fixed_effects 0.195
563 Fleet NA NA log_Fmort 1972 NA fixed_effects 0.217
564 Fleet NA NA log_Fmort 1973 NA fixed_effects 0.191
565 Fleet NA NA log_Fmort 1974 NA fixed_effects 0.326
566 Fleet NA NA log_Fmort 1975 NA fixed_effects 0.329
567 Fleet NA NA log_Fmort 1976 NA fixed_effects 0.322
568 Fleet NA NA log_Fmort 1977 NA fixed_effects −0.147
569 Fleet NA NA log_Fmort 1978 NA fixed_effects −0.090
570 Fleet NA NA log_Fmort 1979 NA fixed_effects −0.105
571 Fleet NA NA log_Fmort 1980 NA fixed_effects −0.351
572 Fleet NA NA log_Fmort 1981 NA fixed_effects −0.755
573 Fleet NA NA log_Fmort 1982 NA fixed_effects −1.270
574 Fleet NA NA log_Fmort 1983 NA fixed_effects −1.673
575 Fleet NA NA log_Fmort 1984 NA fixed_effects −1.684
576 Fleet NA NA log_Fmort 1985 NA fixed_effects −1.754
577 Fleet NA NA log_Fmort 1986 NA fixed_effects −1.758
578 Fleet NA NA log_Fmort 1987 NA fixed_effects −2.104
579 Fleet NA NA log_Fmort 1988 NA fixed_effects −1.705
580 Fleet NA NA log_Fmort 1989 NA fixed_effects −1.611
581 Fleet NA NA log_Fmort 1990 NA fixed_effects −1.271
582 Fleet NA NA log_Fmort 1991 NA fixed_effects −1.254
583 Fleet NA NA log_Fmort 1992 NA fixed_effects −0.818
584 Fleet NA NA log_Fmort 1993 NA fixed_effects −1.004
585 Fleet NA NA log_Fmort 1994 NA fixed_effects −1.242
586 Fleet NA NA log_Fmort 1995 NA fixed_effects −1.325
587 Fleet NA NA log_Fmort 1996 NA fixed_effects −1.419
588 Fleet NA NA log_Fmort 1997 NA fixed_effects −1.566
589 Fleet NA NA log_Fmort 1998 NA fixed_effects −1.497
590 Fleet NA NA log_Fmort 1999 NA fixed_effects −1.575
591 Fleet NA NA log_Fmort 2000 NA fixed_effects −1.441
592 Fleet NA NA log_Fmort 2001 NA fixed_effects −1.343
593 Fleet NA NA log_Fmort 2002 NA fixed_effects −1.183
594 Fleet NA NA log_Fmort 2003 NA fixed_effects −1.019
595 Fleet NA NA log_Fmort 2004 NA fixed_effects −1.139
596 Fleet NA NA log_Fmort 2005 NA fixed_effects −1.044
597 Fleet NA NA log_Fmort 2006 NA fixed_effects −1.086
598 Fleet NA NA log_Fmort 2007 NA fixed_effects −1.060
599 Fleet NA NA log_Fmort 2008 NA fixed_effects −1.113
600 Fleet NA NA log_Fmort 2009 NA fixed_effects −1.307
601 Fleet NA NA log_Fmort 2010 NA fixed_effects −1.360
602 Fleet NA NA log_Fmort 2011 NA fixed_effects −1.118
603 Fleet NA NA log_Fmort 2012 NA fixed_effects −1.293
604 Fleet NA NA log_Fmort 2013 NA fixed_effects −1.464
605 Fleet NA NA log_Fmort 2014 NA fixed_effects −1.392
606 Fleet NA NA log_Fmort 2015 NA fixed_effects −1.123
607 Fleet NA NA log_Fmort 2016 NA fixed_effects −1.093
608 Fleet NA NA log_Fmort 2017 NA fixed_effects −1.226
609 Fleet NA NA log_Fmort 2018 NA fixed_effects −1.358
610 Fleet NA NA log_Fmort 2019 NA fixed_effects −1.280
611 Fleet NA NA log_Fmort 2020 NA fixed_effects −0.948
612 Fleet NA NA log_Fmort 2021 NA fixed_effects −0.780
613 Fleet NA NA log_Fmort 2022 NA fixed_effects −1.347
614 Fleet NA NA log_Fmort 2023 NA fixed_effects −1.460
615 Fleet NA NA log_Fmort 2024 NA fixed_effects −1.633
5 Fleet NA NA log_q NA NA fixed_effects 1.205
143 Fleet NA NA log_q NA NA fixed_effects −8.008
277 Fleet NA NA log_q NA NA fixed_effects 0.659
413 Fleet NA NA log_q NA NA fixed_effects −0.285
872 Population NA log_init_naa NA 1 fixed_effects 7.960
1787 Population NA log_init_naa NA 2 fixed_effects 7.758
1788 Population NA log_init_naa NA 3 fixed_effects 7.118
1789 Population NA log_init_naa NA 4 fixed_effects 5.580
1790 Population NA log_init_naa NA 5 fixed_effects 4.486
1791 Population NA log_init_naa NA 6 fixed_effects 5.071
1792 Population NA log_init_naa NA 7 fixed_effects 4.231
1793 Population NA log_init_naa NA 8 fixed_effects 2.547
1794 Population NA log_init_naa NA 9 fixed_effects −488.554
1795 Population NA log_init_naa NA 10 fixed_effects −460.841
1796 Population NA log_init_naa NA 11 fixed_effects −429.396
1797 Population NA log_init_naa NA 12 fixed_effects −410.922
1798 Population NA log_init_naa NA 13 fixed_effects −403.337
1799 Population NA log_init_naa NA 14 fixed_effects −399.577
1800 Population NA log_init_naa NA 15 fixed_effects −388.533
683 Recruitment Beverton-Holt log_devs 1965 NA fixed_effects 1.039
686 Recruitment Beverton-Holt log_devs 1966 NA fixed_effects 0.351
687 Recruitment Beverton-Holt log_devs 1967 NA fixed_effects 0.779
688 Recruitment Beverton-Holt log_devs 1968 NA fixed_effects 0.625
689 Recruitment Beverton-Holt log_devs 1969 NA fixed_effects 0.597
690 Recruitment Beverton-Holt log_devs 1970 NA fixed_effects 0.259
691 Recruitment Beverton-Holt log_devs 1971 NA fixed_effects −0.274
692 Recruitment Beverton-Holt log_devs 1972 NA fixed_effects −0.567
693 Recruitment Beverton-Holt log_devs 1973 NA fixed_effects 0.046
694 Recruitment Beverton-Holt log_devs 1974 NA fixed_effects −0.379
695 Recruitment Beverton-Holt log_devs 1975 NA fixed_effects −0.360
696 Recruitment Beverton-Holt log_devs 1976 NA fixed_effects −0.313
697 Recruitment Beverton-Holt log_devs 1977 NA fixed_effects −0.103
698 Recruitment Beverton-Holt log_devs 1978 NA fixed_effects 0.443
699 Recruitment Beverton-Holt log_devs 1979 NA fixed_effects 1.327
700 Recruitment Beverton-Holt log_devs 1980 NA fixed_effects 0.509
701 Recruitment Beverton-Holt log_devs 1981 NA fixed_effects 0.583
702 Recruitment Beverton-Holt log_devs 1982 NA fixed_effects −0.147
703 Recruitment Beverton-Holt log_devs 1983 NA fixed_effects 0.903
704 Recruitment Beverton-Holt log_devs 1984 NA fixed_effects −0.357
705 Recruitment Beverton-Holt log_devs 1985 NA fixed_effects 0.547
706 Recruitment Beverton-Holt log_devs 1986 NA fixed_effects −0.388
707 Recruitment Beverton-Holt log_devs 1987 NA fixed_effects −1.063
708 Recruitment Beverton-Holt log_devs 1988 NA fixed_effects −1.323
709 Recruitment Beverton-Holt log_devs 1989 NA fixed_effects −0.618
710 Recruitment Beverton-Holt log_devs 1990 NA fixed_effects 0.933
711 Recruitment Beverton-Holt log_devs 1991 NA fixed_effects 0.240
712 Recruitment Beverton-Holt log_devs 1992 NA fixed_effects −0.001
713 Recruitment Beverton-Holt log_devs 1993 NA fixed_effects 0.690
714 Recruitment Beverton-Holt log_devs 1994 NA fixed_effects −0.406
715 Recruitment Beverton-Holt log_devs 1995 NA fixed_effects −0.712
716 Recruitment Beverton-Holt log_devs 1996 NA fixed_effects 0.177
717 Recruitment Beverton-Holt log_devs 1997 NA fixed_effects 0.593
718 Recruitment Beverton-Holt log_devs 1998 NA fixed_effects −0.190
719 Recruitment Beverton-Holt log_devs 1999 NA fixed_effects −0.138
720 Recruitment Beverton-Holt log_devs 2000 NA fixed_effects 0.236
721 Recruitment Beverton-Holt log_devs 2001 NA fixed_effects 0.583
722 Recruitment Beverton-Holt log_devs 2002 NA fixed_effects 0.143
723 Recruitment Beverton-Holt log_devs 2003 NA fixed_effects −0.282
724 Recruitment Beverton-Holt log_devs 2004 NA fixed_effects −1.044
725 Recruitment Beverton-Holt log_devs 2005 NA fixed_effects −1.383
726 Recruitment Beverton-Holt log_devs 2006 NA fixed_effects −0.358
727 Recruitment Beverton-Holt log_devs 2007 NA fixed_effects 0.506
728 Recruitment Beverton-Holt log_devs 2008 NA fixed_effects −0.404
729 Recruitment Beverton-Holt log_devs 2009 NA fixed_effects 1.029
730 Recruitment Beverton-Holt log_devs 2010 NA fixed_effects 0.184
731 Recruitment Beverton-Holt log_devs 2011 NA fixed_effects −0.305
732 Recruitment Beverton-Holt log_devs 2012 NA fixed_effects −0.438
733 Recruitment Beverton-Holt log_devs 2013 NA fixed_effects 0.994
734 Recruitment Beverton-Holt log_devs 2014 NA fixed_effects 0.898
735 Recruitment Beverton-Holt log_devs 2015 NA fixed_effects −0.205
736 Recruitment Beverton-Holt log_devs 2016 NA fixed_effects −1.319
737 Recruitment Beverton-Holt log_devs 2017 NA fixed_effects −1.326
738 Recruitment Beverton-Holt log_devs 2018 NA fixed_effects −0.582
739 Recruitment Beverton-Holt log_devs 2019 NA fixed_effects 1.500
740 Recruitment Beverton-Holt log_devs 2020 NA fixed_effects 0.197
741 Recruitment Beverton-Holt log_devs 2021 NA fixed_effects −0.213
742 Recruitment Beverton-Holt log_devs 2022 NA fixed_effects −0.489
743 Recruitment Beverton-Holt log_devs 2023 NA fixed_effects 0.023
744 Recruitment Beverton-Holt log_devs 2024 NA fixed_effects 1.065
682 Recruitment Beverton-Holt log_rzero NA NA fixed_effects 10.112
275 Selectivity Logistic inflection_point NA NA fixed_effects 6.455
411 Selectivity Logistic inflection_point NA NA fixed_effects −107.131
545 Selectivity Logistic inflection_point NA NA fixed_effects 4.334
1 Selectivity DoubleLogistic inflection_point_asc NA NA fixed_effects 1.581
3 Selectivity DoubleLogistic inflection_point_desc NA NA fixed_effects 50.062
276 Selectivity Logistic slope NA NA fixed_effects 0.707
412 Selectivity Logistic slope NA NA fixed_effects 261.817
546 Selectivity Logistic slope NA NA fixed_effects 1.791
2 Selectivity DoubleLogistic slope_asc NA NA fixed_effects 1.974
4 Selectivity DoubleLogistic slope_desc NA NA fixed_effects −0.051

Notes

  • AVO selectivity is fixed independently with ascending inflection point 1.5, ascending slope 2.0, descending inflection point 8.0, and descending slope 0.1.
  • ATS weight-at-age is used as the growth input.
  • Composition likelihoods use a multinomial distribution with sample sizes taken from the 2024 EBS pollock assessment inputs.