Introduction

Scenario: You are assessing Red Seabream from the Portuguese Algarve Coast, a species exploited by bottom trawl with no formal stock assessment. A port sampling programme has collected length measurements from commercial catches over recent years.

Additionally, samples from five regions with different fishing intensities are available — from a marine reserve (no fishing) to an unmanaged area.

Your task: use length-based indicators and the LBSPR model to evaluate whether the stock is being sustainably exploited and provide management advice.

Data files needed:

File Description
lengths_seabream_2024.csv Single-year LF sample (2024)
lengths_seabream_byyear.csv Multi-year LF samples (2021–2024)
lengths_scenarios.csv LF from 5 regions with different fishing effort
lifehistory_seabream.csv Life history parameters

1 Setup

if (!require("tidyverse")) install.packages("tidyverse")
if (!require("LBSPR"))    install.packages("LBSPR")

library(tidyverse)
library(LBSPR)

theme_set(theme_minimal(base_size = 14) +
            theme(panel.grid.minor = element_blank(),
                  plot.title = element_text(face = "bold")))

2 Load and Explore the Data

2.1 Life history parameters

lf_2024      <- read.csv("lengths_seabream_2024.csv")
lf_byyear    <- read.csv("lengths_seabream_byyear.csv")
lf_scenarios <- read.csv("lengths_scenarios.csv")
lh_params    <- read.csv("lifehistory_seabream.csv")
lh_params %>% knitr::kable(caption = "Life history parameters for Red Seabream.")
Life history parameters for Red Seabream.
parameter value source notes
Linf_cm 40.0 FishBase von Bertalanffy asymptotic length
K_per_year 0.2 FishBase von Bertalanffy growth rate
t0 -0.5 FishBase theoretical age at length 0
M_per_year 0.3 Then et al. (2015) estimated from max age (Hoenig)
MK_ratio 1.5 derived (M/K) M / K ratio — key for length methods
Lmat50_cm 22.0 Local study length at 50% maturity
Lmat95_cm 26.0 Local study length at 95% maturity
Lopt_cm 26.7 derived optimal harvest length: Linf * 3/(3+M/K)
Linf   <- lh_params$value[lh_params$parameter == "Linf_cm"]
vbK    <- lh_params$value[lh_params$parameter == "K_per_year"]
M      <- lh_params$value[lh_params$parameter == "M_per_year"]
MK     <- lh_params$value[lh_params$parameter == "MK_ratio"]
Lmat50 <- lh_params$value[lh_params$parameter == "Lmat50_cm"]
Lmat95 <- lh_params$value[lh_params$parameter == "Lmat95_cm"]
Lopt   <- lh_params$value[lh_params$parameter == "Lopt_cm"]

cat("Key values:  Linf =", Linf, "cm,  M/K =", round(MK, 2),
    ",  Lopt =", round(Lopt, 1), "cm,  Lmat50 =", Lmat50, "cm\n")
#> Key values:  Linf = 40 cm,  M/K = 1.5 ,  Lopt = 26.7 cm,  Lmat50 = 22 cm

Key reference lengths:

  • Linf (40 cm) — asymptotic length; the maximum length a fish would reach if it lived forever.
  • Lopt (26.7 cm) — the length at which cohort biomass is maximised. Computed as \(L_\text{opt} = L_\infty \times \frac{3}{3 + M/K}\). Catching fish smaller than Lopt reduces yield.
  • Lmat50 (22 cm) — length at which 50% of fish are mature. Catching fish below Lmat threatens recruitment.

2.2 The 2024 sample

cat("n =", nrow(lf_2024), "fish\n")
#> n = 2000 fish
cat("Length range:", round(min(lf_2024$length_cm), 1), "–",
    round(max(lf_2024$length_cm), 1), "cm\n")
