U.S. West Coast Case Study of Pacific Hake

The setup

Code
# Names of required packages
packages <- c(
  "dplyr",
  "ggridges",
  "ggplot2",
  "gt",
  "here",
  "lubridate",
  "remotes",
  "reshape2",
  "shinystan",
  "tidybayes",
  "tidyr",
  "TMB"
)

# Install packages not yet installed
installed_packages <- packages %in% rownames(installed.packages())
if (any(installed_packages == FALSE)) {
  install.packages(packages[!installed_packages], repos = "http://cran.us.r-project.org")
}

# the Bayesian case study requires these extra packages
if (!"StanEstimators" %in% rownames(installed.packages())) {
  install.packages("StanEstimators", repos = c("https://andrjohns.r-universe.dev", "https://cloud.r-project.org"))
}

if (!"adnuts" %in% rownames(installed.packages())) {
  remotes::install_github("Cole-Monnahan-NOAA/adnuts")
}

# SS3 case studies require r4ss
if (!"r4ss" %in% rownames(installed.packages())) {
  remotes::install_github("r4ss/r4ss")
}

# SS3 case studies require r4ss
if (!"stockplotr" %in% rownames(installed.packages())) {
  remotes::install_github("nmfs-ost/stockplotr")
}

# Install FIMS: main branch version if on main, dev version on any other branch
remotes::install_github("NOAA-FIMS/FIMS", ref = "dev-hake")

# Load packages
invisible(lapply(packages, library, character.only = TRUE))

library(FIMS)
library(adnuts)
R_version <- version$version.string
TMB_version <- packageDescription("TMB")$Version
FIMS_commit <- substr(packageDescription("FIMS")$GithubSHA1, 1, 7)
ggplot2::theme_set(ggplot2::theme_bw())

data_directory <- "data_files"
function_directory <- "R"
purrr::walk(
    dir(function_directory, full.names = TRUE),
  source
)
Code
common_name <- "Pacific Hake"

terminal_year <- 2024

hake_maturity_inflection_point <- 2.2
hake_maturity_slope <- 1.8
sample_size_multiplier <- 10
log_rzero_prior_mean <- 15
log_rzero_prior_sd <- 1.155
init_naa_prior_mean <- 18
init_naa_prior_sd <- 1.155
natural_mortality <- 0.235
steepness <- 0.8118
# Taken from CEATTLE estimates
natural_mortality_by_age <- dplyr::tibble(
  age = 1:5,
  cannibalism = c(
    0.619078417, 0.038591177, 0.012472784, 0.010002492, 0.018692867
  )
)

# The model can be found within the following folder on the hake server 
# base_dir <- "/srv/hake/models/2025/01-version/01-base-models/01-base"
base_dir <- "https://raw.githubusercontent.com/pacific-hake/hake-assessment/refs/heads/model-files/models/2025"
maturity_data <- utils::read.csv("https://raw.githubusercontent.com/pacific-hake/hake-assessment/refs/heads/master/data-tables/maturity-ogives.csv")
  • R version: R version 4.5.2 (2025-10-31)
  • TMB version: 1.9.19
  • FIMS commit: 14916c8
  • Stock name: Pacific Hake
  • Region: U.S. West Coast
  • Analyst: Kelli F. Johnson

Simplifications to the original assessment

The model presented in this case study was changed substantially from the operational version and should not be considered reflective of the Pacific Hake stock. These results are intended to be a demonstration and nothing more.

The operational model was not altered in an attempt to more closely match a FIMS model.

Data

The data for the FIMS model was created using get_ss3_data(), written by Drs. Ian G. Taylor and Megumi C. Oshima. The function was provided the 2025 Stock Synthesis model files using r4ss::SS_read(). All data for a FIMS model is contained in one data frame, using a long format, i.e., one row per observation instead of a matrix format. Here, we call this object data_4_model.

Code
inputs <- r4ss::SS_read(base_dir)
inputs[["wtatage"]] <- r4ss::SS_readwtatage(file.path(base_dir, "wtatage.ss_new"))

