SSF Formulations
In this script we demonstrate different approaches to including covariates in an SSF model.
Load required packages
Import data and clean
New names:
Rows: 133161 Columns: 11
── Column specification
──────────────────────────────────────────────────────── Delimiter: "," chr
(2): node, dates dbl (7): ...1, lat, lon, height, accuracy, heading, speed dttm
(2): timestamp, DateTime
ℹ Use `spec()` to retrieve the full column specification for this data. ℹ
Specify the column types or set `show_col_types = FALSE` to quiet this message.
• `` -> `...1`
Code
# remove individuals that have poor data quality or less than about 3 months of data.
# The "2014.GPS_COMPACT copy.csv" string is a duplicate of ID 2024, so we exclude it
buffalo_data <- buffalo_data %>% filter(!node %in% c("2014.GPS_COMPACT copy.csv",
2029, 2043, 2265, 2284, 2346))
buffalo_data <- buffalo_data %>%
group_by(node) %>%
arrange(DateTime, .by_group = T) %>%
distinct(DateTime, .keep_all = T) %>%
arrange(node) %>%
mutate(ID = node)
buffalo_clean <- buffalo_data[, c(12, 2, 4, 3)]
colnames(buffalo_clean) <- c("id", "time", "lon", "lat")
attr(buffalo_clean$time, "tzone") <- "Australia/Queensland"
buffalo_clean$sex <- "f"
buffalo_clean$life_stage <- "adult"
head(buffalo_clean)[1] "Australia/Queensland"
Create a step object
Use the amt package to create a trajectory object from the cleaned data.
Plot the data spatially
Code

Pick out a single individual
Code
which_buffalo <- "2158" # select a single buffalo ID
# create directory to save plots for each buffalo
plot_save_path <- paste0("outputs/buffalo_", which_buffalo)
dir.create(plot_save_path, showWarnings = FALSE)
buffalo_id <- buffalo_all %>% filter(id == which_buffalo)
buffalo_id %>%
ggplot(aes(x = x_, y = y_, colour = t_)) +
geom_path(alpha = 0.5) +
geom_point(alpha = 0.5, size = 0.1) +
coord_fixed() +
scale_x_continuous("Easting (m)") +
scale_y_continuous("Northing (m)") +
theme_classic()
Import spatial covariate
Although NDVI changes over time, and we have access to monthly layers, we will just select a single month here.
Code

class : SpatRaster
size : 2280, 2400, 1 (nrow, ncol, nlyr)
resolution : 25, 25 (x, y)
extent : 0, 60000, -1463000, -1406000 (xmin, xmax, ymin, ymax)
coord. ref. : GDA94 / Geoscience Australia Lambert (EPSG:3112)
source : ndvi_aug_2018.tif
name : ndvi
min value : -0.544105
max value : 0.808655
Select local extent
We just want to look at an area centred on a single location, which might be combined with our movement probability surface to generate a next-step probability surface.
We’ll centre the extent on an interesting looking part of the landscape, and then buffer it by 1500m (with a buffer of the cell resolution/2 to ensure there is a central cell), which contains most of the step lengths, and therefore most of the movement kernel.
Code
cell_resolution <- 25 # in metres
buffalo_single_point <- buffalo_id[7500,] # select one of the locations
buffer <- 1500 + (cell_resolution/2) # buffer in metres
window_extent <- ext(buffalo_single_point$x_ - buffer,
buffalo_single_point$x_ + buffer,
buffalo_single_point$y_ - buffer,
buffalo_single_point$y_ + buffer)
ndvi_window <- crop(ndvi, window_extent)
# set the extent to 0 at the buffalo location
ndvi_window <- terra::shift(ndvi_window,
dx = -buffalo_single_point$x_,
dy = -buffalo_single_point$y_)
# plot the NDVI layer with the buffalo location as the centre point
plot(ndvi_window,
# main = paste0("NDVI"),
col = brewer.pal(9, "Greens"),
plg=list(x="top", title="NDVI", tics="out", cex=.8))
points(x = 0, y = 0, col = "red")
Code
# Save the plot
png(filename = paste0(plot_save_path, "/ndvi_local_", which_buffalo, ".png"),
width = 100, height = 100, units = "mm", res = 600)
plot(ndvi_window,
# main = paste0("NDVI"),
col = brewer.pal(9, "Greens"),
plg=list(x="top", title="NDVI", tics="out", cex=.8))
points(x = 0, y = 0, col = "red")
dev.off()quartz_off_screen
2
Sample random steps to fit models
Code
[1] 0.4513375
[1] 621.3796
[1] 0.1531606
Circular Data:
Type = angles
Units = radians
Template = none
Modulo = asis
Zero = 0
Rotation = counter
[1] 0
Code
# sample random steps
buffalo_ssf_data <- buffalo_id_steps %>%
random_steps(n = 10,
sl_dist = gamma_dist,
ta_dist = vonmises_dist) %>%
extract_covariates(ndvi) %>%
mutate(ndvi_sq = ndvi^2,
log_sl = log(sl_),
cos_ta = cos(ta_),
times = 1) # create a dummy column for times (used in a Cox PH model, but not relevant to an SSF) that all contain the same valueDifferent SSF formulations
Firstly, we want to pull out the range of NDVI values to plot the curves with.

Code
0.5% 99.5%
0.06121541 0.71819625
Set RSS min and max values
This is hard-coded as the maximum rss value across the different formulations, to ensure the plotting extent is consistent across approaches.
Linear covariate
Model formula
\(y \sim ndvi\)
Fit model
Code
Call:
coxph(formula = Surv(rep(1, 106678L), case_) ~ ndvi + sl_ + log_sl +
cos_ta + strata(step_id_), data = data, method = "exact")
n= 106678, number of events= 9698
coef exp(coef) se(coef) z Pr(>|z|)
ndvi -9.104e-01 4.024e-01 1.444e-01 -6.302 2.93e-10 ***
sl_ 1.026e-05 1.000e+00 3.192e-05 0.321 0.748
log_sl 3.338e-03 1.003e+00 5.687e-03 0.587 0.557
cos_ta -1.252e-02 9.876e-01 1.503e-02 -0.833 0.405
---
Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
exp(coef) exp(-coef) lower .95 upper .95
ndvi 0.4024 2.4853 0.3032 0.534
sl_ 1.0000 1.0000 0.9999 1.000
log_sl 1.0033 0.9967 0.9922 1.015
cos_ta 0.9876 1.0126 0.9589 1.017
Concordance= 0.501 (se = 0.004 )
Likelihood ratio test= 42.36 on 4 df, p=1e-08
Wald test = 41.2 on 4 df, p=2e-08
Score (logrank) test = 41.21 on 4 df, p=2e-08
[1] 46475.21
Code
Family: Cox PH
Link function: identity
Formula:
cbind(times, step_id_) ~ ndvi + sl_ + log_sl + cos_ta
Parametric coefficients:
Estimate Std. Error z value Pr(>|z|)
ndvi -9.104e-01 1.444e-01 -6.302 2.93e-10 ***
sl_ 1.026e-05 3.192e-05 0.321 0.748
log_sl 3.338e-03 5.687e-03 0.587 0.557
cos_ta -1.252e-02 1.503e-02 -0.833 0.405
---
Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
Deviance explained = -0.0499%
-REML = 23252 Scale est. = 1 n = 106678
[1] 46475.21
Code
# Get predictions on log-scale with standard errors
predictions <- predict(ssf_linear_gam,
newdata = pred_data,
type = "link", # log-scale
se.fit = TRUE)
# Extract log RSS and standard errors
log_rss <- predictions$fit
se_log_rss <- predictions$se.fit
# Calculate confidence intervals on log-scale
log_rss_lower <- log_rss - 1.96 * se_log_rss
log_rss_upper <- log_rss + 1.96 * se_log_rss
# Transform to RSS scale
rss <- exp(log_rss)
rss_lower <- exp(log_rss_lower)
rss_upper <- exp(log_rss_upper)
# Create your dataframe
ndvi_coef_df <- data.frame(
ndvi_values = ndvi_values,
log_rss = log_rss,
log_rss_lower = log_rss_lower,
log_rss_upper = log_rss_upper,
rss = rss,
rss_lower = rss_lower,
rss_upper = rss_upper
)
# plot the response curve
ggplot(data = ndvi_coef_df) +
geom_hline(yintercept = 1, linetype = "dashed", colour = "grey") +
geom_ribbon(aes(x = ndvi_values, ymin = rss_lower, ymax = rss_upper),
fill = "grey70", alpha = 0.5) +
geom_line(aes(x = ndvi_values, y = rss),
colour = "black", linewidth = 1) +
scale_x_continuous(breaks = seq(-0.2, 0.8, by = 0.1)) +
labs(x = "NDVI", y = "RSS") +
theme_classic() 
Response curve
Code
# extract the NDVI coefficient and its standard error
ndvi_coef <- ssf_linear$model$coefficients[1]
ndvi_coef_se <- sqrt(diag(ssf_linear$model$var))[1] # SE of coefficient
# calculate the RSS for each NDVI value
ndvi_coef_df <- data.frame(
ndvi_values = ndvi_values,
log_rss = ndvi_values * ndvi_coef,
rss = exp(ndvi_values * ndvi_coef),
se_prediction = abs(ndvi_values) * ndvi_coef_se # SE of prediction at each NDVI value
)
# plot the response curve
ggplot(data = ndvi_coef_df) +
geom_hline(yintercept = 1, linetype = "dashed", colour = "grey") +
geom_ribbon(aes(x = ndvi_values, ymin = exp(log_rss - 1.96 * se_prediction), ymax = exp(log_rss + 1.96 * se_prediction)),
fill = "grey70", alpha = 0.5) +
geom_line(aes(x = ndvi_values, y = rss),
colour = "black", linewidth = 1) +
geom_rug(data = buffalo_ssf_data %>% filter(case_ == T),
aes(x = ndvi), alpha = 0.25, sides="b") +
scale_x_continuous(breaks = seq(-0.2, 0.8, by = 0.1)) +
scale_y_continuous(limits = c(rss_min, rss_max)) +
labs(x = "NDVI", y = "RSS") +
theme_classic() 
Plot habitat selection
Code