#> Length range: 7.2 – 41 cm
cat("Mean length: ", round(mean(lf_2024$length_cm), 1), "cm\n")
#> Mean length:  22.8 cm
ggplot(lf_2024, aes(length_cm)) +
  geom_histogram(binwidth = 1, fill = "#1C7293", colour = "white", alpha = 0.8) +
  geom_vline(xintercept = Linf, linetype = "dotted", colour = "grey30") +
  geom_vline(xintercept = Lopt, linetype = "dashed", colour = "#81B29A") +
  geom_vline(xintercept = Lmat50, linetype = "dashed", colour = "#E07A5F") +
  annotate("text", x = Linf + 0.5, y = Inf, label = "Linf",
           vjust = 2, colour = "grey30", size = 3.5) +
  annotate("text", x = Lopt + 0.5, y = Inf, label = "Lopt",
           vjust = 4, colour = "#81B29A", size = 3.5) +
  annotate("text", x = Lmat50 - 0.5, y = Inf, label = "Lmat",
           vjust = 6, colour = "#E07A5F", size = 3.5, hjust = 1) +
  labs(title = "Red Seabream — Length-Frequency (2024)",
       x = "Total length (cm)", y = "Count")
Length-frequency distribution of Red Seabream sampled in 2024. Vertical lines show key biological reference lengths.

Length-frequency distribution of Red Seabream sampled in 2024. Vertical lines show key biological reference lengths.

Q: Does the mean length at capture appear to be above or below Lopt? What does this suggest about the fishing pressure on this stock?

2.3 Regional comparison

These samples come from five regions with very different fishing intensities, from a marine reserve to an unmanaged fishery:

ggplot(lf_scenarios, aes(length_cm)) +
  geom_histogram(aes(fill = region), binwidth = 1, colour = "white",
                 alpha = 0.8, linewidth = 0.2) +
  geom_vline(xintercept = Lopt, linetype = "dashed", colour = "#81B29A") +
  geom_vline(xintercept = Lmat50, linetype = "dashed", colour = "#E07A5F") +
  facet_wrap(~region, ncol = 1, scales = "free_y") +
  scale_fill_manual(values = c("#1C7293", "#13A0A0", "#81B29A", "#F2CC8F", "#E07A5F")) +
  labs(title = "Length Distributions Across Regions",
       subtitle = "Green dashed = Lopt;  Orange dashed = Lmat",
       x = "Total length (cm)", y = "Count") +
  theme(legend.position = "none")
Length distributions across regions. As fishing effort increases, the distribution shifts left and large fish disappear.

Length distributions across regions. As fishing effort increases, the distribution shifts left and large fish disappear.

Q: How does the shape of the distribution change from the marine reserve to the unmanaged region? Pay attention to the mode (peak) and the right tail (large fish).

Q: At which effort level do most fish appear to be caught before they reach maturity (Lmat)?


3 Length-Based Indicators

Length-based indicators are the simplest screening tools for data-limited stocks. They compare observed summary statistics to biological reference lengths, with no model fitting required:

Indicator Formula Interpretation
Lmean / Lopt Mean length ÷ optimal length < 1 → growth overfishing (catching fish before they reach the size that maximizes yield per recruit → reduce growth/yield efficiency)
Lc / Lmat Length at first capture ÷ maturity length < 1 → risk of recruitment overfishing (removing fish before they reproduce enough → reduce spawning opportunity)
Pmega % of fish > 1.1 × Lopt < 30% → loss of mega-spawners (have we lost the large spawners / old age classes → reduce stock structure & spawning capacity)

3.1 Indicators for the 2024 sample

Lmega_threshold <- Lopt * 1.1

Lmean   <- mean(lf_2024$length_cm)
Lc_obs  <- as.numeric(quantile(lf_2024$length_cm, 0.05))
Pmega   <- mean(lf_2024$length_cm > Lmega_threshold) * 100
pct_mat <- mean(lf_2024$length_cm > Lmat50) * 100

status_lmean <- ifelse(Lmean / Lopt >= 0.9, "OK",
                       ifelse(Lmean / Lopt >= 0.8, "WARNING", "ALARM"))
status_lc    <- ifelse(Lc_obs / Lmat50 >= 1, "OK", "WARNING")
status_pmega <- ifelse(Pmega >= 30, "OK", "WARNING")

tibble(
  Indicator = c("Lmean / Lopt", "Lc / Lmat", "Pmega"),
  Value     = c(sprintf("%.1f / %.1f = %.3f", Lmean, Lopt, Lmean / Lopt),
                sprintf("%.1f / %.1f = %.3f", Lc_obs, Lmat50, Lc_obs / Lmat50),
                sprintf("%.1f%%", Pmega)),
  Status    = c(status_lmean, status_lc, status_pmega)
) %>% knitr::kable(caption = "Length-based indicators for Red Seabream (2024).")
Length-based indicators for Red Seabream (2024).
Indicator Value Status
Lmean / Lopt 22.8 / 26.7 = 0.854 WARNING
Lc / Lmat 16.0 / 22.0 = 0.727 WARNING
Pmega 10.8% WARNING

