Skip to contents

distsamp stops at segments. This vignette picks up where vignette("segmenting-narwc-data") ends and shows how to hand its output to Distance and dsm, because the handoff is where the units and the segment-to-detection keying are easy to get wrong.

The Distance and dsm chunks below are not evaluated — neither package is a dependency of distsamp, so this vignette builds without them. The code is real and runnable once you install them.

The data is mock. narwc-example.csv is a synthetic survey generated by data-raw/make-fixture.R; no real NARWC records are distributed with this package or were used to develop it. It has two days, seven tracks, and a handful of sightings — nowhere near enough to fit a detection function or a density surface to. Every number below illustrates the plumbing, and none of it is a result.

What you start with

path <- system.file("extdata", "narwc-example.csv", package = "distsamp")
dat <- read_narwc(path)
#> `read_narwc()` renamed 2 columns:
#>   LAT_DD  -> LATITUDE
#>   LONG_DD -> LONGITUDE
#> All matched an exact entry in the alias table; `narwc_column_mapping()` returns this, and `quiet = TRUE` silences it.
segs <- segment_survey(dat, seg_length = 5, seed = 1, species = "RIWH")
segs
#> <distsamp_segments>
#>   segments:  20
#>   tracks:    7
#>   total effort: 92.23 km
#>   segment length: median 4.44 km, range 2.22-7.78 km
#>   target length: 5 km   seed: 1
#>   species:   RIWH
#>   detections: 6 (6 with a perpendicular distance, m)

Two tables matter here.

segments — the sampling units. One row each, with effort and a location:

head(segs$segments[, c("seg_id", "seg_eff", "mid_lat", "mid_lon", "wt_beaufort")])
#> # A tibble: 6 × 5
#>   seg_id         seg_eff mid_lat mid_lon wt_beaufort
#>   <chr>            <dbl>   <dbl>   <dbl>       <dbl>
#> 1 2024-04-01_2_1    7.78    43.0   -69             2
#> 2 2024-04-01_2_2    5.56    43.1   -69             2
#> 3 2024-04-01_2_3    3.33    43.1   -69             2
#> 4 2024-04-01_2_4    5.56    43.2   -69             2
#> 5 2024-04-01_4_1    4.44    43.3   -69.1           2
#> 6 2024-04-01_4_2    3.33    43.3   -69.1           2

detections — one row per sighting, with perpendicular distance:

segs$detections[, c("seg_id", "SPECCODE", "size", "distance", "side")]
#> # A tibble: 6 × 5
#>   seg_id         SPECCODE  size distance side 
#>   <chr>          <chr>    <dbl>    <dbl> <chr>
#> 1 2024-04-01_2_1 RIWH         1    229   right
#> 2 2024-04-01_2_2 RIWH         2    132.  left 
#> 3 2024-04-01_2_2 RIWH         1    397.  right
#> 4 2024-04-01_4_2 RIWH         4     61.4 left 
#> 5 2024-04-01_4_2 RIWH         1    855.  right
#> 6 2024-04-02_1_3 RIWH         4    160.  left

seg_id is the key between them. Every detection belongs to exactly one segment, and every segment carries the effort that detection was made on.

The units trap

This is the single most common way to get a density estimate wrong by three orders of magnitude, so it is worth being explicit.

c(effort = "km", distance = segs$settings$distance_units)
#>   effort distance 
#>     "km"      "m"

seg_eff is in kilometres, because that is what effort accumulation produces. distance defaults to metres, because ALT is in metres (handbook 8.A.1) and perpendicular distances for aerial cetacean surveys are conventionally reported that way.

You have two options. Either work in kilometres throughout:

segs_km <- segment_survey(dat, seg_length = 5, seed = 1, species = "RIWH",
                          distance_units = "km")
segs_km$detections$distance
#> [1] 0.22900000 0.13221321 0.39663963 0.06136037 0.85463963 0.16034753

or keep metres and tell Distance about it via convert_units. For effort in km, distance in m, and area in km², that conversion factor is:

# (m -> km) * 2 sides * (km effort)
convert_units <- 0.001

Whichever you pick, state it in your methods.

Look at the distances first

Before fitting anything, look at the distribution you are about to fit a curve to.

plot(segs, what = "distances")

You are looking for a shoulder near zero and a tail that falls away. What you find changes what you fit:

  • A dip in the first bin may not be a sampling artefact. An aircraft cannot see the water directly beneath it — for the Skymaster, handbook 8.A.31 says CETAP-era distances “are actually measured from about 1/8 mile to either side of the survey line”. That argues for ds(left = ...), not for assuming the animals were not there.
  • A peak away from zero is what the gamma key function describes. It is not available in Distance::ds(), which offers hn, hr, and unif only; it lives in mrds::ddf(key = "gamma"). Note that a gamma key and a left truncation model the same phenomenon, so using both counts the blind spot twice.
  • A long flat tail is the case for truncating harder.
  • A distance the aircraft could not possibly have seen is not a wide detection, it is a broken one — a sighting matched to the wrong segment, or a coordinate with a lost sign. The axis stops at the 99th percentile so one such value cannot push every real distance into the first bin, and the subtitle reports how many lie beyond and how large the largest is. If it says the largest is too far to be a detection, fix that before fitting anything: ?sighting_distances says where distances come from and which source produced it.