data_4_model <- get_ss3_data(
  ss3_inputs = inputs,
  fleets = seq(inputs[["dat"]][["Nfleets"]]),
  ages = c(0, inputs[["dat"]][["agebin_vector"]])
) |>
  dplyr::filter(
    timing <= inputs[["dat"]][["endyr"]],
    value != -999,
    # TODO: allow fleet3 but specify units as numbers
    name != "fleet3"
  ) |>
  dplyr::mutate(
    name = dplyr::case_when(
      name == "fleet1" ~ "fishery",
      name == "fleet2" ~ "survey"
    ),
    uncertainty = ifelse(
      test = type == "age" & name == "survey",
      uncertainty * 10,
      uncertainty
    )
  ) |>
  dplyr::filter(timing <= terminal_year, timing > 1976) |>
  FIMS::FIMSFrame()
Code
# Default print method of a FIMSFrame object
data_4_model
tbl_df of class 'FIMSFrame'
with the following 'types': age_comp, landings, weight-at-age, index
# A tibble: 6 × 8
  type     name      age length timing  value unit  uncertainty
  <chr>    <chr>   <dbl>  <dbl>  <int>  <dbl> <chr>       <dbl>
1 age_comp fishery     0     NA   1977  0.001 ""            320
2 age_comp fishery     1     NA   1977  0.001 ""            320
3 age_comp fishery     2     NA   1977  8.45  ""            320
4 age_comp fishery     3     NA   1977  3.68  ""            320
5 age_comp fishery     4     NA   1977 27.5   ""            320
6 age_comp fishery     5     NA   1977  3.60  ""            320
additional slots include the following:fleets:
[1] "fishery" "survey" 
n_years:
[1] 48
ages:
 [1]  0  1  2  3  4  5  6  7  8  9 10 11 12 13 14 15
n_ages:
[1] 16
lengths:
numeric(0)
n_lengths:
[1] 0
start_year:
[1] 1977
end_year:
[1] 2024
Code
# Summary of the data types available
FIMS::get_data(data_4_model) |>
  dplyr::filter(value != -999) |>
  dplyr::group_by(type, name) |>
  dplyr::count()
# A tibble: 5 × 3
# Groups:   type, name [5]
  type          name        n
  <chr>         <chr>   <int>
1 age_comp      fishery   768
2 age_comp      survey    240
3 index         survey     15
4 landings      fishery    48
5 weight-at-age fishery   768

Weight-at-age data

Currently, FIMS uses a single weight-at-age matrix as there is no seasonality or timing in FIMS and weight-at-age is assumed to be the same across the population and fleets. Thus, no simplifications were needed for the FIMS model regarding weight-at-age data because all of the weight-at-age matrices in the Stock Synthesis model are replicates of each other, e.g., weight at age for fleet two is the same as beginning and middle of the year population weight at age in this model.

The last five years of weight-at-age data were averaged to provide weight-at-age information for the beginning of the year after the last year of landings were removed from the population.

Maturity

For the 2025 base model, year-specific maturity at age was derived from a temperature-specific relationship. FIMS uses a single set of logistic maturity parameters for all years, and thus, the year-specific maturity ogives had to be translated to a single logistic curve.

First, annual estimates of maturity at age for day 278 were averaged across years. Subsequently, this averaged ogive was fit using a logistic curve external to the assessment model. This lead to a curve with an inflection of 2.2 and a slope of 1.8 (Fig. @fig:maturity-plot). These values were specified as fixed inputs to the FIMS model.

Changes to the maturity model in FIMS can easily be accommodated but there are few populations that require changes, and thus, efforts have not been placed here. If desired, we could easily make the logistic parameters vary with time.

The two maturity ogives are quite similar with only small differences near the origin of lower empirical maturity for age-1 fish.

Averaged maturity at age from the 2025 base model (empirical) compared to logistic maturity (logistic).

Fisheries data

Any number of fleets, their landings, compositions, and catch-per-unit effort data can be accommodated in FIMS, and thus, regardless if the U.S. and Canadian fishery data are grouped into a single fleet, the fishery data can be accommodated in a FIMS model.

The Dirichlet-multinomial distribution is not yet available in FIMS. Compositional data were fit using a multinomial distribution. The input sample sizes for the survey age-composition data were multiplied by 10 to up-weight the survey data.

