Reproducing the Schrankogel grid
Source:vignettes/reproducing-schrankogel.Rmd
reproducing-schrankogel.RmdThe measurement timesift exists to make was first run on
894 alpine plots on Schrankogel, in the central Austrian Alps: three
hydrological years of hourly soil temperature, one logger per plot,
predicting presence and absence of 101 vascular plant species. The
record was read at seven grains, from every hour to a single value per
year, by three architectures and by logistic models on 188 hand-built
summaries of the same loggers. Skill peaked at the weekly average and
fell by 0.08 toward yearly, and the coldest and warmest day of a grain
carried more than its mean.
This vignette says which settings of this package correspond to which part of that grid, and points at the script that runs it.
What the reproduction needs
The soil-temperature series and the species records are the Zenodo deposit of Chytrý et al. (doi:10.5281/zenodo.17047026, CC BY 4.0). Four files of it are read:
| file | what it carries |
|---|---|
spe_wide.csv |
presence and absence, 894 plots by 252 species |
logger_data.csv |
23,515,776 hourly readings, 26,304 per plot |
seasons.csv |
the season each date of the record belongs to |
output_temperature_variables_scaled.csv |
the 188 hand-aggregated temperature summaries |
The snow arm of the paper reads a daily snow-cover series that is an in-house product of the study group and is not in the deposit, so it is not reproduced here. Everything else is.
The contract
Species in at least 25 plots, then five aggregate taxa removed. That is inherited from the published baseline rather than chosen, and it is what fixes the species set at 101.
spe <- read.csv(file.path(deposit, "spe_wide.csv"), check.names = FALSE)
rownames(spe) <- as.character(spe$logger_ID)
counts <- colSums(spe[setdiff(names(spe), "logger_ID")])
keep <- setdiff(names(counts)[counts >= 25],
c("Alchemilla vulgaris agg.", "Taraxacum sp.", "Festuca halleri agg.",
"Euphrasia sp.", "Phleum alpinum agg."))
y <- as.matrix(spe[, keep, drop = FALSE])
dim(y)
#> [1] 894 101The split and the cells
The fold map is an artifact rather than an algorithm: it is built
once and read by everything that scores, so no arm can regenerate its
own. The study wrote one to folds.csv, the package ships it
beside the reproduction script, and reading that file back reproduces
its splits exactly.
f <- read.csv(system.file("reproduce", "folds.csv", package = "timesift"))
folds <- setNames(as.integer(f$fold), as.character(f$logger_ID))
cells <- scorable_cells(y, folds)
sum(cells$scorable)
#> [1] 10031003 of 1010 cells, 99.3 percent, and all 101 species keep at least
one scorable fold. Building a map here instead gives a different
partition of the same design, since fold_map() draws on R’s
random stream and rsample drew on a different one:
folds <- fold_map(y, v = 10, seed = 1, strata = 5)The seven grains
Six of the seven grains are named ones. The seventh is not: the deposit cuts its seasons at the equinoxes and the solstices rather than on the first of a month, and labels every date of the record accordingly, which gives 13 bins over the three years rather than 12. Reading that file back as a binning function is how the season rung is the deposit’s season.
astronomical_seasons <- function(path) {
labels <- read.csv(path)
key <- paste(labels$season, format(as.Date(labels$day), "%Y"))
edges <- sort(as.POSIXct(paste0(labels$day[!duplicated(key)], " 00:00:00"), tz = "UTC"))
function(when) edges[findInterval(as.numeric(when), as.numeric(edges))]
}
binning <- list(native = "native", halfday = "halfday", day = "day", week = "week",
month = "month",
season = astronomical_seasons(file.path(deposit, "seasons.csv")),
year = "year")Each gives the bin count the paper reports:
| grain | bins |
|---|---|
| hour | 26304 |
| halfday | 2192 |
| day | 1096 |
| week | 157 |
| month | 36 |
| season | 13 |
| year | 3 |
The script asserts every one of them before fitting anything.
The two readings of a grain
The resolution ladder is fitted on the grain mean, the only summary defined at all seven grains. The models themselves are reported on the grain’s coldest day, its mean and its warmest day, which is defined from the weekly grain up because it reduces to whole days first.
mean_reading <- grain_matrix(readings, logger_ID, date, temp, grain = "week")
reported <- grain_matrix(readings, logger_ID, date, temp, grain = "week",
stats = c("cold_day", "mean", "warm_day"))The paper’s other three schemes are the same call with different
stats: "min" and "max" alone,
c("min", "mean", "max") for the grain’s own extremes, and
c("mean_daily_min", "mean", "mean_daily_max") for the
average daily minimum and maximum.
The channels the encoders are given
An encoder that ends in global pooling discards when a thermal event happened, so the position of each bin in the year is supplied as two channels. They are the time index of each bin, not a summary of the readings, so they add no hand-built thermal feature to the network arm.
input <- bind_channels(reported, calendar_channels(reported))The arms
The aggregated-feature arms read the deposit’s 188 variables. A
feature table has no time axis, so it enters through
feature_matrix() and is then an arm of the same ladder,
scored on the same cells by the same rule.
agg <- read.csv(file.path(deposit, "output_temperature_variables_scaled.csv"), check.names = FALSE)
rownames(agg) <- as.character(agg$logger_ID)
features <- feature_matrix(as.matrix(agg[rownames(y), setdiff(names(agg), "logger_ID")]),
label = "aggregates")
baseline <- grain_ladder(
features, y,
list(elastic_net = elasticnet(alpha = 0.5, n_inner = 5, squares = TRUE),
stepwise = stepwise(max_terms = 3, degree = 2)),
folds = folds)Both redo their predictor selection inside every fold, on the training plots only, which is the footing the encoders are fitted on. The encoders are the three the paper reports, at the settings it reports them at, which are the defaults here:
cnn() is four blocks of a one-dimensional convolution of
kernel width 7, batch normalisation, a rectified linear activation and
max pooling by two, at 16, 32, 64 and 128 channels, then global average
pooling and dropout at 0.3. rescnn() is a convolutional
stem and four stages of 32, 64, 128 and 256 channels holding two dilated
residual blocks each, dilations 1, 2, 4 and 8, with squeeze-excitation
gates, pooling average and maximum together. mlp() flattens
the channels through two hidden layers of 512 and 256 units.
How all three are trained is train_control(). Its
defaults are AdamW at a learning rate of 1e-3 with weight decay 1e-4,
cosine annealing over 60 epochs, early stopping after ten epochs without
an improvement on an inner validation split of 15 percent of the fitting
plots, and a per-species positive-class weight capped at 50. The
encoders of the study read batches of 32 plots, which the grid asks for;
the default is 64.
The eleven members of the paper’s ensemble are the same architectures at three widths, three kernel widths and three seeds, trained with weight averaging over the tail of the epoch budget:
members <- c(
lapply(list(c(16L, 32L, 64L, 128L), c(32L, 64L, 128L, 256L), c(16L, 32L, 64L)),
function(ch) cnn(channels = ch, swa = TRUE)),
lapply(c(5L, 7L, 9L), function(k) cnn(kernel = k, swa = TRUE)),
lapply(c(1L, 2L, 3L), function(s) cnn(swa = TRUE, seed = s)),
lapply(c(1L, 2L), function(s) rescnn(swa = TRUE, seed = s)))
names(members) <- sprintf("m%02d", seq_along(members))The grid
The ladder is one call. Each grain is built once, the calendar channels are joined to it, and every encoder is fitted at every rung on the one fold map and the one mask.
ladder_input <- timesift_set(lapply(binning, function(w) {
x <- grain_matrix(readings, logger_ID, date, temp, grain = w)
bind_channels(x, calendar_channels(x))
}))
grid <- grain_ladder(ladder_input, y, encoders, folds = folds)
summary(grid)The ensemble is one further arm on the same rungs. Each member runs as an arm of its own, and their held-out predictions are averaged: a member’s out-of-fold prediction on a fold is its held-out prediction there, so averaging the eleven and choosing a threshold afterwards is the set scored as one model rather than as a vote between eleven decisions.
lad <- grain_ladder(ladder_input, y, members, folds = folds)
oof <- attr(lad, "predictions")
arms <- paste("week", names(members), sep = "|")
stack <- ensemble_fit(oof[arms], y, attr(lad, "cells"), folds, spec = ensemble("mean"))
combined <- ensemble_combine(stack, oof[arms])combined is then scored on the same cells and by the
same metric as every other arm, which is what the driver appends to the
grid under the name ensemble.
The procedure the demonstration reports
The grid above is every candidate scored on the same folds, which is
the measurement. The demonstration is one step above it: inside each
outer training set the study chose one of 33 candidates on five inner
folds by the area under the curve, refitted the winner on the whole
training set and predicted the held-out plots once.
select_grain() is that procedure, so reproducing the
demonstration is this call rather than a description of it.
A candidate is a window and a summary. The mean is defined at all seven windows, the reading-level extremes and their three-channel pair from the half-daily window up, and the two day-level pairs from the weekly window up, since those reduce to whole days first.
summaries <- list(mean = "mean", min = "min", max = "max",
minmeanmax = c("min", "mean", "max"),
dailyextreme = c("mean_daily_min", "mean", "mean_daily_max"),
extremeday = c("cold_day", "mean", "warm_day"))
candidates <- timesift_set(unlist(lapply(names(summaries), function(s) {
windows <- if (s == "mean") names(binning)
else if (s %in% c("dailyextreme", "extremeday")) c("week", "month", "season", "year")
else setdiff(names(binning), "native")
setNames(lapply(windows, function(w) {
x <- grain_matrix(readings, logger_ID, date, temp, grain = binning[[w]], stats = summaries[[s]])
bind_channels(x, calendar_channels(x))
}), paste(windows, s, sep = "."))
}), recursive = FALSE))
length(candidates)
#> [1] 33The inner partition is an artifact too. The study wrote one inner
fold per outer fold and training plot, and the package ships it beside
the reproduction script; select_grain() takes
inner as a function of the training response, so reading
that file back is what makes the inner splits the study’s own rather
than a fresh deal.
inner <- read.csv(system.file("reproduce", "inner_folds.csv", package = "timesift"))
inner$logger_ID <- as.character(inner$logger_ID)
inner_split <- function(y_train) {
# The fit is handed its training response and nothing else, so which outer fold it sits in is
# read off the plots it does not hold.
left_out <- unique(folds[setdiff(names(folds), rownames(y_train))])
rows <- inner[inner$outer_fold == left_out, ]
setNames(as.integer(rows$inner_fold), rows$logger_ID)[rownames(y_train)]
}
selection <- select_grain(candidates, y, cnn(epochs = 60, batch_size = 32),
folds = folds, inner = inner_split, metric = "roc_auc")
selection$selectedThat is ten outer folds by five inner folds by 33 candidates, plus
one refit per outer fold: 1,660 encoder fits, which is an overnight run
on a graphics processor and is why the stage is not on by default. What
comes back is the score of the procedure, selection included, rather
than the score of the window it happened to choose, and
selection$inner holds every candidate’s inner score in
every outer fold, which is where a choice made on a hair’s difference is
visible.
The arm that choice is read against is the penalised model on the same weekly three-channel series, which is the study’s own comparison:
weekly <- grain_matrix(readings, logger_ID, date, temp, grain = "week",
stats = c("cold_day", "mean", "warm_day"))
series <- grain_ladder(timesift_set(list(series = weekly)), y,
list(elastic_net = elasticnet(alpha = 0.5, n_inner = 5, squares = TRUE)),
folds = folds)157 weeks by three channels is 471 numbers per plot, and with their squares the penalised fit reads 942 columns.
The environment a fitted encoder carries
A representation is arithmetic on the record and reproduces exactly.
A fitted network does not: its weights depend on the library that
trained it and on the device it trained on. The reproduction script
therefore records both beside every number it writes, in
run.meta:
torch::torch_config()$libtorch_version
torch::cuda_is_available()The study’s grid ran on one CUDA device; a run on a processor gives the same shapes and not the same weights, which is why the network cells are compared to the seed noise the paper measures rather than to the digit.
The grain contrast
The table of every grain against its architecture’s best is a mixed
model on the per-cell scores,
score ~ grain + (1 | species) + (1 | fold) by restricted
maximum likelihood, with Dunnett’s many-to-one procedure against the
reference:
grain_contrasts(grid, learner = "cnn")The design is balanced at 101 species by ten folds by seven grains,
so each grain is compared within a species and within a fold.
Benjamini-Hochberg across all eighteen comparisons is
p.adjust(out$p_value, "BH") over the three architectures’
tables stacked.
Running it
The driver is installed with the package:
system.file("reproduce", "schrankogel.R", package = "timesift")Rscript schrankogel.R <deposit_dir> <out_dir> \
--stages=contract,representation,baseline,networks,contrasts,inflation \
--grains=day,week,month,season,year --learners=cnn,rescnn,mlp,ensemble --folds=folds.csv
It writes one CSV per stage and asserts the input at every step: the plot count, the species count, the rarest retained species, the cell count, and the bin count of every grain. A stage runs over all 894 plots and all 101 species or it does not run, so there is no setting that quietly shrinks what a number was computed on.
The coarse grains are affordable on a processor. The hourly rung is 26,304 steps per plot and wants a graphics processor, as it had in the study; the whole grid there was three architectures by seven grains by ten folds, refitted under four seeds.
What the reproduction is checked against
The paper’s own numbers, arm by arm. The verified ones, in the order the script produces them:
| quantity | reported | reproduced |
|---|---|---|
| plots, species, rarest species | 894, 101, 26 | 894, 101, 26 |
| scorable cells | 1003 of 1010 | 1003 of 1010 |
| bins per grain | 26304, 2192, 1096, 157, 36, 13, 3 | same |
| numbers per plot, weekly three-channel | 471 | 471 |
| inflation of a level whose truth is 0.60 | +0.110 | +0.110 |
| elastic net on the 188 aggregates | 0.687 | 0.686 |
| stepwise AIC on the 188 aggregates | 0.662 | not rerun |
| convolutional network, weekly, coldest and warmest day | 0.712 | not rerun |
| the same network on the full hourly record | 0.658 | not rerun |
A model fitted twice on different hardware does not give the same weights, so the network cells are reproduced to the seed noise the paper measures, a standard deviation of 0.0017 across seeds at the median cell. The representation, the fold map, the mask and the aggregated-feature arms carry no such noise and reproduce exactly.