Code
quartz_off_screen
2
Quadratic covariate
Model formula
\(y \sim ndvi + ndvi^2\)
Fit model
Code
Call:
coxph(formula = Surv(rep(1, 106678L), case_) ~ ndvi + ndvi_sq +
sl_ + log_sl + cos_ta + strata(step_id_), data = data, method = "exact")
n= 106678, number of events= 9698
coef exp(coef) se(coef) z Pr(>|z|)
ndvi 4.462e+00 8.668e+01 4.981e-01 8.958 <2e-16 ***
ndvi_sq -9.008e+00 1.224e-04 8.157e-01 -11.044 <2e-16 ***
sl_ -8.545e-06 1.000e+00 3.201e-05 -0.267 0.790
log_sl 7.233e-03 1.007e+00 5.720e-03 1.265 0.206
cos_ta -1.353e-02 9.866e-01 1.504e-02 -0.899 0.369
---
Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
exp(coef) exp(-coef) lower .95 upper .95
ndvi 8.668e+01 1.154e-02 3.265e+01 2.301e+02
ndvi_sq 1.224e-04 8.169e+03 2.475e-05 6.055e-04
sl_ 1.000e+00 1.000e+00 9.999e-01 1.000e+00
log_sl 1.007e+00 9.928e-01 9.960e-01 1.019e+00
cos_ta 9.866e-01 1.014e+00 9.579e-01 1.016e+00
Concordance= 0.528 (se = 0.003 )
Likelihood ratio test= 175.8 on 5 df, p=<2e-16
Wald test = 147.1 on 5 df, p=<2e-16
Score (logrank) test = 147.2 on 5 df, p=<2e-16
[1] 46343.8
Code
# fit an ssf gam model with parametric terms. -ends up being the same as above,
# but allows us to use the `predict` function to get predictions on the log-scale with standard errors,
# which is a bit more work to do with the `amt` model object.
ssf_quadratic_gam <- mgcv::gam(cbind(times, step_id_) ~
ndvi +
ndvi_sq +
sl_ + log_sl + cos_ta,
data = buffalo_ssf_data,
family = cox.ph,
weight = case_)
summary(ssf_quadratic_gam)
Family: Cox PH
Link function: identity
Formula:
cbind(times, step_id_) ~ ndvi + ndvi_sq + sl_ + log_sl + cos_ta
Parametric coefficients:
Estimate Std. Error z value Pr(>|z|)
ndvi 4.462e+00 4.981e-01 8.958 <2e-16 ***
ndvi_sq -9.008e+00 8.157e-01 -11.044 <2e-16 ***
sl_ -8.545e-06 3.201e-05 -0.267 0.790
log_sl 7.233e-03 5.720e-03 1.265 0.206
cos_ta -1.353e-02 1.504e-02 -0.899 0.369
---
Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
Deviance explained = -0.0992%
-REML = 23184 Scale est. = 1 n = 106678
[1] 46343.8
Generate the response curve
Code
# Get predictions on log-scale with standard errors
predictions <- predict(ssf_quadratic_gam,
newdata = pred_data,
type = "link", # log-scale
se.fit = TRUE)
# Extract log RSS and standard errors
log_rss <- predictions$fit
se_log_rss <- predictions$se.fit
# Calculate confidence intervals on log-scale
log_rss_lower <- log_rss - 1.96 * se_log_rss
log_rss_upper <- log_rss + 1.96 * se_log_rss
# Transform to RSS scale
rss <- exp(log_rss)
rss_lower <- exp(log_rss_lower)
rss_upper <- exp(log_rss_upper)
# Create your dataframe
ndvi_coef_df <- data.frame(
ndvi_values = ndvi_values,
log_rss = log_rss,
log_rss_lower = log_rss_lower,
log_rss_upper = log_rss_upper,
rss = rss,
rss_lower = rss_lower,
rss_upper = rss_upper
)
# plot the response curve
ggplot(data = ndvi_coef_df) +
geom_hline(yintercept = 1, linetype = "dashed", colour = "grey") +
geom_ribbon(aes(x = ndvi_values, ymin = rss_lower, ymax = rss_upper),
fill = "grey70", alpha = 0.5) +
geom_line(aes(x = ndvi_values, y = rss),
colour = "black", linewidth = 1) +
geom_rug(data = buffalo_ssf_data %>% filter(case_ == T),
aes(x = ndvi), alpha = 0.25, sides="b") +
scale_x_continuous(breaks = seq(-0.2, 0.8, by = 0.1)) +
scale_y_continuous(limits = c(rss_min, rss_max)) +
labs(x = "NDVI", y = "RSS") +
theme_classic() 
Response curve
Code
# extract the NDVI coefficient
ndvi_coef <- ssf_quadratic$model$coefficients[1]
ndvi_coef_sq <- ssf_quadratic$model$coefficients[2]
ndvi_se = sqrt(diag(ssf_quadratic$model$var))[1]
ndvi_sq_se = sqrt(diag(ssf_quadratic$model$var))[2]
log_rss = (ndvi_values * ndvi_coef) + (ndvi_values^2 * ndvi_coef_sq)
rss = exp(log_rss)
# calculate the RSS for each NDVI value
ndvi_coef_df <- data.frame(ndvi_values, log_rss, rss)
# plot the response curve
ggplot() +
geom_hline(yintercept = 1, linetype = "dashed", colour = "grey") +
geom_line(data = ndvi_coef_df, aes(x = ndvi_values, y = rss),
colour = "black", linewidth = 1) +
scale_x_continuous(breaks = seq(-0.2, 0.8, by = 0.1)) +
labs(x = "NDVI", y = "RSS") +
theme_classic() 
Plot habitat selection
Code
# extract the NDVI coefficient
ndvi_coef <- ssf_quadratic$model$coefficients[1]
ndvi_coef_sq <- ssf_quadratic$model$coefficients[2]
# calculate the RSS for the local NDVI values
ndvi_quadratic <- exp((ndvi_window * ndvi_coef) +
(ndvi_window^2 * ndvi_coef_sq))
# plot the habitat selection (spatial RSS)
plot(ndvi_quadratic, main = "RSS - quadratic")
Code
quartz_off_screen
2
Smooth term
Fit a GAM to the data, with a smooth term across the range of NDVI values (Klappstein et al. 2024).
Model formula
\(y \sim s(ndvi)\)
Fit model
Code
Family: Cox PH
Link function: identity
Formula:
cbind(times, step_id_) ~ s(ndvi, bs = "cr") + sl_ + log_sl +
cos_ta
Parametric coefficients:
Estimate Std. Error z value Pr(>|z|)
sl_ 1.530e-06 3.196e-05 0.048 0.9618
log_sl 1.211e-02 5.761e-03 2.102 0.0355 *
cos_ta -1.345e-02 1.506e-02 -0.893 0.3719
---
Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
Approximate significance of smooth terms:
edf Ref.df Chi.sq p-value
s(ndvi) 8.418 8.85 383 <2e-16 ***
---
Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
Deviance explained = 0.33%
-REML = 23089 Scale est. = 1 n = 106678
[1] 46120.8
Using ggplot with the smooth_estimates dataframe from gratia
Code
ggplot(data = gam_smooths) +
geom_hline(yintercept = 1, linetype = "dashed", colour = "grey") +
geom_ribbon(aes(x = ndvi, ymin = exp(.estimate - 1.96 * .se), ymax = exp(.estimate + 1.96 * .se)),
fill = "grey70", alpha = 0.5) +
geom_line(aes(x = ndvi, y = exp(.estimate)),
colour = "black", linewidth = 1) +
labs(x = "NDVI", y = "RSS") +
# scale_x_continuous(limits = c(ndvi_quantiles[1], 0.5)) +
# scale_y_continuous(limits = c(0.65, 1.3)) +
theme_classic() 
ALternatively, generate the response curve with the predict function
Code
# Get predictions on log-scale with standard errors
predictions <- predict(ssf_gam,
newdata = pred_data,
type = "link", # log-scale
se.fit = TRUE)
# Extract log RSS and standard errors
log_rss <- predictions$fit
se_log_rss <- predictions$se.fit
# Calculate confidence intervals on log-scale
log_rss_lower <- log_rss - 1.96 * se_log_rss
log_rss_upper <- log_rss + 1.96 * se_log_rss
# Transform to RSS scale
rss <- exp(log_rss)
rss_lower <- exp(log_rss_lower)
rss_upper <- exp(log_rss_upper)
# Create your dataframe
ndvi_coef_df <- data.frame(
ndvi_values = ndvi_values,
log_rss = log_rss,
log_rss_lower = log_rss_lower,
log_rss_upper = log_rss_upper,
rss = rss,
rss_lower = rss_lower,
rss_upper = rss_upper
)
# plot the response curve
ggplot(data = ndvi_coef_df) +
geom_hline(yintercept = 1, linetype = "dashed", colour = "grey") +
geom_ribbon(aes(x = ndvi_values, ymin = rss_lower, ymax = rss_upper),
fill = "grey70", alpha = 0.5) +
geom_line(aes(x = ndvi_values, y = rss),
colour = "black", linewidth = 1) +
geom_rug(data = buffalo_ssf_data %>% filter(case_ == T),
aes(x = ndvi), alpha = 0.25, sides="b") +
scale_x_continuous(breaks = seq(-0.2, 0.8, by = 0.1)) +
scale_y_continuous(limits = c(rss_min, rss_max)) +
labs(x = "NDVI", y = "RSS") +
theme_classic() 
Plot habitat selection
Code