3.2 Indicators across regions

indicators_regions <- lf_scenarios %>%
  group_by(region) %>%
  summarise(
    n          = n(),
    Lmean      = round(mean(length_cm), 1),
    Lmean_Lopt = round(mean(length_cm) / Lopt, 3),
    Pmega      = round(mean(length_cm > Lmega_threshold) * 100, 1),
    pct_mature = round(mean(length_cm > Lmat50) * 100, 1),
    .groups = "drop"
  ) %>%
  mutate(Status = case_when(
    Lmean_Lopt >= 0.9 ~ "OK",
    Lmean_Lopt >= 0.8 ~ "WARNING",
    TRUE ~ "ALARM"
  ))

indicators_regions %>%
  knitr::kable(caption = "Length indicators by region. Status is based on Lmean/Lopt.")
Length indicators by region. Status is based on Lmean/Lopt.
region n Lmean Lmean_Lopt Pmega pct_mature Status
Marine Reserve (no fishing) 2500 26.4 0.989 33.0 71.9 OK
Region A (low effort) 2500 25.1 0.939 24.4 65.0 OK
Region B (moderate effort) 2500 22.9 0.859 11.2 51.5 WARNING
Region C (high effort) 2500 21.4 0.800 4.2 39.4 WARNING
Region D (unmanaged) 2500 20.0 0.751 1.4 25.1 ALARM

Q: Which region(s) show signs of growth overfishing (ALARM)?

Q: Based on indicators alone, what management advice would you give for the unmanaged region?


4 LBSPR — Spawning Potential Ratio

LBSPR (Hordyk et al. 2015) estimates the Spawning Potential Ratio (SPR) by fitting the expected length composition to observed data. SPR is the fraction of per-recruit reproductive output that remains under fishing, relative to unfished conditions.

SPR value Interpretation
1.0 Unfished population
≥ 0.40 Target — sustainably exploited
0.20 – 0.40 Under pressure — management action needed
< 0.20 Limit breached — likely overfished

Required inputs: Linf, M/K, Lmat50, Lmat95, and length-frequency data. The model estimates F/M and selectivity (SL50, SL95) internally.

4.1 Set up the LBSPR life history object

This object stays the same across all analyses — it encodes what we know about the species:

lh_pars <- new("LB_pars")
lh_pars@Linf <- Linf
lh_pars@MK   <- MK
lh_pars@L50  <- Lmat50
lh_pars@L95  <- Lmat95
# Bins for histogramming length data (shared across all fits)
bins <- seq(0, ceiling(Linf * 1.1), by = 1)

4.2 Single-year assessment (2024)

# Prepare LF data for LBSPR
lf_hist_2024 <- hist(lf_2024$length_cm, breaks = bins, plot = FALSE)

lb_2024 <- new("LB_lengths")
lb_2024@LMids  <- lf_hist_2024$mids
lb_2024@LData  <- matrix(lf_hist_2024$counts, ncol = 1)
lb_2024@Years  <- 2024
lb_2024@NYears <- 1L

# Fit the model
fit_2024 <- LBSPRfit(lh_pars, lb_2024, verbose = FALSE)
spr_2024 <- fit_2024@SPR[1]
fm_2024  <- fit_2024@FM[1]

tibble(
  Parameter = c("SPR", "F/M", "SL50 (selectivity)", "SL95 (selectivity)"),
  Estimate  = c(round(spr_2024, 3), round(fm_2024, 2),
                paste(round(fit_2024@SL50[1], 1), "cm"),
                paste(round(fit_2024@SL95[1], 1), "cm"))
) %>% knitr::kable(caption = "LBSPR results for Red Seabream (2024).")
LBSPR results for Red Seabream (2024).
Parameter Estimate
SPR 0.25
F/M 1.18
SL50 (selectivity) 18 cm
SL95 (selectivity) 22.6 cm

Interpretation:

if (spr_2024 >= 0.4) {
  cat("SPR =", round(spr_2024, 3),
      "→ Above the target (0.4). Stock appears sustainably fished.\n")
} else if (spr_2024 >= 0.2) {
  cat("SPR =", round(spr_2024, 3),
      "→ Between target (0.4) and limit (0.2). Stock under pressure.\n")
  cat("Recommendation: reduce F or increase minimum landing size.\n")
} else {
  cat("SPR =", round(spr_2024, 3),
      "→ Below limit (0.2). Stock likely overfished.\n")
  cat("Urgent action needed to reduce fishing mortality.\n")
}
#> SPR = 0.25 → Between target (0.4) and limit (0.2). Stock under pressure.
#> Recommendation: reduce F or increase minimum landing size.

Q: What can you interpret from the estimated SPR value?

Q: The estimated F/M is 1.18. Is fishing mortality higher or lower than natural mortality? What does this imply?

Q: Look at the estimated selectivity (SL50, SL95). How does SL50 compare to Lmat50? Are fish being caught before or after maturity?

*Q:** Given those results, what would you recommend to a manager (hint: think about the possibilities on reducing F or increasing minimum landing sizes)


4.3 LBSPR across regions

We now apply the same LBSPR model to each region. This produces a gradient of SPR values from the unfished reserve to the heavily exploited area, allowing us to see how fishing intensity maps to SPR:

# Helper: fit LBSPR to a length vector, return results or NAs on failure
fit_lbspr_region <- function(lengths, region_name) {
  tryCatch({
    h <- hist(lengths, breaks = bins, plot = FALSE)
    
    ld <- new("LB_lengths")
    ld@LMids  <- h$mids
    ld@LData  <- matrix(h$counts, ncol = 1)
    ld@Years  <- 2024
    ld@NYears <- 1L
    
    fit <- LBSPRfit(lh_pars, ld, verbose = FALSE)
    
    tibble(Region = region_name,
           n = length(lengths),
           SPR = fit@SPR[1],
           FM  = fit@FM[1],
           SL50 = fit@SL50[1])
  }, error = function(e) {
    tibble(Region = region_name,
           n = length(lengths),
           SPR = NA_real_, FM = NA_real_, SL50 = NA_real_)
  })
}

# Fit each region
region_results <- lf_scenarios %>%
  group_by(region) %>%
  group_modify(~ fit_lbspr_region(.x$length_cm, .y$region)) %>%
  ungroup()
region_results %>%
  mutate(across(c(SPR, FM, SL50), ~round(., 3)),
         Status = case_when(
           is.na(SPR) ~ "—",
           SPR >= 0.4 ~ "Above target",
           SPR >= 0.2 ~ "Between target & limit",
           TRUE ~ "Below limit"
         )) %>%
  knitr::kable(caption = "LBSPR estimates by region. NA indicates the model could not converge (can happen for the unfished reserve if there is no selectivity to estimate).")
LBSPR estimates by region. NA indicates the model could not converge (can happen for the unfished reserve if there is no selectivity to estimate).
region Region n SPR FM SL50 Status
Marine Reserve (no fishing) Marine Reserve (no fishing) 2500 0.926 0.05 17.95 Above target
Region A (low effort) Region A (low effort) 2500 0.592 0.35 17.84 Above target
Region B (moderate effort) Region B (moderate effort) 2500 0.273 1.06 17.75 Between target & limit
Region C (high effort) Region C (high effort) 2500 0.133 2.00 17.79 Below limit
Region D (unmanaged) Region D (unmanaged) 2500 0.066 3.17 17.53 Below limit
region_results %>%
  filter(!is.na(SPR)) %>%
  ggplot(aes(reorder(Region, -SPR), SPR, fill = SPR)) +
  geom_col(width = 0.6, alpha = 0.85) +
  geom_hline(yintercept = 0.4, linetype = "dashed", colour = "#81B29A",
             linewidth = 0.8) +
  geom_hline(yintercept = 0.2, linetype = "dashed", colour = "#E07A5F",
             linewidth = 0.8) +
  annotate("text", x = 0.6, y = 0.42, label = "Target (0.4)",
           colour = "#81B29A", hjust = 0, size = 3.5) +
  annotate("text", x = 0.6, y = 0.22, label = "Limit (0.2)",
           colour = "#E07A5F", hjust = 0, size = 3.5) +
  scale_fill_gradient2(low = "#E07A5F", mid = "#F2CC8F", high = "#81B29A",
                       midpoint = 0.4, guide = "none") +
  labs(title = "SPR Across Regions (LBSPR)",
       x = "", y = "Spawning Potential Ratio (SPR)") +
  theme(axis.text.x = element_text(angle = 25, hjust = 1)) +
  coord_cartesian(ylim = c(0, 1))
