Using sdmTMB for spatiotemporal modelling
Understanding spatial and spatiotemporal models:
- Understand the spatial range.
- Become familiar with spatial random fields through simulation.
- Become familiar with spatiotemporal random fields and how they can combine with spatial random fields.
- Understand how correlation in spatiotemporal random fields looks.
Understanding the spatial range
The spatial range is the distance at which two data points are effectively independent. Technically, this is the distance at which correlation declines to approximately 0.13.
We can visualize the Matérn correlation function for various range values:
<- 0.5 #< try changing this
range
<- sqrt(8) / range
kappa <- seq(0.001, 1, length.out = 100)
distance <- kappa * distance * besselK(kappa * distance, nu = 1)
correlation plot(distance, correlation, type = "l", ylim = c(0, 1))
abline(h = 0.13, lty = 2) # 0.13 correlation
abline(v = range, lty = 2)
Here we have a unitless distance from 0 to 1. In real situations these would be in something like m or km and so the range would be units of m or km.
Exercise:
- Try running the previous chunk over and over with various
range
values. Note how the correlation function changes.
Spatial simulation
Next we will simulate some spatial random fields to understand what they look like and how their parameters control them.
First, this chunk creates a set of X and Y spatial coordinates and constructs a mesh using a minimum triangle edge length cutoff distance of 0.05. We need this for the internal random field calculations with the SPDE.
# A grid of X and Y values:
<- expand.grid(
predictor_dat X = seq(0, 1, length.out = 100), Y = seq(0, 1, length.out = 100)
)<- make_mesh(predictor_dat, c("X", "Y"), cutoff = 0.05)
mesh plot(mesh)
The basic function for simulating random fields in sdmTMB is sdmTMB_simulate()
.
The formula here contains only an intercept but could include covariates.
The family is Gaussian to specify that we simulate a normally distributed response.
The range (see above) is 0.6.
The two important standard deviation parameters are
sigma_O
(controlling the marginal standard deviation ofomega
, the spatial random field) andphi
(controlling the observation error standard deviation).We are plotting
observed
(observed values: the expected value plus observation error).
<- sdmTMB_simulate(
sim_dat formula = ~ 1,
data = predictor_dat,
mesh = mesh,
family = gaussian(link = "identity"),
range = 0.6, #< try changing this (spatial range)
sigma_O = 0.2, #< try changing this (spatial standard deviation)
phi = 0.001, #< try changing this observation error standard deviation
B = 0 # intercept
)
Warning in checkMatrixPackageVersion(): Package version inconsistency detected.
TMB was built with Matrix version 1.5.4
Current Matrix version is 1.5.3
Please re-install 'TMB' from source using install.packages('TMB', type = 'source') or ask CRAN for a binary version of 'TMB' matching CRAN's 'Matrix' package
Warning in checkDepPackageVersion(dep_pkg = "TMB"): Package version inconsistency detected.
glmmTMB was built with TMB version 1.9.3
Current TMB version is 1.9.4
Please re-install glmmTMB from source or restore original 'TMB' package (see '?reinstalling' for more information)
# head(sim_dat)
ggplot(sim_dat, aes(X, Y, fill = observed)) +
geom_raster()
Exercise:
- Try running the previous chunk over and over. Try changing the
range
andsigma_O
values. What do you observe? - Try increasing the observation error standard deviation
phi
and re-running it many times. Try making it equal tosigma_O
and twice its size. What happens when observation error is much larger thansigma_O
?
Note that the colour scale may be changing between runs!
Spatiotemporal simulation
<- expand.grid(
predictor_dat2 X = seq(0, 1, length.out = 100), Y = seq(0, 1, length.out = 100), year = 1:6
)<- make_mesh(predictor_dat2, c("X", "Y"), cutoff = 0.1) mesh2
<- sdmTMB_simulate(
sim_dat2 formula = ~ 1,
data = predictor_dat2,
time = "year",
mesh = mesh2,
family = gaussian(),
range = 0.5, #< spatial/spatiotemporal range
sigma_O = 0.2, #< spatial SD
sigma_E = 0.1, #< spatiotemporal SD
phi = 0.001,
B = 0 # intercept
)# head(sim_dat2)
ggplot(sim_dat2, aes(X, Y, fill = omega_s)) +
geom_raster() +
facet_wrap(~year) +
scale_fill_gradient2()
ggplot(sim_dat2, aes(X, Y, fill = epsilon_st)) +
geom_raster() +
facet_wrap(~year) +
scale_fill_gradient2()
ggplot(sim_dat2, aes(X, Y, fill = observed)) +
geom_raster() +
facet_wrap(~year) +
scale_fill_gradient2()
Exercise:
- Try running the previous 4 chunks over and over and observe the various components.
- What do you notice about
omega_s
(spatial random field) across the time slices? Conversely, what do you notice aboutepsilon_st
(spatiotemporal random fields) across time? - Try increasing
sigma_E
(spatiotemporal SD) to be larger thansigma_O
(spatial SD). How do the observations change?
Fitting a geostatistical spatial model
Let’s fit a basic spatial model to some simulated data. We’ll simulate on a grid and then down-sample from that grid. (We’ll do it this way so we can plot a smooth known random field in the end for comparison.)
<- expand.grid(
predictor_dat X = seq(0, 1, length.out = 100),
Y = seq(0, 1, length.out = 100)
)set.seed(19201) # for consistency
$covariate <- rnorm(n = nrow(predictor_dat))
predictor_dat<- make_mesh(predictor_dat, c("X", "Y"), cutoff = 0.05)
mesh plot(mesh)
<- sdmTMB_simulate(
sim_dat4 formula = ~ 1 + covariate,
data = predictor_dat,
mesh = mesh,
family = gaussian(link = "identity"),
range = 0.5,
sigma_O = 0.4,
phi = 0.3,
B = c(0.4, 0.2), # intercept and covariate coefficient
seed = 98259 # for consistency
)head(sim_dat4)
X Y omega_s mu eta observed (Intercept) covariate
1 0.00000000 0 0.20377670 0.6369297 0.6369297 0.4566973 1 0.1657648
2 0.01010101 0 0.17837392 0.6613120 0.6613120 0.9819005 1 0.4146902
3 0.02020202 0 0.15297114 0.3625643 0.3625643 0.4087907 1 -0.9520340
4 0.03030303 0 0.12756836 0.3930963 0.3930963 0.9683649 1 -0.6723601
5 0.04040404 0 0.10216557 0.6080553 0.6080553 0.9007784 1 0.5294488
6 0.05050505 0 0.07676279 0.3461807 0.3461807 0.2452402 1 -0.6529106
ggplot(sim_dat4, aes(X, Y, fill = omega_s)) +
geom_raster()
ggplot(sim_dat4, aes(X, Y, fill = mu)) +
geom_raster()
ggplot(sim_dat4, aes(X, Y, fill = observed)) +
geom_raster()
Exercise:
- What did we just simulate above? What is the one addition from the first spatial simulation?
- What do the last 3 plots represent? Why do they get increasingly noisy?
Now we’ll down-sample to simulate sampling/observing only 300 locations:
set.seed(1098)
<- sample(seq_len(nrow(sim_dat4)), size = 300)
to_sample <- sim_dat4[to_sample, ]
obs_dat ggplot(obs_dat, aes(X, Y, colour = observed)) +
geom_point()
Start by fitting a regular GLM:
<- glm(observed ~ 1 + covariate, family = gaussian(), data = obs_dat)
fit_glm summary(fit_glm)
Call:
glm(formula = observed ~ 1 + covariate, family = gaussian(),
data = obs_dat)
Deviance Residuals:
Min 1Q Median 3Q Max
-1.48113 -0.36757 0.00013 0.31924 1.33303
Coefficients:
Estimate Std. Error t value Pr(>|t|)
(Intercept) 0.81382 0.02850 28.55 < 2e-16 ***
covariate 0.19316 0.02721 7.10 9.2e-12 ***
---
Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
(Dispersion parameter for gaussian family taken to be 0.243673)
Null deviance: 84.897 on 299 degrees of freedom
Residual deviance: 72.615 on 298 degrees of freedom
AIC: 431.78
Number of Fisher Scoring iterations: 2
We can calculate and plot the residuals spatially:
$resid <- residuals(fit_glm)
obs_datggplot(obs_dat, aes(X, Y, colour = resid)) +
geom_point() +
scale_colour_gradient2()
And plot predictions spatially on the full grid:
$pred_glm <- predict(fit_glm, newdata = sim_dat4)
sim_dat4
<- range(c(sim_dat4$mu, sim_dat4$pred_glm))
lims
<- ggplot(sim_dat4, aes(X, Y, fill = mu)) +
g1 geom_raster() + ggtitle("Truth") +
scale_fill_viridis_c(limits = lims)
<- ggplot(sim_dat4, aes(X, Y, fill = pred_glm)) +
g2 geom_raster() + ggtitle("Estimated") +
scale_fill_viridis_c(limits = lims)
::plot_grid(g1, g2, nrow = 2) cowplot
Exercise:
- Do you see spatial correlation in the residuals?
- What are at least 2 problems this creates?
- How good do those predictions look compared to the known truth?
- In the context of this workshop, what is a solution to this?
First, let’s convince ourselves that we can fit the exact same model with sdmTMB. All we have to do is turn off the spatial random fields:
<- sdmTMB(observed ~ 1 + covariate, family = gaussian(),
fit_glm2 data = obs_dat, spatial = "off") # default is spatial = "on"
summary(fit_glm2)
Model fit by ML ['sdmTMB']
Formula: observed ~ 1 + covariate
Mesh: NULL (isotropic covariance)
Data: obs_dat
Family: gaussian(link = 'identity')
coef.est coef.se
(Intercept) 0.81 0.03
covariate 0.19 0.03
Dispersion parameter: 0.49
ML criterion at convergence: 212.889
See ?tidy.sdmTMB to extract these values as a data frame.
Exercise:
- Check if
fit_glm
andfit_glm2
match and represent the same model.
Now, try fitting a GLM with Gaussian random fields:
<- make_mesh(obs_dat, c("X", "Y"), cutoff = 0.05)
fitting_mesh plot(fitting_mesh)
<- sdmTMB(
fit_sdmTMB ~ 1 + covariate,
observed family = gaussian(),
data = obs_dat,
spatial = "on",
mesh = fitting_mesh
)sanity(fit_sdmTMB)
✔ Non-linear minimizer suggests successful convergence
✔ Hessian matrix is positive definite
✔ No extreme or very small eigenvalues detected
✔ No gradients with respect to fixed effects are >= 0.001
✔ No fixed-effect standard errors are NA
✔ No standard errors look unreasonably large
✔ No sigma parameters are < 0.01
✔ No sigma parameters are > 100
✔ Range parameter doesn't look unreasonably large
summary(fit_sdmTMB)
Spatial model fit by ML ['sdmTMB']
Formula: observed ~ 1 + covariate
Mesh: fitting_mesh (isotropic covariance)
Data: obs_dat
Family: gaussian(link = 'identity')
coef.est coef.se
(Intercept) 0.81 0.19
covariate 0.20 0.02
Dispersion parameter: 0.29
Matérn range: 0.54
Spatial SD: 0.34
ML criterion at convergence: 104.334
See ?tidy.sdmTMB to extract these values as a data frame.
tidy(fit_sdmTMB, conf.int = TRUE)
# A tibble: 2 × 5
term estimate std.error conf.low conf.high
<chr> <dbl> <dbl> <dbl> <dbl>
1 (Intercept) 0.811 0.192 0.435 1.19
2 covariate 0.199 0.0176 0.164 0.233
tidy(fit_sdmTMB, "ran_pars", conf.int = TRUE)
Standard errors intentionally omitted because they have been calculated in log
space.
# A tibble: 3 × 5
term estimate std.error conf.low conf.high
<chr> <dbl> <lgl> <dbl> <dbl>
1 range 0.540 NA 0.309 0.943
2 phi 0.295 NA 0.268 0.325
3 sigma_O 0.344 NA 0.245 0.483
summary(fit_glm2)
Model fit by ML ['sdmTMB']
Formula: observed ~ 1 + covariate
Mesh: NULL (isotropic covariance)
Data: obs_dat
Family: gaussian(link = 'identity')
coef.est coef.se
(Intercept) 0.81 0.03
covariate 0.19 0.03
Dispersion parameter: 0.49
ML criterion at convergence: 212.889
See ?tidy.sdmTMB to extract these values as a data frame.
summary(fit_sdmTMB)
Spatial model fit by ML ['sdmTMB']
Formula: observed ~ 1 + covariate
Mesh: fitting_mesh (isotropic covariance)
Data: obs_dat
Family: gaussian(link = 'identity')
coef.est coef.se
(Intercept) 0.81 0.19
covariate 0.20 0.02
Dispersion parameter: 0.29
Matérn range: 0.54
Spatial SD: 0.34
ML criterion at convergence: 104.334
See ?tidy.sdmTMB to extract these values as a data frame.
Exercise:
- How did the dispersion parameter (Gaussian SD) differ between
fit_glm2
andfit_sdmTMB
? Why? - How close are the estimated values to the truth? Is that about what you expected?
Now let’s calculate and plot residuals for the random field model:
$resid_sdmTMB <- residuals(fit_sdmTMB)
obs_datggplot(obs_dat, aes(X, Y, colour = resid_sdmTMB)) +
geom_point() +
scale_colour_gradient2()
And make predictions on the full grid:
<- predict(fit_sdmTMB, newdata = sim_dat4) p
<- range(c(sim_dat4$mu, p$est))
lims <- ggplot(sim_dat4, aes(X, Y, fill = mu)) +
g1 geom_raster() + ggtitle("True mean") +
scale_fill_viridis_c(limits = lims)
<- ggplot(p, aes(X, Y, fill = est)) +
g2 geom_raster() + ggtitle("Estimated value") +
scale_fill_viridis_c(limits = lims)
::plot_grid(g1, g2, nrow = 2) cowplot
<- range(c(sim_dat4$omega_s, p$omega_s))
lims <- ggplot(sim_dat4, aes(X, Y, fill = omega_s)) +
g1 geom_raster() + ggtitle("True random field") +
scale_fill_gradient2(limits = lims)
<- ggplot(p, aes(X, Y, fill = omega_s)) +
g2 geom_raster() + ggtitle("Estimated random field") +
scale_fill_gradient2(limits = lims)
::plot_grid(g1, g2, nrow = 2) cowplot
Predicted vs. true for the two models:
par(mfrow = c(2, 1))
plot(sim_dat4$mu, sim_dat4$pred_glm, main = "GLM",
xlab = "True", ylab = "Predicted")
plot(sim_dat4$mu, p$est, main = "GLM with random fields",
xlab = "True", ylab = "Predicted")
Exercise:
- How do the residuals look now? Why?
- How do the predictions look compared to before? Why?
- How do the random fields look? Is this as good as you expected? What would make these easier/harder to estimate?
Goals:
- Practice fitting a basic spatiotemporal model.
- Gain experience using cross validation for model selection.
- Understand how to inspect the model output.
- Practice predicting from the model on new data and making visualizations of those predictions.
- Gain familiarity with fitting and interpreting different random field structures.
The data
We will work with data representing Pacific Cod in the West Coast Vancouver Island synoptic trawl survey.
<- pcod dat
head(dat)
# A tibble: 6 × 12
year X Y depth density present lat lon depth_mean depth_sd
<int> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl>
1 2003 446. 5793. 201 113. 1 52.3 -130. 5.16 0.445
2 2003 446. 5800. 212 41.7 1 52.3 -130. 5.16 0.445
3 2003 449. 5802. 220 0 0 52.4 -130. 5.16 0.445
4 2003 437. 5802. 197 15.7 1 52.4 -130. 5.16 0.445
5 2003 421. 5771. 256 0 0 52.1 -130. 5.16 0.445
6 2003 418. 5772. 293 0 0 52.1 -130. 5.16 0.445
# ℹ 2 more variables: depth_scaled <dbl>, depth_scaled2 <dbl>
The dataset contains sampling locations (X and Y) and year
. It also contains sampling depth
in meters and sample density density
(CPUE) in units of tonnes/km2.
ggplot(dat, aes(X,Y, size = density)) + geom_point()
And check to make sure that looks right:
ggplot(dat, aes(X, Y, size = density)) + geom_point(shape = 21) + coord_fixed()
We can also plot the data by year:
ggplot(dat, aes(X, Y, size = density, colour = log(density + 1))) +
geom_point(alpha = 0.3) +
facet_wrap(~year) + coord_fixed()
We will create these new columns to use later:
$log_depth <- log(dat$depth)
dat$year_factor <- as.factor(dat$year) dat
Constructing a mesh
We start by constructing an SPDE mesh with INLA. This creates some matrices that are used internally when fitting the model. We will use the shortcut function make_mesh()
and use a cutoff (minimum triangle length of 10 km). Note: this is the only parameter that can be changed in make_mesh()
but more control over mesh arguments can be done with INLA (inla.mesh.2d()
).
<- make_mesh(dat, xy_cols = c("X", "Y"), cutoff = 10)
mesh plot(mesh)
$mesh$n # number of vertices or knots mesh
[1] 205
# ggplot alternative:
ggplot() + inlabru::gg(mesh$mesh) + coord_fixed() +
geom_point(aes(X, Y), data = dat, alpha = 0.2, size = 0.5)
Exercise:
- Try adjusting the size of the cutoff distance to explore the effects of decreasing the mesh resolution. Make sure to reset the
cutoff
value to10
in the end so the rest of the exercise behaves as intended, because model convergence issues can be caused by meshes that are either too fine or too coarse.
Fitting a spatial model
The most basic model we could fit would be a model with a single spatial random field, and no covariates. Using silent = FALSE
lets us see what is happening, but it’s awkward when running code in Rmd/Quarto chunks (with the default RStudio setting to ‘Show output inline for all R Markdown document’) so we will comment it out in most cases here. But it is a good idea to use it if models are running slowly or not converging to monitor progress.
<- sdmTMB(
fit_spatial ~ 1, # intercept only
density data = dat,
family = tweedie(link = "log"),
mesh = mesh,
spatial = "on",
# silent = FALSE
)
sanity(fit_spatial)
✔ Non-linear minimizer suggests successful convergence
✔ Hessian matrix is positive definite
✔ No extreme or very small eigenvalues detected
✔ No gradients with respect to fixed effects are >= 0.001
✔ No fixed-effect standard errors are NA
✔ No standard errors look unreasonably large
✔ No sigma parameters are < 0.01
✔ No sigma parameters are > 100
✔ Range parameter doesn't look unreasonably large
Did it have trouble? If so, try an extra optimization run and see if that’s sufficient:
<- run_extra_optimization(fit_spatial)
fit_spatial sanity(fit_spatial)
✔ Non-linear minimizer suggests successful convergence
✔ Hessian matrix is positive definite
✔ No extreme or very small eigenvalues detected
✔ No gradients with respect to fixed effects are >= 0.001
✔ No fixed-effect standard errors are NA
✔ No standard errors look unreasonably large
✔ No sigma parameters are < 0.01
✔ No sigma parameters are > 100
✔ Range parameter doesn't look unreasonably large
fit_spatial
Spatial model fit by ML ['sdmTMB']
Formula: density ~ 1
Mesh: mesh (isotropic covariance)
Data: dat
Family: tweedie(link = 'log')
coef.est coef.se
(Intercept) 2.74 0.35
Dispersion parameter: 13.72
Tweedie p: 1.60
Matérn range: 20.65
Spatial SD: 2.65
ML criterion at convergence: 6566.570
See ?tidy.sdmTMB to extract these values as a data frame.
Exercise:
A refresher:
- What is the ‘Matern range’ in the output?
- What is the ‘Spatial SD’ in the output?
- What is the ‘Dispersion parameter’ in the output?
Fitting a model with spatial + spatiotemporal fields
This first model includes a quadratic effect of log depth (poly(log_depth, 2)
), a factor effect for each year, and models total density using a Tweedie distribution and a log link. The spatial field and spatiotemporal fields are estimated.
The year factors give each year its own mean (this is generally the approach used in fisheries stock assessment). The 0 +
omits the intercept such that each year’s estimate represents a mean as opposed to a difference from the intercept. This part is arbitrary and chosen for the sake of this exercise.
The choice to use log depth helps the model fit because we have fewer samples from the deeper depths.
<- sdmTMB(
fit ~ 0 + year_factor + poly(log_depth, 2),
density data = dat,
family = tweedie(link = "log"),
mesh = mesh,
spatial = "on",
spatiotemporal = "iid", #< new
time = "year", #< new
# silent = FALSE
)
Exercise:
- Skim the help file (
?sdmTMB
). What are the other options forspatiotemporal
?
Print the model:
fit
Spatiotemporal model fit by ML ['sdmTMB']
Formula: density ~ 0 + year_factor + poly(log_depth, 2)
Mesh: mesh (isotropic covariance)
Time column: year
Data: dat
Family: tweedie(link = 'log')
coef.est coef.se
year_factor2003 1.83 0.29
year_factor2004 2.39 0.27
year_factor2005 2.17 0.27
year_factor2007 0.98 0.29
year_factor2009 1.49 0.28
year_factor2011 1.96 0.28
year_factor2013 2.31 0.27
year_factor2015 2.12 0.27
year_factor2017 1.39 0.29
poly(log_depth, 2)1 -63.66 6.10
poly(log_depth, 2)2 -95.54 5.87
Dispersion parameter: 11.01
Tweedie p: 1.50
Matérn range: 14.70
Spatial SD: 1.54
Spatiotemporal SD: 1.65
ML criterion at convergence: 6262.261
See ?tidy.sdmTMB to extract these values as a data frame.
Exercise:
- What is new in the output of
fit
compared tofit_spatial
?
Run a basic sanity check on the model:
sanity(fit)
✔ Non-linear minimizer suggests successful convergence
✔ Hessian matrix is positive definite
✔ No extreme or very small eigenvalues detected
✔ No gradients with respect to fixed effects are >= 0.001
✔ No fixed-effect standard errors are NA
✔ No standard errors look unreasonably large
✔ No sigma parameters are < 0.01
✔ No sigma parameters are > 100
✔ Range parameter doesn't look unreasonably large
We can use the tidy function to get the coefficients for the fixed effects:
tidy(fit, conf.int = TRUE)
# A tibble: 11 × 5
term estimate std.error conf.low conf.high
<chr> <dbl> <dbl> <dbl> <dbl>
1 year_factor2003 1.83 0.285 1.28 2.39
2 year_factor2004 2.39 0.267 1.87 2.92
3 year_factor2005 2.17 0.270 1.64 2.70
4 year_factor2007 0.983 0.289 0.416 1.55
5 year_factor2009 1.49 0.283 0.932 2.04
6 year_factor2011 1.96 0.276 1.42 2.50
7 year_factor2013 2.31 0.266 1.79 2.83
8 year_factor2015 2.12 0.274 1.59 2.66
9 year_factor2017 1.39 0.287 0.823 1.95
10 poly(log_depth, 2)1 -63.7 6.10 -75.6 -51.7
11 poly(log_depth, 2)2 -95.5 5.87 -107. -84.0
and by setting effects = "ran_pars"
, we can extract the variance and random effect components:
tidy(fit, effects = "ran_pars", conf.int = TRUE)
Standard errors intentionally omitted because they have been calculated in log
space.
# A tibble: 5 × 5
term estimate std.error conf.low conf.high
<chr> <dbl> <lgl> <dbl> <dbl>
1 range 14.7 NA 10.0 21.5
2 phi 11.0 NA 10.3 11.8
3 sigma_O 1.54 NA 1.18 2.01
4 sigma_E 1.65 NA 1.31 2.07
5 tweedie_p 1.50 NA 1.48 1.52
There are multiple ways we can plot the depth effect on density. First, we can create a new data frame of all potential values, setting the other predictors (here year) to a fixed value.
<- data.frame(log_depth = seq(log(50), log(700), length.out = 100), year = 2004)
nd # (picking any one year)
$year_factor <- as.factor(nd$year)
nd
<- predict(
p
fit,newdata = nd,
re_form = ~ 0, # means only include the fixed effects (not the default)
se_fit = TRUE # means calculate standard errors (not the default)
)
ggplot(p, aes(log_depth, exp(est),
ymin = exp(est - 1.96 * est_se), ymax = exp(est + 1.96 * est_se))) +
geom_line() + geom_ribbon(alpha = 0.4)
The second approach is to pass the sdmTMB object to the visreg package. This shows the conditional effect, where all values other than depth are held at a particular value. Note the default visreg plot is in link (here, log) space and the dots are randomized quantile residuals.
::visreg(fit, xvar = "log_depth") visreg
::visreg(fit, xvar = "log_depth", scale = "response") visreg
Third, we could use the ggeffects
package, which can be used to show the marginal effects of predictors (averaging over all other covariates rather than using a single fixed value). For more details see the visualizing marginal effects vignette. Note that won’t yet work with smoother s()
terms, but will soon work with the similar ggeffects::ggpredict()
.
<- ggeffects::ggeffect(fit, "log_depth [3.5:6.7 by=0.05]")
g plot(g)
Prediction
Let’s now predict on a grid that covers the entire survey (wcvi_grid
).
<- replicate_df(qcs_grid, "year", unique(pcod$year))
grid $log_depth <- log(grid$depth)
grid$year_factor <- as.factor(grid$year) grid
We can predict on the original data:
<- predict(fit) p0
To predict on a new data frame, we can specify newdata
. Here, we will predict on the survey grid. (1) This makes it easy to make visualizations. (2) This will be useful if we wanted to generate an area-weighted standardized population index later.
<- predict(fit, newdata = grid) p
We can plot each of the components of the prediction data frame spatially:
# Depth and year effect contribution:
# (Everything not a random field)
ggplot(p, aes(X, Y, fill = exp(est_non_rf))) +
facet_wrap(~year) +
geom_raster() +
coord_fixed()
# Spatial random field:
ggplot(p, aes(X, Y, fill = omega_s)) +
facet_wrap(~year) +
geom_raster() +
scale_fill_gradient2() +
coord_fixed()
# Spatial-temporal random field:
ggplot(p, aes(X, Y, fill = epsilon_st)) +
facet_wrap(~year) +
geom_raster() +
scale_fill_gradient2() +
coord_fixed()
# Overall estimate of density in link (log) space:
ggplot(p, aes(X, Y, fill = est)) +
facet_wrap(~year) +
geom_raster() +
coord_fixed()
# Overall estimate of density: (with log-distributed colour)
ggplot(p, aes(X, Y, fill = exp(est))) +
facet_wrap(~year) +
geom_raster() +
coord_fixed() +
scale_fill_viridis_c(trans = "log10")
Exercise:
- Step through each of the plots above and discuss what each represents.
Residual checking
We can calculate randomized quantile residuals with the residuals.sdmTMB()
function.
$resid <- residuals(fit) dat
We can plot those residuals spatially:
ggplot(dat, aes(X, Y, colour = resid)) +
facet_wrap(~year) +
geom_point(size = 0.5) +
coord_fixed() +
scale_colour_gradient2()
Exercise:
- What are you looking for in the above? Does it look OK?
- Usually visual inspection is fine but what kind of formal test could you use for spatial correlation?
We can check the distribution of the residuals with a QQ plot:
qqnorm(dat$resid)
qqline(dat$resid)
These don’t look great, but, this is largely a function of error from the Laplace approximation on the random effects (see Thygesen et al. 2017). MCMC-based are a better approach, but are slower. The following line of code will take a while to run and will require rstan and tmbstan. You may not want to run it. There’s a whole vignette on residual checking with sdmTMB here: https://pbs-assess.github.io/sdmTMB/articles/residual-checking.html It’s an active area of research.
# warning: will take a couple minutes and requires rstan!
# see ?residuals.sdmTMB
set.seed(1)
$resid_mcmc <- residuals(
dat
fit, type = "mle-mcmc",
mcmc_iter = 201,
mcmc_warmup = 200
)qqnorm(dat$resid_mcmc)
qqline(dat$resid_mcmc)
We can simulate new observations from the model and check properties of the simulation compared to the observed data:
<- simulate(fit, nsim = 100) s
mean(s == 0)
[1] 0.555063
mean(dat$density == 0)
[1] 0.5380308
hist(s, xlim = c(0, 30), breaks = 200, freq = FALSE)
hist(dat$density, xlim = c(0, 30), breaks = 200, freq = FALSE)
Exercise:
- What is the difference between
sdmTMB_simulate()
andsimulate.sdmTMB()
?
Fitting an AR(1) fields model
An alternative model would use AR(1) (first order autoregressive) correlated fields with or without spatial fields. We’ll fit a version without spatial fields.
<- update(fit, spatial = "off", spatiotemporal = "ar1") fit_ar1
sanity(fit_ar1)
✔ Non-linear minimizer suggests successful convergence
✔ Hessian matrix is positive definite
✔ No extreme or very small eigenvalues detected
✔ No gradients with respect to fixed effects are >= 0.001
✔ No fixed-effect standard errors are NA
✔ No standard errors look unreasonably large
✔ No sigma parameters are < 0.01
✔ No sigma parameters are > 100
✔ Range parameter doesn't look unreasonably large
fit_ar1
Spatiotemporal model fit by ML ['sdmTMB']
Formula: density ~ 0 + year_factor + poly(log_depth, 2)
Mesh: mesh (isotropic covariance)
Time column: year
Data: dat
Family: tweedie(link = 'log')
coef.est coef.se
year_factor2003 1.97 0.28
year_factor2004 2.53 0.26
year_factor2005 2.29 0.27
year_factor2007 1.07 0.29
year_factor2009 1.56 0.28
year_factor2011 2.04 0.27
year_factor2013 2.32 0.26
year_factor2015 2.13 0.27
year_factor2017 1.42 0.29
poly(log_depth, 2)1 -60.21 5.60
poly(log_depth, 2)2 -93.85 5.73
Dispersion parameter: 11.24
Tweedie p: 1.50
Spatiotemporal AR1 correlation (rho): 0.62
Matérn range: 15.53
Spatiotemporal SD: 2.11
ML criterion at convergence: 6284.092
See ?tidy.sdmTMB to extract these values as a data frame.
AIC(fit, fit_ar1)
df AIC
fit 16 12556.52
fit_ar1 16 12600.18
tidy(fit_ar1, "ran_pars", conf.int = TRUE)
Standard errors intentionally omitted because they have been calculated in log
space.
# A tibble: 5 × 5
term estimate std.error conf.low conf.high
<chr> <dbl> <lgl> <dbl> <dbl>
1 range 15.5 NA 10.7 22.6
2 phi 11.2 NA 10.5 12.0
3 sigma_E 2.11 NA 1.74 2.56
4 tweedie_p 1.50 NA 1.48 1.53
5 rho 0.618 NA 0.456 0.740
Exercise:
- What is the estimated random field AR(1) correlation value (
rho
)? (Hint: seeprint(fit_ar1)
and thetidy()
line above.) - What does this mean? Does this make sense ecologically?
- What is a subtle reason why you might want to include spatial fields if you’re using AR(1) fields? (Hint: think about the first time step and what the random field SD represents in the AR(1) process.)