A separate analysis was conducted to investigate the fit of the age-composition data to each of the distributions available in Stock Synthesis. The analysis concluded that, if the true sample size is known, that the hake ages can be fit using a multinomial distribution. More research is being conducted regarding how true these conclusions are when the input sample size is not properly specified.

Survey data

Any number of surveys can be accommodated in a FIMS model. Additionally, the units of the data can be in metric tons or numbers.

Regardless, the age-1 index is not fit to in this case study because it is not in the 2025 base model.

No extra uncertainty was estimated for the index of abundance because this feature is not available in FIMS.

Model configurations

Fishing mortality

Annual fishing mortality was estimated using fixed effects.

Recruitment

The natural log of initial recruitment was estimated as a fixed effect, initialized at the same value as the base model (14.592). In the base model, this parameter is bounded between 13 and 17. Parameter bounds do not exist in FIMS but priors do, and thus, a normal prior with a mean of 15 and a standard deviation of 1.155 was used.

Steepness was fixed at the median estimate from the 2025 base model of 0.8118. The beta prior used in the base model is not yet available in FIMS.

FIMS models are currently powered by Template Model Builder (TMB), which allows for the estimation of random effects, similar to models built using the Woods Hole Assessment Model (WHAM). We harnessed this capability and estimated recruitment deviations as a random effect with a normal distribution and an estimated standard deviation.

Code
init_naa <- exp(init_naa_prior_mean) *
  1000 *
  exp(-(get_ages(data_4_model) - 1) * natural_mortality)
init_naa[get_n_ages(data_4_model)] <- init_naa[get_n_ages(data_4_model)] /
  natural_mortality # sum of infinite series

The time series of recruitment deviations is one element shorter than the time series in a Stock Synthesis model because the initial numbers at age object includes initial numbers for age-zero fish. The vector of the natural log of initial numbers at age was estimated using fixed effects and initialized at values in line with initial values for unfished recruitment and natural mortality. Additionally, a normal prior with a mean of 18 and a standard deviation of 1.155 was placed on the natural log of initial numbers at age in an attempt to aid model convergence. The input data to the model was also truncated to exclude the early years before age-composition data is available. This aided in the estimation of these parameters.

The priors on the natural log of unfished recruitment and the natural log of initial numbers at age should be transformed using a Jacobian transformation because the parameters are in log space but that transformation is not currently available in FIMS. The Jacobian allows for adjustment of the scale of integration so the total measure is preserved.

Selectivity

The logistic and double-logistic selectivity curves are currently the only selectivity forms that are available in FIMS. Both forms were investigated for Pacific Hake. We also investigated the use of a time-varying inflection point and/or slope for the fishing fleet. Additionally, we investigated allowing just a portion of the parameter vector to vary with time starting at several different time points, where the base model starts in 1991.

Work is in progress to implement an age-specific non-parametric selectivity curve in FIMS as there is a need for this option from both coasts. Consequently, we have put a high priority on it within the FIMS development team, and it will be available before the 2027 Pacific Hake assessment.

Natural mortality

Natural mortality in FIMS is age and year specific. To estimate a single parameter, the entire parameter vector could be mapped to a single value. That was not investigated here. Instead, natural mortality for all years was fixed at the sum of the median estimate of natural mortality from the 2025 base model (0.235) and the mean estimate of cannibalism across all years for a given age group. The use of age-specific natural mortality was to help stabilize the estimates of initial numbers at age, though the endeavour was not entirely successful.

# A tibble: 5 × 3
    age cannibalism     m
  <int>       <dbl> <dbl>
1     1      0.619  0.854
2     2      0.0386 0.274
3     3      0.0125 0.247
4     4      0.0100 0.245
5     5      0.0187 0.254

The lognormal prior is available in FIMS, and thus, the age- and year-specific parameter vector could be mapped to a single value and estimated using a log-normal prior but that was not investigated here.

Parameterization

FIMS models are configured with a data frame or R objects rather than an input file.