None of this tells you anything about g(0). Animals that were submerged when the aircraft passed leave no trace in this plot, and a perfectly smooth curve is equally consistent with half of them having been invisible.

Fitting a detection function

Distance::ds() wants a data frame with a distance column, optionally with size and detection covariates. segs$detections is already that shape.

detection_data() assembles that table for you:

flat <- detection_data(
  segs,
  area = 5811,                    # square km - yours to supply
  covariates = "wt_beaufort"
)
#> `detection_data()`: 20 segments, 6 detections (m).
#>   g(0) = 1 is assumed; see `?detection_data`.
head(flat)
#> # A tibble: 6 × 11
#>   Region.Label  Area Sample.Label Effort object distance distbegin distend  size
#>   <chr>        <dbl> <chr>         <dbl>  <int>    <dbl>     <dbl>   <dbl> <dbl>
#> 1 all           5811 2024-04-01_…   7.78      1     229         NA      NA     1
#> 2 all           5811 2024-04-01_…   5.56      2     132.        NA      NA     2
#> 3 all           5811 2024-04-01_…   5.56      3     397.        NA      NA     1
#> 4 all           5811 2024-04-01_…   3.33     NA      NA         NA      NA    NA
#> 5 all           5811 2024-04-01_…   5.56     NA      NA         NA      NA    NA
#> 6 all           5811 2024-04-01_…   4.44     NA      NA         NA      NA    NA
#> # ℹ 2 more variables: distance_source <chr>, wt_beaufort <dbl>
library(Distance)

df <- ds(flat, key = "hn", truncation = "10%", formula = ~ wt_beaufort)

summary(df)
plot(df)
gof_ds(df)

Four things worth pausing on.

area is required and has no default. It scales abundance directly. The scripts this package was rewritten from had 5,811 km² written into the middle of a function, which is exactly how a study-area figure outlives the study it belonged to.

Segments with no detections are in the table, carrying a missing distance.

sum(is.na(flat$distance))
#> [1] 16

That is not padding. It is how a flatfile records effort that produced no sightings, and dropping those rows would remove the denominator and inflate density.

Use wt_beaufort, not mean_beaufort. The weighted version weights each record by the distance it covers, so a sea state held over 3 km counts for more than one recorded over 200 m. That is the quantity detection actually depends on.

Circling detections are excluded by default, and the exclusion is reported rather than silent. A position logged off the track is not measured at the moment of detection. They still count towards the segment’s abundance, so this does not touch segs$sightings — excluding a detection from the detection function while counting the animal towards density is a normal combination, and the two are separate arguments:

nrow(detection_data(segs, area = 5811, include_circling = TRUE))
#> `detection_data()`: 20 segments, 6 detections (m).
#>   g(0) = 1 is assumed; see `?detection_data`.
#> [1] 22

Check which source each distance came from. A methods section has to state this, and point and interval distances cannot be fitted together:

table(flat$distance_source, useNA = "no")
#> 
#> angle 
#>     6

Truncation discards the tail, but not the effort. detection_data() takes a truncation argument that drops detections beyond it and leaves their segments in place with effort intact — effort searched is effort searched whether or not anything was seen within the truncation distance. Pass the same value to ds(), or you will fit the model to one set of detections and scale abundance by another.

nrow(detection_data(segs, area = 5811, truncation = 500))
#> `detection_data()`: 20 segments, 5 detections (m).
#>   dropped 1 beyond the truncation distance; their segments keep their effort
#>   g(0) = 1 is assumed; see `?detection_data`.
#> [1] 21

Comparing several models

Fitting a set and picking the best is standard, and there are two traps in it.

AIC only compares models fitted to the same data. Changing the truncation changes which detections are in the likelihood, so AICs either side of that are not comparable. Vary key function, adjustments, and covariates within a fixed truncation; treat truncation as a separate comparison altogether.

Binned and unbinned fits cannot share a table either. STRIP-derived distances are intervals and are fitted binned; angle- and position-derived distances are points. A multi-year model set splits along that line whether you want it to or not.

And rank on more than AIC. What propagates into abundance is the effective strip half-width, and two models within 2 AIC of each other can give materially different values of it — so read p‾\bar{p} and its CV alongside the AIC, with a goodness-of-fit statistic.

The package repository’s docs/07-fitting-architecture.md works through this, along with where an automated model-selection sweep should live and what can and cannot be done about g(0).

Building the dsm inputs

dsm wants two data frames with specific column names. The mapping from distsamp output is mechanical:

library(dsm)

