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 |
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")))
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.")
| 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:
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.
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?
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.
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)?
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) |
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).")
| 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 |
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.")
| 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?
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.
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)
# 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).")
| 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)
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).")
| 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.
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?
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).")
| 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.
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.)
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.")
| 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.
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.)
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.")
| 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?
Exercise A — Management summary
Write a 1-paragraph stock status statement for Algarve Red Seabream. Include:
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?