Code
time_varying_selectivity <- FALSE
selectivity_used <- "Logistic"
FIMS::clear()
parameters <- FIMS::create_default_configurations(data = data_4_model) |>
  tidyr::unnest(data) |>
  dplyr::rows_update(
    tibble::tibble(
      module_name = "Selectivity",
      fleet_name = c("fishery", "survey"),
      module_type = c(selectivity_used, "Logistic")
    ),
    by = c("module_name", "fleet_name")
  ) |>
  FIMS::create_default_parameters(data = data_4_model) |>
  tidyr::unnest(data) |>
  dplyr::rows_update(
    tibble::tibble(
      module_name = c("Maturity", "Maturity"),
      label = c("inflection_point", "slope"),
      value = c(
        hake_maturity_inflection_point,
        hake_maturity_slope
      )
    ),
    by = c("module_name", "label")
  ) |>
  dplyr::rows_update(
    tibble::tibble(
      module_name = c("Population"),
      label = c("log_M"),
      value = log(dplyr::pull(natural_mortality_matrix, m)),
      time = dplyr::pull(natural_mortality_matrix, year),
      age = dplyr::pull(natural_mortality_matrix, age)
    ),
    by = c("module_name", "label", "time", "age")
  ) |>
  dplyr::rows_update(
    tibble::tibble(
      module_name = "Population",
      label = "log_init_naa",
      age = FIMS::get_ages(data_4_model),
      value = log(init_naa),
      estimation_type = "fixed_effects"
    ),
    by = c("module_name", "label", "age")
  ) |>
  dplyr::rows_update(
    tibble::tibble(
      module_name = "Selectivity",
      fleet_name = rep(c("fishery", "survey"), each = 2),
      label = rep(c("inflection_point", "slope"), 2),
      # TODO: play with these start values
      value = c(2.05, 3.17, 4.4, 3.3),
      estimation_type = "fixed_effects"
    ),
    by = c("module_name", "fleet_name", "label")
  ) |>
  dplyr::rows_update(
    tibble::tibble(
      module_name = "Fleet",
      fleet_name = "survey",
      label = c("log_q"),
      value = log(0.832),
      estimation_type = "fixed_effects"
    ),
    by = c("module_name", "fleet_name", "label")
  )
  
fleet_names <- get_fleets(data_4_model)

# Initialize lists to store fleet-related objects
fleet <- fleet_selectivity <-
  fleet_landings <- fleet_landings_distribution <-
  fleet_index <- fleet_index_distribution <-
  fleet_age_comp <- fleet_agecomp_distribution <-
  vector("list", length(fleet_names))