segment.data <- data.frame(
  Sample.Label = segs$segments$seg_id,
  Effort       = segs$segments$seg_eff,   # km
  X            = segs$segments$mid_lon,   # project these first, see below
  Y            = segs$segments$mid_lat,
  beaufort     = segs$segments$wt_beaufort
)

observation.data <- data.frame(
  object       = seq_len(nrow(df_data)),
  Sample.Label = df_data$seg_id,
  size         = df_data$size,
  distance     = df_data$distance
)

object is just a detection identifier; dsm uses it to link a detection to its row in the fitted detection function, so it must match the row order of the data you passed to ds().

Project the coordinates first

mid_lon/mid_lat are decimal degrees. Fitting a smooth of longitude and latitude in degrees distorts distances badly at the latitudes NARWC surveys cover — a degree of longitude is about 81 km at 43°N against 111 km for a degree of latitude. Project to something equal-area before modelling:

pts <- segments_as_sf(segs, "midpoints")

# Albers equal-area, Gulf of Maine
gom_aea <- paste(
  "+proj=aea +lat_1=40 +lat_2=46 +lat_0=43 +lon_0=-69",
  "+x_0=0 +y_0=0 +datum=WGS84 +units=km +no_defs"
)
projected <- sf::st_transform(pts, gom_aea)

xy <- sf::st_coordinates(projected)
head(xy)
#>              X         Y
#> [1,]  0.000000  3.893573
#> [2,]  0.000000 10.568327
#> [3,]  0.000000 15.018201
#> [4,]  0.000000 19.468104
#> [5,] -8.107262 30.041539
#> [6,] -8.102634 33.935268

Then use those X/Y in segment.data.

Fitting the model

mod <- dsm(
  count ~ s(X, Y, bs = "ts") + s(depth, bs = "ts"),
  ddf.obj = df,
  segment.data = segment.data,
  observation.data = observation.data,
  family = tw(),          # Tweedie handles the zero inflation
  method = "REML"
)

summary(mod)
plot(mod)
gam.check(mod)

A Tweedie or negative binomial family is usually right: most segments hold zero animals, and the non-zero ones are overdispersed. Miller et al. (2013) discuss the choice.

Covariates are your job

distsamp gives you a location per segment and nothing else. Environmental covariates — depth, sea surface temperature, fronts — have to be sampled at each segment midpoint yourself. segments_as_sf(segs, "midpoints") is the intended handoff:

pts <- segments_as_sf(segs, "midpoints")

# e.g. with terra
depth <- terra::extract(bathymetry_raster, terra::vect(pts))
segment.data$depth <- depth[, 2]

Two cautions.

Sample at the midpoint, which is what Becker et al. (2019) do — covariates “derived based on the segment’s geographical mid-point”, with SST and depth standard deviations taken over a 3 × 3-pixel box around it. segment_midpoints() places that point half the segment’s effort along the track rather than at the centroid of its records, so it is on the trackline by construction.

And match covariates in time as well as space. A segment flown in April needs April’s SST, not the annual mean. segs$segments$DATE carries the survey date for exactly this.

Reporting

Record the seed. Without it the segmentation is not reproducible, and neither is anything downstream of it.

str(segs$settings)
#> List of 11
#>  $ seg_length       : num 5
#>  $ species          : chr "RIWH"
#>  $ seed             : num 1
#>  $ seg_tol_frac     : num 0.5
#>  $ min_track_km     : num 1
#>  $ min_segment_km   : num 1
#>  $ dist_method      : chr "haversine"
#>  $ circling         : chr "same_species"
#>  $ circling_distance: chr "with_group"
#>  $ distance_units   : chr "m"
#>  $ distance_sources : chr [1:3] "angle" "exact" "strip"

Suggested methods wording is in docs/06-references.md in the package repository.

References

Becker, E.A., Forney, K.A., Ferguson, M.C., Foley, D.G., Smith, R.C., Barlow, J. and Redfern, J.V. (2010) Comparing California Current cetacean-habitat models developed using in situ and remotely sensed sea surface temperature data. Marine Ecology Progress Series 413:163-183. https://doi.org/10.3354/meps08696

Becker, E.A., Forney, K.A., Redfern, J.V., Barlow, J., Jacox, M.G., Roberts, J.J. and Palacios, D.M. (2019) Predicting cetacean abundance and distribution in a changing climate. Diversity and Distributions 25:626-643. https://doi.org/10.1111/ddi.12867

Hedley, S.L. and Buckland, S.T. (2004) Spatial models for line transect sampling. Journal of Agricultural, Biological, and Environmental Statistics 9:181-199. https://doi.org/10.1198/1085711043578

Miller, D.L., Burt, M.L., Rexstad, E.A. and Thomas, L. (2013) Spatial models for distance sampling data: recent developments and future directions. Methods in Ecology and Evolution 4:1001-1010. https://doi.org/10.1111/2041-210X.12105

Miller, D.L., Rexstad, E., Thomas, L., Marshall, L. and Laake, J.L. (2019) Distance sampling in R. Journal of Statistical Software 89(1):1-28. https://doi.org/10.18637/jss.v089.i01