SPR estimates across regions with increasing fishing intensity. The marine reserve (if fitted) should be near 1.0; heavily fished regions approach the limit reference point.

SPR estimates across regions with increasing fishing intensity. The marine reserve (if fitted) should be near 1.0; heavily fished regions approach the limit reference point.

Q: Compare the LBSPR results with the indicator traffic-light table from Section 3. Do the two methods agree about which regions are in trouble?


4.4 Multi-year trend (2021–2024)

Has the stock status been improving or deteriorating? We fit LBSPR separately for each year:

yearly_results <- map_dfr(sort(unique(lf_byyear$year)), function(yr) {
  lf_yr <- lf_byyear %>% filter(year == yr)
  h <- hist(lf_yr$length_cm, breaks = bins, plot = FALSE)
  
  ld <- new("LB_lengths")
  ld@LMids  <- h$mids
  ld@LData  <- matrix(h$counts, ncol = 1)
  ld@Years  <- yr
  ld@NYears <- 1L
  
  fit <- LBSPRfit(lh_pars, ld, verbose = FALSE)
  
  tibble(Year = yr, SPR = fit@SPR[1], FM = fit@FM[1],
         SL50 = fit@SL50[1], n = nrow(lf_yr))
})
yearly_results %>%
  mutate(across(c(SPR, FM, SL50), ~round(., 3))) %>%
  knitr::kable(caption = "LBSPR estimates by year (2021–2024).")
LBSPR estimates by year (2021–2024).
Year SPR FM SL50 n
2021 0.463 0.56 17.92 1500
2022 0.328 0.88 17.90 1500
2023 0.235 1.22 17.72 1500
2024 0.187 1.55 18.01 1500
ggplot(yearly_results, aes(Year, SPR)) +
  geom_line(colour = "#065A82", linewidth = 1) +
  geom_point(colour = "#065A82", size = 3) +
  geom_hline(yintercept = 0.4, linetype = "dashed", colour = "#81B29A") +
  geom_hline(yintercept = 0.2, linetype = "dashed", colour = "#E07A5F") +
  annotate("text", x = min(yearly_results$Year), y = 0.42,
           label = "Target (0.4)", colour = "#81B29A", hjust = 0, size = 3.5) +
  annotate("text", x = min(yearly_results$Year), y = 0.22,
           label = "Limit (0.2)", colour = "#E07A5F", hjust = 0, size = 3.5) +
  labs(title = "Red Seabream — SPR Trend (2021–2024)",
       x = "Year", y = "Spawning Potential Ratio (SPR)") +
  coord_cartesian(ylim = c(0, max(yearly_results$SPR) * 1.2))
SPR trend over time. The stock has been declining towards the limit reference point.

SPR trend over time. The stock has been declining towards the limit reference point.

Q: Is SPR improving or deteriorating? When did the stock cross below the target of 0.4?

Q: What could explain the SPR trend? (Think: increasing effort, changes in gear, recruitment failure, etc.)


5 Sensitivity to Linf

Linf is the single most influential input for all length-based methods. Even a small error propagates through the model because Linf determines the expected shape of the length distribution.

Linf_test <- c(35, 38, 40, 42, 45)

# Reuse the 2024 LF data
linf_sens <- map_dfr(Linf_test, function(Li) {
  pars_test <- new("LB_pars")
  pars_test@Linf <- Li
  pars_test@MK   <- MK
  pars_test@L50  <- Lmat50
  pars_test@L95  <- Lmat95
  
  tryCatch({
    fit <- LBSPRfit(pars_test, lb_2024, verbose = FALSE)
    tibble(Linf_used = Li, SPR = fit@SPR[1], FM = fit@FM[1],
           correct = (Li == Linf))
  }, error = function(e) {
    tibble(Linf_used = Li, SPR = NA_real_, FM = NA_real_,
           correct = (Li == Linf))
  })
})
linf_sens %>%
  mutate(across(c(SPR, FM), ~round(., 3)),
         Note = ifelse(correct, "← correct Linf", "")) %>%
  select(-correct) %>%
  knitr::kable(caption = "LBSPR sensitivity to assumed Linf. Even 5 cm error drastically changes the SPR estimate.")