for (i in seq_along(fleet_names)) {
  # Selectivity
  fleet_selectivity[[i]] <- FIMS:::initialize_selectivity(
    parameters = parameters,
    data = data_4_model,
    fleet_name = fleet_names[i]
  )
  # Time-varying selectivity for the fishery
  if (i == 1 & time_varying_selectivity) {
    selectivity_type <- parameters |>
      dplyr::filter(module_name == "Selectivity", fleet_name == "fishery") |>
      dplyr::slice(1) |>
      dplyr::pull(module_type)
    if (selectivity_type == "Logistic"){
      fleet_selectivity[[i]]$inflection_point$resize(get_n_years(data_4_model))
      for (ii in seq(get_n_years(data_4_model))) {
        fleet_selectivity[[i]]$inflection_point[ii]$value <- 1
        fleet_selectivity[[i]]$inflection_point[ii]$estimation_type$set("fixed_effects")
      }
      fleet_selectivity[[i]]$slope$resize(get_n_years(data_4_model))
      for (ii in seq(get_n_years(data_4_model))) {
        fleet_selectivity[[i]]$slope[ii]$value <- 1
        fleet_selectivity[[i]]$slope[ii]$estimation_type$set("fixed_effects")
      }
    }
    if (selectivity_type == "DoubleLogistic") {
      fleet_selectivity[[i]]$inflection_point_asc$resize(get_n_years(data_4_model))
      for (ii in seq(get_n_years(data_4_model))) {
        fleet_selectivity[[i]]$inflection_point_asc[ii]$value <- 1
        fleet_selectivity[[i]]$inflection_point_asc[ii]$estimation_type$set("fixed_effects")
      }
      # fleet_selectivity[[i]]$inflection_point_desc$resize(get_n_years(data_4_model))
      # for (ii in seq(get_n_years(data_4_model))) {
      #   fleet_selectivity[[i]]$inflection_point_desc[ii]$value <- 1
      #   fleet_selectivity[[i]]$inflection_point_desc[ii]$estimation_type$set("fixed_effects")
      # }
    }
  }

  # Setup the fleets
  fleet_module_ids <- c(
    selectivity = fleet_selectivity[[i]]$get_id()
  )
  fleet_types <- get_data(data_4_model) |>
    dplyr::filter(name == fleet_names[i]) |>
    dplyr::pull(type) |>
    unique()
  data_distribution_names_for_fleet_i <- parameters |>
    dplyr::filter(fleet_name == fleet_names[i] & distribution_type == "Data") |>
    dplyr::pull(module_type)

  # Landings
  if ("landings" %in% fleet_types &&
    "Landings" %in% data_distribution_names_for_fleet_i) {
    # Initialize landings module for the current fleet
    fleet_landings[[i]] <- FIMS:::initialize_landings(
      data = data_4_model,
      fleet_name = fleet_names[i]
    )
    # Add the module ID for the initialized landings to the list of fleet module IDs
    fleet_module_ids <- c(
      fleet_module_ids,
      c(landings = fleet_landings[[i]]$get_id())
    )
  }

  # Index
  if ("index" %in% fleet_types &&
    "Index" %in% data_distribution_names_for_fleet_i) {
    # Initialize index module for the current fleet
    fleet_index[[i]] <- FIMS:::initialize_index(
      data = data_4_model,
      fleet_name = fleet_names[i]
    )
    fleet_module_ids <- c(
      fleet_module_ids,
      c(index = fleet_index[[i]]$get_id())
    )
  }

  # Age composition
  if ("age_comp" %in% fleet_types &&
    "AgeComp" %in% data_distribution_names_for_fleet_i) {
    # Initialize age composition module for the current fleet
    fleet_age_comp[[i]] <- FIMS:::initialize_comp(
      data = data_4_model,
      fleet_name = fleet_names[i],
      type = "AgeComp"
    )
    fleet_module_ids <- c(
      fleet_module_ids,
      c(age_comp = fleet_age_comp[[i]]$get_id())
    )
  }

  fleet[[i]] <- FIMS:::initialize_fleet(
    parameters = parameters,
    data = data_4_model,
    fleet_name = fleet_names[i],
    linked_ids = fleet_module_ids
  )

  # Fleet uncertainty
  fleet_sd_input <- parameters |>
    dplyr::filter(fleet_name == fleet_names[i] & label == "log_sd") |>
    dplyr::mutate(
      label = "sd",
      value = exp(value)
    )

  if ("index" %in% fleet_types &&
    "Index" %in% data_distribution_names_for_fleet_i) {
    fleet_index_distribution[[i]] <- FIMS:::initialize_data_distribution(
      module = fleet[[i]],
      family = lognormal(link = "log"),
      sd = fleet_sd_input,
      data_type = "index"
    )
  }

  if ("landings" %in% fleet_types &&
    "Landings" %in% data_distribution_names_for_fleet_i) {
    fleet_landings_distribution[[i]] <- FIMS:::initialize_data_distribution(
      module = fleet[[i]],
      family = lognormal(link = "log"),
      sd = fleet_sd_input,
      data_type = "landings"
    )
  }

  if ("age_comp" %in% fleet_types &&
    "AgeComp" %in% data_distribution_names_for_fleet_i) {
    fleet_agecomp_distribution[[i]] <- FIMS:::initialize_data_distribution(
      module = fleet[[i]],
      family = multinomial(link = "logit"),
      data_type = "agecomp"
    )
  }
}