Code
quartz_off_screen
2
Temporal dynamics
Static model for comparison
Code
Call:
coxph(formula = Surv(rep(1, 106678L), case_) ~ ndvi + sl_ + log_sl +
cos_ta + strata(step_id_), data = data, method = "exact")
n= 106678, number of events= 9698
coef exp(coef) se(coef) z Pr(>|z|)
ndvi -9.104e-01 4.024e-01 1.444e-01 -6.302 2.93e-10 ***
sl_ 1.026e-05 1.000e+00 3.192e-05 0.321 0.748
log_sl 3.338e-03 1.003e+00 5.687e-03 0.587 0.557
cos_ta -1.252e-02 9.876e-01 1.503e-02 -0.833 0.405
---
Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
exp(coef) exp(-coef) lower .95 upper .95
ndvi 0.4024 2.4853 0.3032 0.534
sl_ 1.0000 1.0000 0.9999 1.000
log_sl 1.0033 0.9967 0.9922 1.015
cos_ta 0.9876 1.0126 0.9589 1.017
Concordance= 0.501 (se = 0.004 )
Likelihood ratio test= 42.36 on 4 df, p=1e-08
Wald test = 41.2 on 4 df, p=2e-08
Score (logrank) test = 41.21 on 4 df, p=2e-08
[1] 46475.21
Code
ndvi_coef <- ssf_static$model$coefficients["ndvi"]
ndvi_se <- sqrt(diag(ssf_static$model$var))[1] # SE of coefficient
ndvi_static_coef_df <- data.frame(hour = seq(0, 23, length.out = 231),
ndvi_coef = ndvi_coef,
ndvi_se = ndvi_se,
ndvi_lo = ndvi_coef - 1.96 * ndvi_se,
ndvi_hi = ndvi_coef + 1.96 * ndvi_se)Warning in data.frame(hour = seq(0, 23, length.out = 231), ndvi_coef =
ndvi_coef, : row names were found from a short variable and have been discarded
Code
# Plot with ribbon
ggplot(ndvi_static_coef_df, aes(x = hour)) +
geom_hline(yintercept = 0, linetype = "dashed", colour = "black") +
geom_vline(xintercept = 9, linetype = "dashed", colour = "orange") +
geom_vline(xintercept = 17, linetype = "dashed", colour = "skyblue") +
geom_ribbon(aes(ymin = ndvi_lo, ymax = ndvi_hi), alpha = 0.2) +
geom_line(aes(y = ndvi_coef)) +
labs(title = "ndvi", x = "Hour", y = expression(beta[NDVI]), fill = NULL) +
scale_x_continuous(breaks = seq(0, 24, by = 4)) +
scale_y_continuous(limits = c(-5,3.5), breaks = seq(-5, 4, by = 1)) +
theme_classic()
Pull out a response curve from one of the hours
Code
sample_hours <- c(9, 17)
ndvi_coef_df <- purrr::map_dfr(sample_hours, function(sample_hour) {
# extract the NDVI coefficient and its standard error
ndvi_coef <- ndvi_static_coef_df %>% filter(hour == sample_hour) %>% pull(ndvi_coef)
ndvi_coef_se <- ndvi_static_coef_df %>% filter(hour == sample_hour) %>% pull(ndvi_se)
# calculate the RSS for each NDVI value
data.frame(
ndvi_values = ndvi_values,
log_rss = ndvi_values * ndvi_coef,
rss = exp(ndvi_values * ndvi_coef),
se_prediction = abs(ndvi_values) * ndvi_coef_se
) %>% mutate(hour = as.factor(sample_hour), .before = ndvi_values)
})
# plot the response curve
ggplot(data = ndvi_coef_df) +
geom_hline(yintercept = 1, linetype = "dashed", colour = "grey") +
geom_ribbon(aes(x = ndvi_values,
ymin = exp(log_rss - 1.96 * se_prediction),
ymax = exp(log_rss + 1.96 * se_prediction),
fill = hour),
alpha = 0.5) +
geom_line(aes(x = ndvi_values, y = rss, colour = hour),
linewidth = 1) +
geom_rug(data = buffalo_ssf_data %>% filter(case_ == T),
aes(x = ndvi), alpha = 0.25, sides="b") +
scale_colour_manual(values = c("orange", "skyblue")) +
scale_fill_manual(values = c("orange", "skyblue")) +
scale_x_continuous(breaks = seq(-0.2, 0.8, by = 0.1)) +
scale_y_continuous(limits = c(0, 5.5)) +
labs(x = "NDVI", y = "RSS") +
theme_classic() +
theme(legend.position = "none")
Fit harmonic model with quadratic terms
Code
Call:
coxph(formula = Surv(rep(1, 106678L), case_) ~ ndvi + ndvi_sq +
sl_ + log_sl + cos_ta + strata(step_id_), data = data, method = "exact")
n= 106678, number of events= 9698
coef exp(coef) se(coef) z Pr(>|z|)
ndvi 4.462e+00 8.668e+01 4.981e-01 8.958 <2e-16 ***
ndvi_sq -9.008e+00 1.224e-04 8.157e-01 -11.044 <2e-16 ***
sl_ -8.545e-06 1.000e+00 3.201e-05 -0.267 0.790
log_sl 7.233e-03 1.007e+00 5.720e-03 1.265 0.206
cos_ta -1.353e-02 9.866e-01 1.504e-02 -0.899 0.369
---
Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
exp(coef) exp(-coef) lower .95 upper .95
ndvi 8.668e+01 1.154e-02 3.265e+01 2.301e+02
ndvi_sq 1.224e-04 8.169e+03 2.475e-05 6.055e-04
sl_ 1.000e+00 1.000e+00 9.999e-01 1.000e+00
log_sl 1.007e+00 9.928e-01 9.960e-01 1.019e+00
cos_ta 9.866e-01 1.014e+00 9.579e-01 1.016e+00
Concordance= 0.528 (se = 0.003 )
Likelihood ratio test= 175.8 on 5 df, p=<2e-16
Wald test = 147.1 on 5 df, p=<2e-16
Score (logrank) test = 147.2 on 5 df, p=<2e-16
[1] 46343.8
Create table of NDVI and hour values
Code
ndvi_hour_values <- expand.grid(ndvi = ndvi_values,
hour = seq(0, 23, length.out = length(ndvi_values)))
ndvi_lin_coef <- ssf_static_quadratic$model$coefficients["ndvi"]
ndvi_quad_coef <- ssf_static_quadratic$model$coefficients["ndvi_sq"]
ndvi_hour_values <- ndvi_hour_values %>% mutate(
ndvi_lin_coef = ndvi_lin_coef,
ndvi_quad_coef = ndvi_quad_coef,
ssf_quadratic_log_rss = (ndvi * ndvi_lin_coef) + (ndvi^2 * ndvi_quad_coef)
)
max_abs_colour <- max(abs(range(ndvi_hour_values$ssf_quadratic_log_rss)))
# plot the selection surface
ggplot() +
geom_raster(data = ndvi_hour_values,
aes(x = hour, y = ndvi, fill = ssf_quadratic_log_rss)) +
labs(title = "(ndvi + ndvi^2)", x = "Hour", y = "NDVI", fill = "log-RSS") +
geom_contour(data = ndvi_hour_values,
aes(x = hour, y = ndvi, z = ssf_quadratic_log_rss),
colour = "black") +
geom_contour(data = ndvi_hour_values,
aes(x = hour, y = ndvi, z = ssf_quadratic_log_rss),
breaks = 0, colour = "black", linetype = "solid", linewidth = 0.75) +
geom_vline(xintercept = 9, linetype = "dashed", colour = "orange") +
geom_vline(xintercept = 17, linetype = "dashed", colour = "skyblue") +
scale_fill_distiller(palette = "RdBu", type = "div", direction = 1,
limits = c(-max_abs_colour, max_abs_colour)) +
coord_cartesian(expand = FALSE) +
geom_rug(data = buffalo_ssf_data %>% filter(case_ == T),
aes(y = ndvi), alpha = 0.25, length = unit(0.015, "npc"), sides="l") +
scale_y_continuous(breaks = seq(-0.2, 0.8, by = 0.1)) +
theme_bw() 
Easier to get the curves with GAM predict function
Fit model
Code
# fit an ssf gam model with parametric terms. -ends up being the same as above,
# but allows us to use the `predict` function to get predictions on the log-scale with standard errors,
# which is a bit more work to do with the `amt` model object.
ssf_quadratic_gam <- mgcv::gam(cbind(times, step_id_) ~
ndvi +
ndvi_sq +
sl_ + log_sl + cos_ta,
data = buffalo_ssf_data,
family = cox.ph,
weight = case_)
summary(ssf_quadratic_gam)
Family: Cox PH
Link function: identity
Formula:
cbind(times, step_id_) ~ ndvi + ndvi_sq + sl_ + log_sl + cos_ta
Parametric coefficients:
Estimate Std. Error z value Pr(>|z|)
ndvi 4.462e+00 4.981e-01 8.958 <2e-16 ***
ndvi_sq -9.008e+00 8.157e-01 -11.044 <2e-16 ***
sl_ -8.545e-06 3.201e-05 -0.267 0.790
log_sl 7.233e-03 5.720e-03 1.265 0.206
cos_ta -1.353e-02 1.504e-02 -0.899 0.369
---
Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
Deviance explained = -0.0992%
-REML = 23184 Scale est. = 1 n = 106678
[1] 46343.8
Generate the response curve
Code
# Get predictions on log-scale with standard errors
predictions <- predict(ssf_quadratic_gam,
newdata = pred_data,
type = "link", # log-scale
se.fit = TRUE)
# Extract log RSS and standard errors
log_rss <- predictions$fit
se_log_rss <- predictions$se.fit
# Calculate confidence intervals on log-scale
log_rss_lower <- log_rss - 1.96 * se_log_rss
log_rss_upper <- log_rss + 1.96 * se_log_rss
# Transform to RSS scale
rss <- exp(log_rss)
rss_lower <- exp(log_rss_lower)
rss_upper <- exp(log_rss_upper)
# Create your dataframe
ndvi_coef_df <- data.frame(
ndvi_values = ndvi_values,
log_rss = log_rss,
log_rss_lower = log_rss_lower,
log_rss_upper = log_rss_upper,
rss = rss,
rss_lower = rss_lower,
rss_upper = rss_upper
)
ndvi_coef_df <- rbind(data.frame(hour = 9, ndvi_coef_df), data.frame(hour = 17, ndvi_coef_df))
# plot the response curve
ggplot(data = ndvi_coef_df) +
geom_hline(yintercept = 1, linetype = "dashed", colour = "grey") +
geom_ribbon(aes(x = ndvi_values, ymin = rss_lower, ymax = rss_upper, fill = as.factor(hour)),
alpha = 0.5) +
geom_line(aes(x = ndvi_values, y = rss, colour = as.factor(hour)),
linewidth = 1) +
geom_rug(data = buffalo_ssf_data %>% filter(case_ == T),
aes(x = ndvi), alpha = 0.25, sides="b") +
scale_colour_manual(values = c("orange", "skyblue")) +
scale_fill_manual(values = c("orange", "skyblue")) +
scale_x_continuous(breaks = seq(-0.2, 0.8, by = 0.1)) +
scale_y_continuous(limits = c(0, 5.5)) +
labs(x = "NDVI", y = "RSS") +
theme_classic() +
theme(legend.position = "none")
Harmonic regression
Fit model
We’ll use the fit_issf function from amt, with manually specified harmonic terms.
Code
Call:
coxph(formula = Surv(rep(1, 106678L), case_) ~ ndvi + ndvi:sin_1_hour +
ndvi:cos_1_hour + ndvi:sin_2_hour + ndvi:cos_2_hour + sl_ +
log_sl + cos_ta + strata(step_id_), data = data, method = "exact")
n= 106678, number of events= 9698
coef exp(coef) se(coef) z Pr(>|z|)
ndvi -9.409e-01 3.903e-01 1.479e-01 -6.362 1.99e-10 ***
sl_ 6.033e-07 1.000e+00 3.203e-05 0.019 0.984972
log_sl 3.913e-03 1.004e+00 5.695e-03 0.687 0.492023
cos_ta -1.311e-02 9.870e-01 1.504e-02 -0.871 0.383682
ndvi:sin_1_hour 1.136e+00 3.114e+00 2.159e-01 5.262 1.43e-07 ***
ndvi:cos_1_hour -8.659e-01 4.207e-01 2.001e-01 -4.326 1.52e-05 ***
ndvi:sin_2_hour -1.041e+00 3.529e-01 2.080e-01 -5.006 5.54e-07 ***
ndvi:cos_2_hour 7.534e-01 2.124e+00 2.078e-01 3.627 0.000287 ***
---
Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
exp(coef) exp(-coef) lower .95 upper .95
ndvi 0.3903 2.5623 0.2921 0.5215
sl_ 1.0000 1.0000 0.9999 1.0001
log_sl 1.0039 0.9961 0.9928 1.0152
cos_ta 0.9870 1.0132 0.9583 1.0165
ndvi:sin_1_hour 3.1141 0.3211 2.0397 4.7543
ndvi:cos_1_hour 0.4207 2.3772 0.2842 0.6227
ndvi:sin_2_hour 0.3529 2.8333 0.2348 0.5306
ndvi:cos_2_hour 2.1243 0.4707 1.4138 3.1920
Concordance= 0.527 (se = 0.004 )
Likelihood ratio test= 124.5 on 8 df, p=<2e-16
Wald test = 117.9 on 8 df, p=<2e-16
Score (logrank) test = 117.2 on 8 df, p=<2e-16
[1] 46401.03
Pull out the coefficent across time
Code
ndvi_harmonic_coef_reconstruction <- function(ssf_harmonic_model, hour) {
ndvi_coef <- ssf_harmonic_model$model$coefficients["ndvi"]
ndvi_sin_1_coef <- ssf_harmonic_model$model$coefficients["ndvi:sin_1_hour"]
ndvi_cos_1_coef <- ssf_harmonic_model$model$coefficients["ndvi:cos_1_hour"]
ndvi_sin_2_coef <- ssf_harmonic_model$model$coefficients["ndvi:sin_2_hour"]
ndvi_cos_2_coef <- ssf_harmonic_model$model$coefficients["ndvi:cos_2_hour"]
sin_1_hour <- sin(2 * pi * hour / 24)
cos_1_hour <- cos(2 * pi * hour / 24)
sin_2_hour <- sin(4 * pi * hour / 24)
cos_2_hour <- cos(4 * pi * hour / 24)
coef <- as.numeric(ndvi_coef +
sin_1_hour * ndvi_sin_1_coef +
cos_1_hour * ndvi_cos_1_coef +
sin_2_hour * ndvi_sin_2_coef +
cos_2_hour * ndvi_cos_2_coef)
return(coef)
}
ndvi_temporal_coef_df <- data.frame(hour = seq(0, 23, length.out = 100))
ndvi_temporal_coef_df$ndvi_coef <- sapply(ndvi_temporal_coef_df$hour,
ndvi_harmonic_coef_reconstruction,
ssf_harmonic_model = ssf_harmonics)
# plot the response curve
ggplot(data = ndvi_temporal_coef_df) +
geom_hline(yintercept = 0, linetype = "dashed", colour = "grey") +
geom_line(aes(x = hour, y = ndvi_coef),
colour = "black") +
labs(x = "Hour", y = "NDVI coefficient") +
theme_classic()
Calculate confidence intervals across time and plot
Code
sample_hour <- 9 # hour to plot with a vertical dashed line
# Coefficient names we need
coef_names <- c("ndvi",
"ndvi:sin_1_hour","ndvi:cos_1_hour",
"ndvi:sin_2_hour","ndvi:cos_2_hour")
# coefficients and V from the underlying clogit model
beta <- coef(ssf_harmonics$model)[coef_names]
V <- vcov(ssf_harmonics$model)[coef_names, coef_names, drop = FALSE]
# Build design for each hour
ndvi_temporal_ci <- tibble(hour = seq(0, 23, length.out = 231)) %>%
mutate(
sin_1 = sin(2 * pi * hour / 24),
cos_1 = cos(2 * pi * hour / 24),
sin_2 = sin(4 * pi * hour / 24),
cos_2 = cos(4 * pi * hour / 24)
) %>%
rowwise() %>%
mutate(
x = list(c(1, sin_1, cos_1, sin_2, cos_2)),
est = as.numeric(matrix(unlist(x), nrow = 1) %*% beta),
se = sqrt(as.numeric(matrix(unlist(x), nrow = 1) %*% V %*% matrix(unlist(x), ncol = 1))),
lo = est - 1.96 * se,
hi = est + 1.96 * se
) %>%
ungroup() %>%
select(hour, est, se, lo, hi)
# Plot with ribbon
ggplot(ndvi_temporal_ci, aes(x = hour)) +
geom_hline(yintercept = 0, linetype = "dashed", colour = "black") +
geom_vline(xintercept = 9, linetype = "dashed", colour = "orange") +
geom_vline(xintercept = 17, linetype = "dashed", colour = "skyblue") +
geom_ribbon(aes(ymin = lo, ymax = hi), alpha = 0.2) +
geom_line(aes(y = est)) +
labs(title = "harmonics(ndvi)", x = "Hour", y = expression(beta[NDVI]), fill = NULL) +
scale_x_continuous(breaks = seq(0, 24, by = 4)) +
scale_y_continuous(limits = c(-5,3.5), breaks = seq(-5, 4, by = 1)) +
theme_classic()
Pull out a response curve from one of the hours
Code
sample_hours <- c(9, 17)
ndvi_coef_df <- purrr::map_dfr(sample_hours, function(sample_hour) {
# extract the NDVI coefficient and its standard error
ndvi_coef <- ndvi_temporal_ci %>% filter(hour == sample_hour) %>% pull(est)
ndvi_coef_se <- ndvi_temporal_ci %>% filter(hour == sample_hour) %>% pull(se)
# calculate the RSS for each NDVI value
data.frame(
ndvi_values = ndvi_values,
log_rss = ndvi_values * ndvi_coef,
rss = exp(ndvi_values * ndvi_coef),
se_prediction = abs(ndvi_values) * ndvi_coef_se
) %>% mutate(hour = as.factor(sample_hour), .before = ndvi_values)
})
# plot the response curve
ggplot(data = ndvi_coef_df) +
geom_hline(yintercept = 1, linetype = "dashed", colour = "grey") +
geom_ribbon(aes(x = ndvi_values,
ymin = exp(log_rss - 1.96 * se_prediction),
ymax = exp(log_rss + 1.96 * se_prediction),
fill = hour),
alpha = 0.5) +
geom_line(aes(x = ndvi_values, y = rss, colour = hour),
linewidth = 1) +
geom_rug(data = buffalo_ssf_data %>% filter(case_ == T),
aes(x = ndvi), alpha = 0.25, sides="b") +
scale_colour_manual(values = c("orange", "skyblue")) +
scale_fill_manual(values = c("orange", "skyblue")) +
scale_x_continuous(breaks = seq(-0.2, 0.8, by = 0.1)) +
scale_y_continuous(limits = c(0, 5.5)) +
labs(x = "NDVI", y = "RSS") +
theme_classic() +
theme(legend.position = "none")
Manually create the selection surface
Code
ndvi_harmonic_reconstruction <- function(ssf_harmonic_model, ndvi_value, hour) {
ndvi_coef <- ssf_harmonic_model$model$coefficients["ndvi"]
ndvi_sin_1_coef <- ssf_harmonic_model$model$coefficients["ndvi:sin_1_hour"]
ndvi_cos_1_coef <- ssf_harmonic_model$model$coefficients["ndvi:cos_1_hour"]
ndvi_sin_2_coef <- ssf_harmonic_model$model$coefficients["ndvi:sin_2_hour"]
ndvi_cos_2_coef <- ssf_harmonic_model$model$coefficients["ndvi:cos_2_hour"]
sin_1_hour <- sin(2 * pi * hour / 24)
cos_1_hour <- cos(2 * pi * hour / 24)
sin_2_hour <- sin(4 * pi * hour / 24)
cos_2_hour <- cos(4 * pi * hour / 24)
log_rss <- as.numeric(ndvi_value * ndvi_coef +
ndvi_value * sin_1_hour * ndvi_sin_1_coef +
ndvi_value * cos_1_hour * ndvi_cos_1_coef +
ndvi_value * sin_2_hour * ndvi_sin_2_coef +
ndvi_value * cos_2_hour * ndvi_cos_2_coef)
return(log_rss)
}
# test function
ndvi_harmonic_reconstruction(ssf_harmonics,
ndvi_values[1],
hour = 12)[1] 0
Create table of NDVI and hour values
Code
ndvi_hour_values <- expand.grid(ndvi = ndvi_values,
hour = seq(0, 23, length.out = length(ndvi_values)))
ndvi_hour_values$ssf_linear_log_rss <- mapply(ndvi_harmonic_reconstruction,
ssf_harmonic_model = list(ssf_harmonics),
ndvi_value = ndvi_hour_values$ndvi,
hour = ndvi_hour_values$hour)
# plot the response curve
ggplot(data = ndvi_hour_values) +
geom_point(aes(x = hour, y = ndvi, colour = ssf_linear_log_rss)) +
labs(x = "Hour", y = "NDVI", colour = "log RSS") +
scale_colour_viridis_c() +
theme_classic() 
Code
max_abs_colour <- max(abs(range(ndvi_hour_values$ssf_linear_log_rss)))
# plot the selection surface
ggplot() +
geom_raster(data = ndvi_hour_values,
aes(x = hour, y = ndvi, fill = ssf_linear_log_rss)) +
labs(title = "harmonics(ndvi)", x = "Hour", y = "NDVI", fill = "log-RSS") +
geom_contour(data = ndvi_hour_values,
aes(x = hour, y = ndvi, z = ssf_linear_log_rss),
colour = "black") +
geom_contour(data = ndvi_hour_values,
aes(x = hour, y = ndvi, z = ssf_linear_log_rss),
breaks = 0, colour = "black", linetype = "solid", linewidth = 1) +
scale_fill_distiller(palette = "RdBu", type = "div", direction = 1,
limits = c(-max_abs_colour, max_abs_colour)) + # symmetric around 0)
coord_cartesian(expand = FALSE) +
geom_rug(data = buffalo_ssf_data %>% filter(case_ == T),
aes(y = ndvi), alpha = 0.1, length = unit(0.015, "npc"), sides="l") +
scale_y_continuous(breaks = seq(-0.2, 0.8, by = 0.1)) +
theme_bw()
Fit harmonic model with quadratic terms
Code
ssf_harmonics_quadratic <- amt::fit_issf(case_ ~
# linear terms
ndvi +
ndvi:sin_1_hour + ndvi:cos_1_hour +
ndvi:sin_2_hour + ndvi:cos_2_hour +
# quadratic terms
ndvi_sq +
ndvi_sq:sin_1_hour + ndvi_sq:cos_1_hour +
ndvi_sq:sin_2_hour + ndvi_sq:cos_2_hour +
sl_ + log_sl + cos_ta +
strata(step_id_),
data = buffalo_ssf_data)
summary(ssf_harmonics_quadratic)Call:
coxph(formula = Surv(rep(1, 106678L), case_) ~ ndvi + ndvi:sin_1_hour +
ndvi:cos_1_hour + ndvi:sin_2_hour + ndvi:cos_2_hour + ndvi_sq +
ndvi_sq:sin_1_hour + ndvi_sq:cos_1_hour + ndvi_sq:sin_2_hour +
ndvi_sq:cos_2_hour + sl_ + log_sl + cos_ta + strata(step_id_),
data = data, method = "exact")
n= 106678, number of events= 9698
coef exp(coef) se(coef) z Pr(>|z|)
ndvi 4.484e+00 8.860e+01 5.084e-01 8.819 < 2e-16 ***
ndvi_sq -9.156e+00 1.056e-04 8.458e-01 -10.825 < 2e-16 ***
sl_ -1.736e-05 1.000e+00 3.212e-05 -0.540 0.58899
log_sl 7.769e-03 1.008e+00 5.727e-03 1.357 0.17491
cos_ta -1.503e-02 9.851e-01 1.506e-02 -0.999 0.31803
ndvi:sin_1_hour 6.955e-01 2.005e+00 7.242e-01 0.960 0.33687
ndvi:cos_1_hour 1.014e+00 2.757e+00 7.054e-01 1.438 0.15051
ndvi:sin_2_hour -1.992e+00 1.364e-01 7.216e-01 -2.761 0.00577 **
ndvi:cos_2_hour 9.941e-01 2.702e+00 7.059e-01 1.408 0.15905
ndvi_sq:sin_1_hour 7.542e-01 2.126e+00 1.234e+00 0.611 0.54102
ndvi_sq:cos_1_hour -2.856e+00 5.750e-02 1.135e+00 -2.516 0.01188 *
ndvi_sq:sin_2_hour 1.387e+00 4.004e+00 1.187e+00 1.169 0.24238
ndvi_sq:cos_2_hour 1.546e-01 1.167e+00 1.180e+00 0.131 0.89575
---
Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
exp(coef) exp(-coef) lower .95 upper .95
ndvi 8.860e+01 1.129e-02 3.271e+01 2.400e+02
ndvi_sq 1.055e-04 9.474e+03 2.011e-05 5.539e-04
sl_ 1.000e+00 1.000e+00 9.999e-01 1.000e+00
log_sl 1.008e+00 9.923e-01 9.966e-01 1.019e+00
cos_ta 9.851e-01 1.015e+00 9.564e-01 1.015e+00
ndvi:sin_1_hour 2.005e+00 4.988e-01 4.849e-01 8.289e+00
ndvi:cos_1_hour 2.757e+00 3.627e-01 6.918e-01 1.099e+01
ndvi:sin_2_hour 1.364e-01 7.331e+00 3.316e-02 5.611e-01
ndvi:cos_2_hour 2.702e+00 3.701e-01 6.774e-01 1.078e+01
ndvi_sq:sin_1_hour 2.126e+00 4.704e-01 1.894e-01 2.387e+01
ndvi_sq:cos_1_hour 5.750e-02 1.739e+01 6.214e-03 5.322e-01
ndvi_sq:sin_2_hour 4.004e+00 2.498e-01 3.912e-01 4.098e+01
ndvi_sq:cos_2_hour 1.167e+00 8.568e-01 1.156e-01 1.178e+01
Concordance= 0.546 (se = 0.004 )
Likelihood ratio test= 267.6 on 13 df, p=<2e-16
Wald test = 217.7 on 13 df, p=<2e-16
Score (logrank) test = 218.6 on 13 df, p=<2e-16
[1] 46267.92
Manually create the selection surface
Code
ndvi_harmonic_reconstruction <- function(ssf_harmonic_model, ndvi_value, hour) {
# linear terms
ndvi_coef <- ssf_harmonic_model$model$coefficients["ndvi"]
ndvi_sin_1_coef <- ssf_harmonic_model$model$coefficients["ndvi:sin_1_hour"]
ndvi_cos_1_coef <- ssf_harmonic_model$model$coefficients["ndvi:cos_1_hour"]
ndvi_sin_2_coef <- ssf_harmonic_model$model$coefficients["ndvi:sin_2_hour"]
ndvi_cos_2_coef <- ssf_harmonic_model$model$coefficients["ndvi:cos_2_hour"]
# quadratic terms
ndvi_sq_coef <- ssf_harmonic_model$model$coefficients["ndvi_sq"]
ndvi_sq_sin_1_coef <- ssf_harmonic_model$model$coefficients["ndvi_sq:sin_1_hour"]
ndvi_sq_cos_1_coef <- ssf_harmonic_model$model$coefficients["ndvi_sq:cos_1_hour"]
ndvi_sq_sin_2_coef <- ssf_harmonic_model$model$coefficients["ndvi_sq:sin_2_hour"]
ndvi_sq_cos_2_coef <- ssf_harmonic_model$model$coefficients["ndvi_sq:cos_2_hour"]
sin_1_hour <- sin(2 * pi * hour / 24)
cos_1_hour <- cos(2 * pi * hour / 24)
sin_2_hour <- sin(4 * pi * hour / 24)
cos_2_hour <- cos(4 * pi * hour / 24)
linear_part <- ndvi_value * ndvi_coef +
ndvi_value * sin_1_hour * ndvi_sin_1_coef +
ndvi_value * cos_1_hour * ndvi_cos_1_coef +
ndvi_value * sin_2_hour * ndvi_sin_2_coef +
ndvi_value * cos_2_hour * ndvi_cos_2_coef
ndvi_sq_value <- ndvi_value^2
quadratic_part <- ndvi_sq_value * ndvi_sq_coef +
ndvi_sq_value * sin_1_hour * ndvi_sq_sin_1_coef +
ndvi_sq_value * cos_1_hour * ndvi_sq_cos_1_coef +
ndvi_sq_value * sin_2_hour * ndvi_sq_sin_2_coef +
ndvi_sq_value * cos_2_hour * ndvi_sq_cos_2_coef
return(as.numeric(linear_part + quadratic_part))
}
# test function
ndvi_harmonic_reconstruction(ssf_harmonics_quadratic, ndvi_values[1], hour = 12)[1] 0
Create table of NDVI and hour values
Code
ndvi_hour_values <- expand.grid(ndvi = ndvi_values,
hour = seq(0, 23, length.out = length(ndvi_values)))
ndvi_hour_values$ssf_quadratic_log_rss <- mapply(ndvi_harmonic_reconstruction,
ssf_harmonic_model = list(ssf_harmonics_quadratic),
ndvi_value = ndvi_hour_values$ndvi,
hour = ndvi_hour_values$hour)
# plot the response curve
ggplot(data = ndvi_hour_values) +
geom_point(aes(x = hour, y = ndvi, colour = ssf_quadratic_log_rss)) +
labs(x = "Hour", y = "NDVI", colour = "log RSS") +
scale_colour_viridis_c() +
scale_y_continuous(breaks = seq(-0.2, 0.8, by = 0.1)) +
theme_classic() 
Code
max_abs_colour <- max(abs(range(ndvi_hour_values$ssf_quadratic_log_rss)))
# plot the selection surface
ggplot() +
geom_raster(data = ndvi_hour_values,
aes(x = hour, y = ndvi, fill = ssf_quadratic_log_rss)) +
labs(title = "harmonics(ndvi + ndvi^2)", x = "Hour", y = "NDVI", fill = "log-RSS") +
geom_contour(data = ndvi_hour_values,
aes(x = hour, y = ndvi, z = ssf_quadratic_log_rss),
colour = "black") +
geom_contour(data = ndvi_hour_values,
aes(x = hour, y = ndvi, z = ssf_quadratic_log_rss),
breaks = 0, colour = "black", linetype = "solid", linewidth = 1) +
geom_vline(xintercept = 9, linetype = "dashed", colour = "orange") +
geom_vline(xintercept = 17, linetype = "dashed", colour = "skyblue") +
scale_fill_distiller(palette = "RdBu", type = "div", direction = 1,
limits = c(-max_abs_colour, max_abs_colour)) +
coord_cartesian(expand = FALSE) +
geom_rug(data = buffalo_ssf_data %>% filter(case_ == T),
aes(y = ndvi), alpha = 0.25, length = unit(0.015, "npc"), sides="l") +
scale_y_continuous(breaks = seq(-0.2, 0.8, by = 0.1)) +
theme_bw() 
Pull out a response curve from one of the hours
Code
sample_hours <- c(9, 17)
curve_df <- purrr::map_dfr(sample_hours, function(sample_hour) {
# Harmonics at sample hour
s1 <- sin(2 * pi * sample_hour / 24)
c1 <- cos(2 * pi * sample_hour / 24)
s2 <- sin(4 * pi * sample_hour / 24)
c2 <- cos(4 * pi * sample_hour / 24)
mod <- ssf_harmonics_quadratic$model
betas <- coef(mod)
V <- vcov(mod)
cn <- names(betas)
# For each NDVI value, build the contrast vector g such that g'β = log-RSS
purrr::map_dfr(ndvi_values, function(x) {
g <- setNames(numeric(length(betas)), cn)
# linear term: x * (β_ndvi + harmonics)
g["ndvi"] <- x
g["ndvi:sin_1_hour"] <- x * s1
g["ndvi:cos_1_hour"] <- x * c1
g["ndvi:sin_2_hour"] <- x * s2
g["ndvi:cos_2_hour"] <- x * c2
# quadratic term: x² * (β_ndvi_sq + harmonics)
g["ndvi_sq"] <- x^2
g["ndvi_sq:sin_1_hour"] <- x^2 * s1
g["ndvi_sq:cos_1_hour"] <- x^2 * c1
g["ndvi_sq:sin_2_hour"] <- x^2 * s2
g["ndvi_sq:cos_2_hour"] <- x^2 * c2
log_rss <- as.numeric(g %*% betas)
se_log <- sqrt(as.numeric(t(g) %*% V %*% g))
data.frame(
hour = sample_hour,
ndvi = x,
log_rss = log_rss,
rss = exp(log_rss),
# delta method on the RSS scale: SE_rss ≈ exp(log_rss) * SE_log
rss_lwr = exp(log_rss - 1.96 * se_log),
rss_upr = exp(log_rss + 1.96 * se_log)
)
})
})
# plot the response curve
ggplot(curve_df, aes(x = ndvi)) +
geom_hline(yintercept = 1, linetype = "dashed", colour = "grey") +
geom_ribbon(aes(ymin = rss_lwr, ymax = rss_upr, fill = as.factor(hour)),
alpha = 0.5) +
geom_line(aes(y = rss, colour = as.factor(hour)), linewidth = 1) +
geom_rug(data = buffalo_ssf_data %>% filter(case_ == T),
aes(x = ndvi), alpha = 0.25, sides="b") +
scale_colour_manual(values = c("orange", "skyblue")) +
scale_fill_manual(values = c("orange", "skyblue")) +
scale_x_continuous(breaks = seq(-0.2, 0.8, by = 0.1)) +
scale_y_continuous(limits = c(0, 5.5)) +
labs(x = "NDVI", y = "RSS") +
theme_classic() +
theme(legend.position = "none")
Temporal dynamics with smooth terms
Code
# fit an ssf gam model
# We set the knots explicitly at 0 and 24. Without this, mgcv places the cyclic
# endpoints at the range of the data (0 and 23), which wraps hour 23 onto hour 0
# and squeezes an hour out of the daily cycle.
ssf_gam_temporal <- mgcv::gam(cbind(times, step_id_) ~
s(hour, by = ndvi, bs = "cc") + #, k = 5
sl_ + log_sl + cos_ta,
knots = list(hour = c(0, 24)),
data = buffalo_ssf_data,
family = cox.ph,
weight = case_)
summary(ssf_gam_temporal)
Family: Cox PH
Link function: identity
Formula:
cbind(times, step_id_) ~ s(hour, by = ndvi, bs = "cc") + sl_ +
log_sl + cos_ta
Parametric coefficients:
Estimate Std. Error z value Pr(>|z|)
sl_ -1.933e-06 3.209e-05 -0.060 0.952
log_sl 3.548e-03 5.697e-03 0.623 0.533
cos_ta -1.385e-02 1.505e-02 -0.920 0.358
Approximate significance of smooth terms:
edf Ref.df Chi.sq p-value
s(hour):ndvi 8.104 8.78 149.1 <2e-16 ***
---
Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
Deviance explained = -3.48%
-REML = 23205 Scale est. = 1 n = 106678
[1] 46363.75
Code
gratia::draw(ssf_gam_temporal, rug = FALSE) +
geom_hline(yintercept = 0, linetype = "dashed", colour = "grey60") +
geom_vline(xintercept = 9, linetype = "dashed", colour = "orange") +
geom_vline(xintercept = 17, linetype = "dashed", colour = "skyblue") +
labs() +
scale_x_continuous(breaks = seq(0, 24, by = 4)) +
scale_y_continuous(limits = c(-5,3.5), breaks = seq(-5, 4, by = 1)) +
labs(title = "s(hour, by = ndvi)",
subtitle = NULL,
caption = NULL,
x = "Hour",
y = expression(beta[NDVI]), fill = NULL) +
theme_classic()
Pull out a response curve from one of the hours
Code
pred_data_hour9 <- pred_data
pred_data_hour9$hour <- 9
pred_data_hour17 <- pred_data
pred_data_hour17$hour <- 17
pred_data_hour9_17 <- bind_rows(pred_data_hour9, pred_data_hour17)
# Get predictions on log-scale with standard errors
predictions <- predict(ssf_gam_temporal,
newdata = pred_data_hour9_17,
type = "link", # log-scale
se.fit = TRUE)
pred_data_hour9_17 <- pred_data_hour9_17 %>%
mutate(
# Extract log RSS and standard errors
log_rss = predictions$fit,
se_log_rss = predictions$se.fit,
# Calculate confidence intervals on log-scale
log_rss_lower = log_rss - 1.96 * se_log_rss,
log_rss_upper = log_rss + 1.96 * se_log_rss,
# Transform to RSS scale
rss = exp(log_rss),
rss_lower = exp(log_rss_lower),
rss_upper = exp(log_rss_upper)
)
# plot the response curve
ggplot(data = pred_data_hour9_17) +
geom_hline(yintercept = 1, linetype = "dashed", colour = "grey") +
geom_ribbon(aes(x = ndvi,
ymin = rss_lower, ymax = rss_upper,
fill = as.factor(hour)),
alpha = 0.5) +
geom_line(aes(x = ndvi, y = rss, colour = as.factor(hour)),
linewidth = 1) +
geom_rug(data = buffalo_ssf_data %>% filter(case_ == T),
aes(x = ndvi), alpha = 0.25, sides="b") +
scale_colour_manual(values = c("orange", "skyblue")) +
scale_fill_manual(values = c("orange", "skyblue")) +
scale_x_continuous(breaks = seq(-0.2, 0.8, by = 0.1)) +
scale_y_continuous(limits = c(0, 5.5)) +
labs(x = "NDVI", y = "RSS") +
theme_classic() +
theme(legend.position = "none")Warning: Removed 12 rows containing missing values or values outside the scale range
(`geom_ribbon()`).

Code
Warning: Removed 12 rows containing missing values or values outside the scale range
(`geom_ribbon()`).
GAM selection surface
Code
Code
# Get predictions on log-scale with standard errors
predictions <- predict(ssf_gam_temporal,
newdata = ndvi_hour_values,
type = "link", # log-scale
se.fit = TRUE)
# Extract log RSS and standard errors
gam_log_rss <- predictions$fit
gam_se_log_rss <- predictions$se.fit
# Calculate confidence intervals on log-scale
gam_log_rss_lower <- gam_log_rss - 1.96 * gam_se_log_rss
gam_log_rss_upper <- gam_log_rss + 1.96 * gam_se_log_rss
# Transform to RSS scale
rss <- exp(gam_log_rss)
rss_lower <- exp(gam_log_rss_lower)
rss_upper <- exp(gam_log_rss_upper)
ndvi_hour_values <- ndvi_hour_values %>% mutate(
gam_log_rss = gam_log_rss,
gam_rss = exp(gam_log_rss),
gam_log_rss_lower = as.numeric(gam_log_rss_lower),
gam_log_rss_upper = as.numeric(gam_log_rss_upper)
)
max_abs_colour <- max(abs(range(ndvi_hour_values$gam_log_rss)))
# plot the selection surface
ggplot() +
geom_raster(data = ndvi_hour_values,
aes(x = hour, y = ndvi, fill = gam_log_rss)) +
labs(title = "s(hour, by = ndvi)", x = "Hour", y = "NDVI", fill = "log-RSS") +
geom_contour(data = ndvi_hour_values,
aes(x = hour, y = ndvi, z = gam_log_rss), colour = "black") +
scale_fill_distiller(palette = "RdBu", type = "div", direction = 1,
limits = c(-max_abs_colour, max_abs_colour)) +
coord_cartesian(expand = FALSE) +
geom_rug(data = buffalo_ssf_data %>% filter(case_ == T),
aes(y = ndvi), alpha = 0.25, length = unit(0.015, "npc"), sides="l") +
scale_y_continuous(breaks = seq(-0.2, 0.8, by = 0.1)) +
theme_bw() 
Fit 2D GAM smooth
Code
# fit an ssf gam model
# As above, set the cyclic knots for the hour margin explicitly at 0 and 24
ssf_gam2d_temporal2 <- mgcv::gam(cbind(times, step_id_) ~
te(hour, ndvi, bs = c("cc", "cr")) + #, k = c(5, 5)
sl_ + log_sl + cos_ta,
knots = list(hour = c(0, 24)),
data = buffalo_ssf_data,
family = cox.ph,
weight = case_)
summary(ssf_gam2d_temporal2)
Family: Cox PH
Link function: identity
Formula:
cbind(times, step_id_) ~ te(hour, ndvi, bs = c("cc", "cr")) +
sl_ + log_sl + cos_ta
Parametric coefficients:
Estimate Std. Error z value Pr(>|z|)
sl_ -1.353e-05 3.207e-05 -0.422 0.6730
log_sl 1.096e-02 5.751e-03 1.907 0.0566 .
cos_ta -1.299e-02 1.506e-02 -0.862 0.3886
---
Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
Approximate significance of smooth terms:
edf Ref.df Chi.sq p-value
te(hour,ndvi) 11.72 13.37 351 <2e-16 ***
---
Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
Deviance explained = -0.0651%
-REML = 23099 Scale est. = 1 n = 106678
[1] 46149.4
Plot the smooth selection surface

Code
gam_smooths <- smooth_estimates(ssf_gam2d_temporal2)
filtered_gam_smooths <- gam_smooths %>% filter(ndvi > ndvi_quantiles[1] & ndvi < ndvi_quantiles[2])
gratia::draw(filtered_gam_smooths) +
coord_cartesian(expand = FALSE) +
scale_y_continuous(breaks = seq(-0.2, 0.8, by = 0.1)) +
labs(x = "Hour", y = "NDVI") +
theme_bw()
Code
ndvi_hour_values <- expand.grid(ndvi = ndvi_values,
hour = seq(0, 23, length.out = length(ndvi_values)))
ndvi_hour_values <- ndvi_hour_values %>% mutate(
sl_ = 0,
log_sl = 0,
cos_ta = 0
)
predictions <- predict(ssf_gam2d_temporal2,
newdata = ndvi_hour_values,
type = "link", # log-scale
se.fit = TRUE)
# Extract log RSS and standard errors
gam_log_rss <- predictions$fit
gam_se_log_rss <- predictions$se.fit
# Calculate confidence intervals on log-scale
gam_log_rss_lower <- gam_log_rss - 1.96 * gam_se_log_rss
gam_log_rss_upper <- gam_log_rss + 1.96 * gam_se_log_rss
ndvi_hour_values <- ndvi_hour_values %>% mutate(
gam_log_rss = gam_log_rss,
gam_rss = exp(gam_log_rss),
gam_log_rss_lower = as.numeric(gam_log_rss_lower),
gam_log_rss_upper = as.numeric(gam_log_rss_upper)
)
# plot the selection surface
ggplot(data = ndvi_hour_values) +
geom_point(aes(x = hour, y = ndvi, colour = gam_log_rss)) +
labs(x = "Hour", y = "NDVI", colour = "log RSS") +
scale_colour_viridis_c() +
scale_y_continuous(breaks = seq(-0.2, 0.8, by = 0.1)) +
theme_classic() 
Code

Code
max_abs_colour <- max(abs(range(ndvi_hour_values$gam_log_rss)))
# plot the selection surface
ggplot() +
geom_raster(data = ndvi_hour_values,
aes(x = hour, y = ndvi, fill = gam_log_rss)) +
labs(title = "te(hour, ndvi)", x = "Hour", y = "NDVI", fill = "log-RSS") +
geom_contour(data = ndvi_hour_values,
aes(x = hour, y = ndvi, z = gam_log_rss), colour = "black") +
geom_vline(xintercept = 9, linetype = "dashed", colour = "orange") +
geom_vline(xintercept = 17, linetype = "dashed", colour = "skyblue") +
scale_fill_distiller(palette = "RdBu", type = "div", direction = 1,
limits = c(-max_abs_colour, max_abs_colour)) +
coord_cartesian(expand = FALSE) +
geom_rug(data = buffalo_ssf_data %>% filter(case_ == T),
aes(y = ndvi), alpha = 0.25, length = unit(0.015, "npc"), sides="l") +
scale_y_continuous(breaks = seq(-0.2, 0.8, by = 0.1)) +
theme_bw() 
Pull out a response curve from one of the hours
Code
# Get predictions on log-scale with standard errors
predictions <- predict(ssf_gam2d_temporal2,
newdata = pred_data_hour9_17,
type = "link", # log-scale
se.fit = TRUE)
pred_data_hour9_17 <- pred_data_hour9_17 %>%
mutate(
# Extract log RSS and standard errors
log_rss = predictions$fit,
se_log_rss = predictions$se.fit,
# Calculate confidence intervals on log-scale
log_rss_lower = log_rss - 1.96 * se_log_rss,
log_rss_upper = log_rss + 1.96 * se_log_rss,
# Transform to RSS scale
rss = exp(log_rss),
rss_lower = exp(log_rss_lower),
rss_upper = exp(log_rss_upper)
)
# plot the response curve
ggplot(data = pred_data_hour9_17) +
geom_hline(yintercept = 1, linetype = "dashed", colour = "grey") +
geom_ribbon(aes(x = ndvi,
ymin = rss_lower, ymax = rss_upper,
fill = as.factor(hour)),
alpha = 0.5) +
geom_line(aes(x = ndvi, y = rss, colour = as.factor(hour)),
linewidth = 1) +
geom_rug(data = buffalo_ssf_data %>% filter(case_ == T),
aes(x = ndvi), alpha = 0.25, sides="b") +
scale_colour_manual(values = c("orange", "skyblue")) +
scale_fill_manual(values = c("orange", "skyblue")) +
scale_x_continuous(breaks = seq(-0.2, 0.8, by = 0.1)) +
scale_y_continuous(limits = c(0, 5.5)) +
labs(x = "NDVI", y = "RSS") +
theme_classic() +
theme(legend.position = "none")