LBSPR sensitivity to assumed Linf. Even 5 cm error drastically changes the SPR estimate.
Linf_used SPR FM Note
35 0.527 0.43
38 0.325 0.88
40 0.250 1.18 ← correct Linf
42 0.201 1.49
45 NA NA
linf_sens %>%
  filter(!is.na(SPR)) %>%
  ggplot(aes(Linf_used, SPR, colour = correct)) +
  geom_line(colour = "grey60") +
  geom_point(size = 4) +
  geom_hline(yintercept = 0.4, linetype = "dashed", colour = "#81B29A") +
  geom_hline(yintercept = 0.2, linetype = "dashed", colour = "#E07A5F") +
  scale_colour_manual(values = c("TRUE" = "#81B29A", "FALSE" = "#065A82"),
                      guide = "none") +
  labs(title = "LBSPR Sensitivity to Assumed Linf",
       subtitle = paste0("True Linf = ", Linf, " cm (green point)"),
       x = expression(Assumed ~ L[infinity] ~ "(cm)"),
       y = "Estimated SPR") +
  coord_cartesian(ylim = c(0, 1))
SPR sensitivity to Linf. The true value (green point) gives the correct estimate; even moderate errors change the management conclusion.

SPR sensitivity to Linf. The true value (green point) gives the correct estimate; even moderate errors change the management conclusion.

Q Could the wrong Linf change your management conclusion from “sustainable” to “overfished” (or vice versa)?

Q How would you verify Linf in practice? (Hint: compare to the largest fish in your sample, check FishBase for the region, look for local growth studies, invest on better biological/growth studies.)


6 Bringing It All Together

tibble(
  Method     = c("Indicators (Lm/Lopt)",
                 "LBSPR — SPR",
                 "LBSPR — F/M"),
  Result     = c(sprintf("%.3f", Lmean / Lopt),
                 sprintf("%.3f", spr_2024),
                 sprintf("%.2f", fm_2024)),
  Interpretation = c(
    ifelse(Lmean / Lopt >= 0.9, "No growth overfishing",
           "Growth overfishing likely"),
    case_when(spr_2024 >= 0.4 ~ "Above target",
              spr_2024 >= 0.2 ~ "Between target and limit",
              TRUE ~ "Below limit — overfished"),
    ifelse(fm_2024 > 1, "F exceeds M", "F below M")
  )
) %>% knitr::kable(caption = "Summary of all methods applied to the 2024 sample.")
Summary of all methods applied to the 2024 sample.
Method Result Interpretation
Indicators (Lm/Lopt) 0.854 Growth overfishing likely
LBSPR — SPR 0.250 Between target and limit
LBSPR — F/M 1.18 F exceeds M

Q: Do the indicators and LBSPR agree? When multiple independent methods converge on the same conclusion, how does that affect your confidence in the advice?


Exercises

Exercise A — Management summary

Write a 1-paragraph stock status statement for Algarve Red Seabream. Include:

  1. The SPR estimate and its trend direction (2021–2024)
  2. Whether growth overfishing is occurring (indicators)
  3. Your confidence level (consider the Linf sensitivity)
  4. A specific management recommendation: reduce F, increase minimum landing size, or both?

Exercise B — What if selectivity changes?

A manager proposes increasing the minimum mesh size so that Lc shifts from ~18 cm to 25 cm (above Lmat). Use the LBSPR simulation mode to predict the effect:

# Set up parameters for the new selectivity
sim_pars <- new("LB_pars")
sim_pars@Linf  <- Linf
sim_pars@MK    <- MK
sim_pars@L50   <- Lmat50
sim_pars@L95   <- Lmat95
sim_pars@SL50  <- 25    # new selectivity
sim_pars@SL95  <- 29
sim_pars@FM    <- fm_2024   # keep current F/M

sim_result <- LBSPRsim(sim_pars)
cat("Predicted SPR with new mesh:", round(sim_result@SPR, 3), "\n")

How much would SPR improve? Would it recover to above the 0.4 target?