# Recruitment
recruitment <- methods::new(BevertonHoltRecruitment)
recruitment_process <- methods::new(LogDevsRecruitmentProcess)
recruitment$SetRecruitmentProcessID(recruitment_process$get_id())
# set up log_rzero (equilibrium recruitment)
recruitment$log_rzero[1]$value <- inputs[["ctl"]][["SR_parms"]]["SR_LN(R0)", "INIT"]
recruitment$log_rzero[1]$estimation_type$set("fixed_effects")
# set up logit_steep
recruitment$logit_steep[1]$value <- FIMS::logit(0.2, 1.0, steepness)
recruitment$logit_steep[1]$estimation_type$set("constant")
# recruit deviations should enter the model in normal space.
# The log is taken in the likelihood calculations
# Added an element to the log_devs vector because we need recruitment in the
# terminal year + 1
recruitment$log_devs$resize(get_n_years(data_4_model))
for (y in seq(recruitment$log_devs$size())) {
  recruitment$log_devs[y]$value <- 0
}
recruitment$log_devs$set_all_estimable(TRUE)
recruitment$n_years$set(get_n_years(data_4_model))
recruitment_distribution <- methods::new(DnormDistribution)
# TODO: check with Andrea about log space here
# logR_sd needs to enter the model logged b/c the exp() is
# taken before the likelihood calculation
recruitment_distribution$log_sd[1]$value <- log(
  inputs[["ctl"]][["SR_parms"]]["SR_sigmaR", "INIT"]
)
# TODO: check with Andrea about this not being in the parameter vector
recruitment_distribution$log_sd[1]$estimation_type$set("fixed_effects")
recruitment_distribution$x$resize(recruitment$log_devs$size())
recruitment_distribution$expected_values$resize(recruitment$log_devs$size())
for (i in seq(recruitment$log_devs$size())) {
  recruitment_distribution$x[i]$value <- 0
  recruitment_distribution$expected_values[i]$value <- 0
}
recruitment_distribution$set_distribution_links(
  "random_effects",
  recruitment$log_devs$get_id()
)
recruitment$log_devs$set_all_random(TRUE)

# Put a prior on log_rzero
log_rzero_prior <- methods::new(DnormDistribution)
log_rzero_prior$expected_values$resize(1)
log_rzero_prior$expected_values[1]$value <- log_rzero_prior_mean
log_rzero_prior$log_sd$resize(1)
log_rzero_prior$log_sd[1]$value <- log(log_rzero_prior_sd)
log_rzero_prior$set_distribution_links(
  "prior",
  recruitment$log_rzero$get_id()
)

# Growth
growth <- FIMS:::initialize_growth(
  parameters = parameters,
  data = data_4_model
)
# Add weight-at-age data for the terminal year + 1 b/c beginning of the year
# calculations
growth$n_years$set(get_n_years(data_4_model) + 1)
growth$weights$resize(growth$weights$size() + get_n_ages(data_4_model))
ii <- 0
for(i in (growth$weights$size() - get_n_ages(data_4_model)):(growth$weights$size() - 1)) {
  growth$weights$set(
    i,
    get_data(data_4_model) |>
      dplyr::filter(
        type == "weight-at-age",
        timing %in% (get_end_year(data_4_model) - 4):get_end_year(data_4_model),
        age == ii
      ) |>
      dplyr::pull(value) |>
      mean()
  )
  ii <- ii + 1
}

# Maturity
maturity <- FIMS:::initialize_maturity(
  parameters = parameters,
  data = data_4_model
)

# Population
population_module_ids <- c(
  recruitment = recruitment$get_id(),
  growth = growth$get_id(),
  maturity = maturity$get_id(),
  fleets = purrr::map(fleet, \(x) x$get_id())
)
population <- FIMS:::initialize_population(
  parameters = parameters,
  data = data_4_model,
  # TODO: need to remove linked_ids from the function and add module_id to the
  # parameters tibble
  linked_ids = population_module_ids
)
# Put a prior on init_naa
init_naa_prior <- methods::new(DnormDistribution)
init_naa_prior$expected_values$resize(1)
init_naa_prior$expected_values[1]$value <- init_naa_prior_mean
init_naa_prior$log_sd$resize(1)
init_naa_prior$log_sd[1]$value <- log(init_naa_prior_sd)
init_naa_prior$set_distribution_links(
  "prior",
  population$log_init_naa$get_id()
)
fims_model <- methods::new(CatchAtAge)
fims_model$AddPopulation(population$get_id())

