A classifier such as BirdNET (Kahl
et al., 2021) lists detections: for each audio file, the time windows
where it found a species, with a confidence score. An occupancy model
needs something else: one row per sampling occasion, including the
occasions where nothing was found, and a separate record of which
detections a person checked. This article goes from the first to the
second with fp_read_birdnet(),
fp_read_validation(), and fp_aggregate(), and
fits the model to the result.
The example data are made up so that the article runs anywhere, but they have the layout of real files: BirdNET selection tables in the Raven format, and the clip-by-clip verdicts written by the ‘sheepdog’ package (https://provetelab.org/sheepdog/), which cuts clips from BirdNET detections for review and is installed from the same R-universe as crywolf (https://diogoprov.r-universe.dev).
Two designs use checked data, and they need different files.
fp_read_validation() and the
validation argument of fp_aggregate()
produce.verified table of fp_data(). In
‘sheepdog’, clips of whole recordings
(sampling_mode = "random_recordings") are of this kind and
must not be passed to fp_aggregate().Either way, the clips must be chosen without regard to the
classifier’s score. Clips picked because they have the highest scores
contain fewer false positives than the average detection and make the
false-positive rate look lower than it is. In ‘sheepdog’, draw them at
random among the detections
(sampling_mode = "random_detections").
We start from a simulated dataset with known structure: 60 sites, each recorded on 8 nights, with observation confirmation on about 40% of the occasions.
library(crywolf)
sim <- simulate_fp(nsite = 60, nocc = 8, frac_confirmed = 0.4,
frac_verified = 0, seed = 1)
truth <- sim$data$auto
truth$file <- sprintf("S%02d_202401%02d_200000.wav", truth$site, truth$occasion)
head(truth[, c("site", "occasion", "file", "y", "n_true", "n_false")])
#> site occasion file y n_true n_false
#> 1 1 1 S01_20240101_200000.wav 0 NA NA
#> 2 2 1 S02_20240101_200000.wav 0 0 0
#> 3 3 1 S03_20240101_200000.wav 0 NA NA
#> 4 4 1 S04_20240101_200000.wav 1 NA NA
#> 5 5 1 S05_20240101_200000.wav 0 NA NA
#> 6 6 1 S06_20240101_200000.wav 0 NA NAFrom it we write what the field data would look like. Each occasion with a detection gets a BirdNET row with a high confidence, and some occasions without one get a row below the threshold of 0.5 that we will use. One selection table per site goes into a folder.
set.seed(2)
dir_tables <- file.path(tempdir(), "birdnet")
dir.create(dir_tables, showWarnings = FALSE)
low <- truth$y == 0 & stats::runif(nrow(truth)) < 0.2
det <- truth[truth$y == 1 | low, ]
det$confidence <- ifelse(det$y == 1, stats::runif(nrow(det), 0.5, 1),
stats::runif(nrow(det), 0.1, 0.5))
det$start <- 3 * sample(0:199, nrow(det), replace = TRUE)
purrr::walk(split(det, det$site), function(d) {
tab <- data.frame(
Selection = seq_len(nrow(d)), View = "Spectrogram 1", Channel = 1,
`Begin Time (s)` = d$start, `End Time (s)` = d$start + 3,
`Begin Path` = file.path("/audio", d$file), `File Offset (s)` = d$start,
`Species Code` = "compau", `Common Name` = "Common Pauraque",
Confidence = round(d$confidence, 4), check.names = FALSE)
utils::write.table(tab, file.path(dir_tables, sprintf("S%02d.BirdNET.selection.table.txt",
d$site[1])),
sep = "\t", row.names = FALSE, quote = FALSE)
})
list.files(dir_tables)[1:3]
#> [1] "S01.BirdNET.selection.table.txt" "S02.BirdNET.selection.table.txt"
#> [3] "S03.BirdNET.selection.table.txt"The verdicts follow the layout of
validation_by_clip.csv: one row per reviewed clip. A
confirmed occasion contributes one positive clip if the species was
there and one negative clip if the classifier also fired on something
else.
conf <- truth[!is.na(truth$n_true) & truth$y == 1, ]
clips <- rbind(
data.frame(conf[conf$n_true > 0, c("site", "file")], verdict = "positive"),
data.frame(conf[conf$n_false > 0, c("site", "file")], verdict = "negative"))
clips <- merge(clips, det[, c("file", "start", "confidence")], by = "file")
validation <- data.frame(
site = sprintf("S%02d", clips$site), species_code = "compau",
source_audio = file.path("/audio", clips$file), clip_start = clips$start,
clip_end = clips$start + 3, confidence = clips$confidence,
sampling_mode = "random_detections", occasion_id = sub("\\.wav$", "", clips$file),
verdict = clips$verdict)
f_validation <- file.path(tempdir(), "validation_by_clip.csv")
utils::write.csv(validation, f_validation, row.names = FALSE)
table(validation$verdict)
#>
#> negative positive
#> 12 56fp_read_birdnet() reads every selection table in the
folder, standardizes the column names (Begin Time (s)
becomes begin_time), and adds the name of the audio file
and, when the file name carries a YYYYMMDD_HHMMSS stamp,
the time of each detection.
detections <- fp_read_birdnet(dir_tables)
head(detections[, c("file", "start", "species_code", "confidence", "time")])
#> file start species_code confidence time
#> 1 S01_20240101_200000.wav 561 compau 0.3142 2024-01-01 20:09:21
#> 2 S01_20240103_200000.wav 330 compau 0.1231 2024-01-03 20:05:30
#> 3 S01_20240107_200000.wav 519 compau 0.1950 2024-01-07 20:08:39
#> 4 S02_20240104_200000.wav 189 compau 0.5073 2024-01-04 20:03:09
#> 5 S02_20240108_200000.wav 522 compau 0.3466 2024-01-08 20:08:42
#> 6 S03_20240103_200000.wav 237 compau 0.8389 2024-01-03 20:03:57fp_read_validation() reads the verdicts, in the format
of ‘sheepdog’ 2.0.0 or in the Portuguese format of the sampler up to
version 1.2.0 (validacao_por_clipe.csv), and returns
TRUE for a positive clip, FALSE for a negative
one, and NA for a clip not reviewed.
verdicts <- fp_read_validation(f_validation)
head(verdicts)
#> site species_code file start end confidence verdict
#> 1 S02 compau S02_20240104_200000.wav 189 192 0.5073348 FALSE
#> 2 S03 compau S03_20240103_200000.wav 237 240 0.8389030 TRUE
#> 3 S03 compau S03_20240103_200000.wav 237 240 0.8389030 FALSE
#> 4 S03 compau S03_20240107_200000.wav 246 249 0.9667769 TRUE
#> 5 S05 compau S05_20240102_200000.wav 144 147 0.8415215 TRUE
#> 6 S05 compau S05_20240104_200000.wav 162 165 0.7387293 TRUE
#> occasion_id
#> 1 S02_20240104_200000
#> 2 S03_20240103_200000
#> 3 S03_20240103_200000
#> 4 S03_20240107_200000
#> 5 S05_20240102_200000
#> 6 S05_20240104_200000When ‘sheepdog’ wrote the whole survey with
export_occupancy_data(), fp_read_sheepdog()
reads that folder instead: every occasion, the predictions, the reviewed
clips with their sampling design, and the recordings annotated by ear,
already in the forms used below. It drops clips drawn with
sampling_mode = "top", which were chosen for their high
scores, and refuses schemas it does not know.
The classifier lists detections only, so the occasions come from a table of all recordings: here, one per site and night, with the site covariate.
fp_aggregate() marks an occasion as a detection when it
has a detection at or above the threshold, and turns the verdicts into
the counts n_true and n_false of the occasions
that were checked. Verdicts on clips below the threshold are dropped,
since those clips are not detections at this threshold.
auto <- fp_aggregate(occasions, detections, threshold = 0.5,
validation = verdicts)
head(auto)
#> site file n_detections y score n_true n_false
#> 1 1 S01_20240101_200000.wav 0 0 0.3142 NA NA
#> 2 2 S02_20240101_200000.wav 0 0 0.0000 NA NA
#> 3 3 S03_20240101_200000.wav 0 0 0.0000 NA NA
#> 4 4 S04_20240101_200000.wav 1 1 0.8801 NA NA
#> 5 5 S05_20240101_200000.wav 0 0 0.0000 NA NA
#> 6 6 S06_20240101_200000.wav 0 0 0.0000 NA NA
c(detections = sum(auto$y), checked = sum(!is.na(auto$n_true)))
#> detections checked
#> 160 60
all(auto$y == truth$y)
#> [1] TRUEThe occasions are the same as in the simulated truth.
fp_aggregate() warns when an occasion has more detections
at or above the threshold than reviewed clips, because the confirmation
design assumes that every detection of a checked occasion was
checked.
The result goes to fp_data() as the automated table,
with the names of the confirmation counts.
d <- fp_data(auto, sites, confirmed = c("n_true", "n_false"))
d
#> <fp_data>
#> sites: 60
#> automated occasions: 480 (naive occupancy 0.65)
#> verified sites: 0 (0 occasions; verified detections at 0 sites)
#> confirmed occasions: 60 (56 with a true observation, at 24 sites)
#> sites known occupied: 24
#> site covariates: x, w
#> observation covariates: file, n_detections, scorem <- fp_occu(d, occ = ~ x, fp = ~ 1, random_recorder = FALSE,
threads_per_chain = 1, seed = 1,
refresh = 0, show_messages = FALSE)
summary(m)The simulation used an occupancy slope of -0.8 and a false-positive
rate of 0.05; both lie inside the 95% intervals. The rest of the
analysis, from convergence checks to predictions, is the same as in
vignette("crywolf").
Real data differ from this example in a few ways that matter.
by = c("file", "minute").species to
fp_aggregate() to keep one.vignette("threshold") shows how to check
that.Chambert, T., Miller, D. A. W., & Nichols, J. D. (2015). Modeling false positive detections in species occurrence data under different study designs. Ecology, 96, 332–339. https://doi.org/10.1890/14-1507.1
Kahl, S., Wood, C. M., Eibl, M., & Klinck, H. (2021). BirdNET: A deep learning solution for avian diversity monitoring. Ecological Informatics, 61, 101236. https://doi.org/10.1016/j.ecoinf.2021.101236
Miller, D. A., Nichols, J. D., McClintock, B. T., Grant, E. H. C., Bailey, L. L., & Weir, L. A. (2011). Improving occupancy estimation when two types of observational error occur: non-detection and species misidentification. Ecology, 92, 1422–1428. https://doi.org/10.1890/10-1396.1