# Initialize the model
CreateTMBModel()

Fit

Set up the fit

Code
# Create parameter list from Rcpp modules
parameter_list <- list(
  parameters = list(
    p = get_fixed(),
    re = get_random()
  ),
  model = fims_model
)
# fit <- FIMS::fit_fims(input = parameter_list, optimize = TRUE)
obj <- TMB::MakeADFun(
  data = list(),
  parameters = parameter_list$parameters,
  map = parameter_list$map,
  random = "re",
  DLL = "FIMS",
  silent = TRUE
)
parameter_names <- names(FIMS:::get_parameter_names(obj[["par"]]))
random_names <- names(FIMS:::get_random_names(obj[["env"]]$parList()$re))

Maximum likelihood

Code
# MLE estimation using code from FIMS::fit_fims()
for (ii in 1:3) {
  # control$trace is reset to zero regardless of verbosity because the
  # differences in values printed out using control$trace will be
  # negligible between these different runs and is not worth printing
  opt <- with(
    obj,
    nlminb(
      start = if (ii == 1) {
        par
      } else {
        opt[["par"]]
      },
      objective = fn,
      gradient = gr,
      control = list(eval.max = 10000, iter.max = 10000, trace = 0)
    )
  )
}
FIMS::set_fixed(opt$par)
sd_report <- TMB::sdreport(obj)
# Check that the standard errors are not NA
any(is.na(summary(
  TMB::sdreport(obj, skip.delta.method = TRUE),
  "fixed"
)[, 2]))

fit <- FIMSFit(
  input = parameter_list,
  obj = obj,
  opt = opt,
  sdreport = sd_report
)
output <- FIMS::get_estimates(fit) |>
  dplyr::mutate(
    uncertainty_label = "se",
    year = year_i + FIMS::get_start_year(data_4_model) - 1,
    estimate = estimated
  )

Bayesian

Code
# TODO: Implement this in parallel
mcmc <- adnuts::sample_snuts(
  obj,
  num_samples = 100,
  num_warmup = 200,
  chains = 2,
  cores = 1,
  seed = 1,
  # init = "random",
  control = list(adapt_delta = .95)
)
Optimizing...
Getting Q...
Inverting Q...
Q is 1.69% zeroes, with condition factor=2988741 (min=0.489, max=1460299.6)
Rebuilding TMB obj without random effects...
dense metric selected b/c faster than sparse and high correlation (max=0.9998)
log-posterior at inits=-231531.004; at conditional mode=-231531.004
Starting MCMC sampling...


Gradient evaluation took 0.000335 seconds
1000 transitions using 10 leapfrog steps per transition would take 3.35 seconds.
Adjust your expectations accordingly!



Gradient evaluation took 0.000309 seconds
1000 transitions using 10 leapfrog steps per transition would take 3.09 seconds.
Adjust your expectations accordingly!


Chain [1] Iteration:   1 / 300 [  0%]  (Warmup)
Chain [1] Iteration: 100 / 300 [ 33%]  (Warmup)
Chain [1] Iteration: 200 / 300 [ 66%]  (Warmup)
Chain [1] Iteration: 201 / 300 [ 67%]  (Sampling)
Chain [1] Iteration: 300 / 300 [100%]  (Sampling)

 Elapsed Time: 2.12 seconds (Warm-up)
               0.723 seconds (Sampling)
               2.843 seconds (Total)

Chain [2] Iteration:   1 / 300 [  0%]  (Warmup)
Chain [2] Iteration: 100 / 300 [ 33%]  (Warmup)
Chain [2] Iteration: 200 / 300 [ 66%]  (Warmup)
Chain [2] Iteration: 201 / 300 [ 67%]  (Sampling)
Chain [2] Iteration: 300 / 300 [100%]  (Sampling)

 Elapsed Time: 2.197 seconds (Warm-up)
               0.717 seconds (Sampling)
               2.914 seconds (Total)



Model 'FIMS' has 119 pars, and was fit using NUTS with a 'dense' metric
2 chain(s) of 300 total iterations (200 warmup) were used
Average run time per chain was 2.88 seconds 
Minimum ESS=51 (25.5%), and maximum Rhat=1.073
!! Warning: Signs of non-convergence found. Do not use for inference !!
There were 0 divergences after warmup
Code
posterior <- adnuts::extract_samples(
  mcmc,
  inc_lp = TRUE,
  inc_warmup = FALSE,
  unbounded = FALSE
)
colnames(posterior) <- c(
  parameter_names,
  paste0("Recruitment.1.recruitment_deviation.", obj$env$random),
  "likelihood"
)

posteriors_labeled <- tidyr::pivot_longer(
  posterior[, -NCOL(posterior)],
  cols = dplyr::everything(),
  names_to = "name"
) |>
  dplyr::left_join(
    colnames(posterior) |>
      dplyr::as_tibble() |>
      tidyr::separate_wider_delim(
        delim = ".",
        cols = value,
        names = c("module_name", "fleet_number", "label", "module_id"),
        cols_remove = FALSE,
        too_few = "align_start"
      ) |>
      dplyr::group_by(fleet_number, label) |>
      dplyr::mutate(
        index = if (dplyr::n() == 1) {
          NA_integer_
        } else {
          dplyr::row_number(label)
        },
        fleet_name = dplyr::if_else(fleet_number == 1, "fishery", "survey"),
        words = gsub("_", " ", label)
      ) |>
      dplyr::ungroup(),
    by = c("name" = "value")
  ) |>
  dplyr::left_join(
    y = year_conversion_matrix,
    by = c("index")
  )

# Get derived quantities using report()
post <- apply(as.data.frame(mcmc), 1, \(x) obj$report(x))
SB_long <- purrr::map(post, "spawning_biomass") |>
  purrr::map(
    \(x) data.frame(value = unlist(x), year_i = 1:length(unlist(x)))
  ) |>
  dplyr::bind_rows(.id = "simulation") |>
  dplyr::left_join(
    y = year_conversion_matrix,
    by = c("year_i" = "index")
  )

Mapped parameters

Code to map off parameters to another parameter is the same as “mirroring” parameters in Stock Synthesis.

Results

Plotting using {stockplotr}

Warning: Unknown or uninitialised column: `era`.
Warning in max(dat$year[dat$era == era_name], na.rm = TRUE): no non-missing
arguments to max; returning -Inf
Warning: Removed 1 row containing missing values or values outside the scale range
(`geom_hline()`).
Warning: Removed 1 row containing missing values or values outside the scale range
(`geom_text()`).

Ignoring unknown labels:
• colour : "Model"

Plotting Bayesian results

Parameter plots

The slope of the survey curve was poorly estimated.

Posterior estimates of the logistic selectivity curves for the fishery and the survey.

The survey selects younger fish than the fishery but they both select older fish equally.

Estimated selectivity curves for the fishery and the survey using logistic selectivity.
Picking joint bandwidth of 0.00453

The terminal year was poorly estimated.

Posterior estimates of the natural log of fishing mortality for each year (y axis).

Older ages were poorly estimated.

Posterior estimates of the natural log of initial numbers at age.

The terminal year estimates were poorly estimated.

Posterior estimates of recruitment deviations.

Derived quantity plots

The FIMS model overestimates values from the 1980s compared to the SS3 model and underestimates more recent values.

Comparisons of spawning biomass between the 2025 base model (points are median estimates) and the FIMS model (line with 95 percent credibility interval).

Diagnostic plots

Correlated parameters indicate large amounts of parameter spaces were covered by the chains.

Pairs plots of the six slowest mixing parameters in the FIMS model. Most of the parameters shown relate to the estimation of initial numbers at age.

Older ages were poorly estimated.

Posterior distributions (bins) and marginal estimates (red lines) for the natural log of initial numbers at age for each age, where smaller numbered parameters pertain to younger ages.

Parameters that were poorly estimated in an MLE context had even larger estimates of uncertainty in a Bayesian context.

Estimates of parameter uncertainty from the Bayesian estimation (x axis) compared to the same model fit using maximum likelihood estimation (MLE; y axis).

Clean up

Memory is allocated when FIMS modules are initialized and must be cleared using FIMS::clear(). Though, closing your R session will also clear the memory.

Code
FIMS::clear()