Submission. Knit your Rmd file into HTML and submit it via Brightspace. Include a link to your git repository (GitHub or git.cs.dal.ca) as a comment on your submission.
This lab uses a few different/new libraries to handle signal processing and to cover all 3 of our analysis paradigms for signal data: a time-domain model, a frequency-domain model, and a state-space model.
install.packages(c(
"signal", # digital filters (Butterworth band-pass / notch)
"forecast", # ARIMA time-series models (auto.arima)
"depmixS4" # Gaussian (continuous) hidden Markov models (state-space)
))
If the block below runs without error you are ready to go. Instead of
attaching like normal (using library()) we are just
checking if signal and forecast are both
installed (using requiredNamespace). This is these
libraries contain functions (like filter()) that collide
with each other and dplyr. Instead we will call both
explicitly in the code with the signal:: /
forecast:: prefix where needed.
library(tidyverse)
library(here)
library(fs)
library(depmixS4)
library(yardstick)
library(scales)
requireNamespace('signal')
requireNamespace('forecast')The electrocardiogram (ECG) records the small voltages produced as the heart’s chambers depolarise to contract (systole) and repolarise to relax (diastole).
A single normal beat has a very distinctive/standard shape - the P wave (atrial contracting), the QRS complex (ventricular contracting), and the T wave (ventricular relaxing). The time between the main R spikes in successive QRS peaks is the R-R interval.
When a heart doesn’t beat in a nice regular rhythm we call this a cardiac arrhythmia. Atrial fibrillation (AF) is the most common sustained arrhythmia and a major cause of stroke and heart failure. As AF is often asymptomatic, a lot of work has gone into identifying it accurately from ECGs. Typically, AF is recognised in an ECG by the disappearance of the P wave (as the atria are contracting chaotically rather than in an organised wave) and irregular spacing and timing of the QRS complex (as the ventricles contract at irregular intervals).
In this lab we will build a different AF detector for each signal data analysis paradigm and then compare them against one another:
forecast::auto.arima, the autoregressive / moving-average /
integrated family from the lecture),We use a small prepared subset of the MIT-BIH Atrial Fibrillation Database (the same public PhysioNet database Babaeizadeh et al. validated on): two-channel Holter ECGs sampled at 250 Hz with expert beat and rhythm annotations. Reading the raw PhysioNet files needs some slightly awkward platform-specific libraries, so I’ve simplified things a little (including extract R-R intervals) and converted to a more usable format for you. We’ve also simplified away the “temporal dependence” complexity of how you cut-up time-series data to avoid misleading yourself.
As in the earlier labs, we download a prepared archive once and cache it.
data_dir <- here("data")
cache_dir <- fs::path(data_dir, "cache")
fs::dir_create(cache_dir)
data_url <- "https://maguire-lab.github.io/health_data_science_research_2026/static_files/practicals/ecg_afib.zip"
zip_path <- fs::path(cache_dir, "ecg_afib.zip")
ecg_root <- fs::path(data_dir, "ecg_afib")if (!fs::dir_exists(ecg_root)) {
if (!fs::file_exists(zip_path)) {
download.file(data_url, zip_path, mode = "wb", quiet = TRUE)
}
unzip(zip_path, exdir = data_dir)
}
fs::dir_ls(ecg_root)## /home/fin/Documents/teaching/2025_2026/health_data_science_research_2026/data/ecg_afib/rr_intervals.csv
## /home/fin/Documents/teaching/2025_2026/health_data_science_research_2026/data/ecg_afib/waveforms.csv
The archive contains 2 tables:
waveforms.csv - a handful of 10-second raw ECG snippets
(2500 samples each at 250 Hz), labelled AF or
Normal where record indicates an individual
patient.rr_intervals.csv - sequences of consecutive R-R
intervals split into fixed-length windows (128 beats
each, roughly two minutes) and labelled by rhythm.waveforms <- read_csv(fs::path(ecg_root, "waveforms.csv")) |>
mutate(rhythm = factor(rhythm, levels = c("AF", "Normal")))
rr <- read_csv(fs::path(ecg_root, "rr_intervals.csv")) |>
mutate(rhythm = factor(rhythm, levels = c("AF", "Normal")))
glimpse(waveforms)## Rows: 30,000
## Columns: 5
## $ record <chr> "04015", "04015", "04015", "04015", "04015", "04015", "04015",…
## $ segment <chr> "04015_Normal", "04015_Normal", "04015_Normal", "04015_Normal"…
## $ rhythm <fct> Normal, Normal, Normal, Normal, Normal, Normal, Normal, Normal…
## $ time <dbl> 0.000, 0.004, 0.008, 0.012, 0.016, 0.020, 0.024, 0.028, 0.032,…
## $ ecg <dbl> -0.115, -0.090, -0.100, -0.130, -0.160, -0.215, -0.230, -0.285…
## Rows: 55,560
## Columns: 5
## $ record <chr> "04015", "04015", "04015", "04015", "04015", "04015", "04015", …
## $ window <dbl> 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, …
## $ rhythm <fct> Normal, Normal, Normal, Normal, Normal, Normal, Normal, Normal,…
## $ beat <dbl> 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 1…
## $ rr <dbl> 0.856, 0.840, 0.836, 0.820, 0.828, 0.824, 0.780, 0.844, 0.824, …
Q1 (2 marks): Write a short data dictionary for
waveforms and rr with your best guess
explanation of the values and format of these datasets.
Before any cleaning or exploration we split the data, so that nothing we decide later can be influenced by the test set.
We split on whole records and keep the train and
test data in completely separate objects (rr_train /
rr_test and waveforms_train /
waveforms_test).
set.seed(GLOBAL_SEED)
all_records <- rr |> distinct(record) |> pull(record)
test_records <- sample(all_records, size = round(0.3 * length(all_records)))
rr_train <- rr |> filter(!record %in% test_records)
rr_test <- rr |> filter(record %in% test_records)
waveforms_train <- waveforms |> filter(!record %in% test_records)
waveforms_test <- waveforms |> filter(record %in% test_records)
bind_rows(
rr_train |> distinct(record, window, rhythm) |> mutate(split = "train"),
rr_test |> distinct(record, window, rhythm) |> mutate(split = "test")
) |>
count(split, rhythm)Q2 (2 marks): Why do we split on the record instead of the segment? What could the models end up learning to recognise instead of AF if we split by segment?
Raw ECG is rarely analysis-ready. Two classic problems are baseline wander (slow drift from breathing and electrode movement, below the heartbeat frequency) and mains interference(a sharp hum at the power-line frequency - 60 Hz as these are US recordings - table of national frequencies).
The lecture covered two standard fixes: a band-pass
filter keeps only frequencies likely to be connected to heart
bioelectical signals, and a notch (band-stop) filter
removes narrow bands of interference like the mains
frequency. The the actual way these filters are implemented
varies a lot (with different nuances on how they flatten/amplify
things). In this lab we implement the two filters using something called
a Butterworth
filter from the signal and apply them using
filtfilt() (which filters forwards and backwards so
features are not shifted in time).
fs <- 250
# keep things in conceivable "normal heart rate" range (0.5 Hz to 40 Hz) to smooth out high and low frequency noise
bandpass <- signal::butter(2, c(0.5, 40) / (fs / 2), type = "pass")
# cut out 59-61Hz signal from the electrical grid
notch <- signal::butter(2, c(59, 61) / (fs / 2), type = "stop")
example_wave <- waveforms_train |>
filter(rhythm == "Normal") |>
filter(segment == first(segment))
cleaned <- example_wave |>
mutate(
bp_filtered = signal::filtfilt(bandpass, ecg),
filtered = signal::filtfilt(notch, bp_filtered)
)
cleaned |>
filter(time <= 4) |>
dplyr::select(time, Raw = ecg, Filtered = filtered) |> # dplyr::select - MASS also exports select()
pivot_longer(c(Raw, Filtered), names_to = "stage", values_to = "ecg") |>
mutate(stage = factor(stage, levels = c("Raw", "Filtered"))) |>
ggplot(aes(time, ecg)) +
geom_line(linewidth = 0.3) +
facet_wrap(~stage, ncol = 1) +
labs(x = "Time (s)", y = "ECG (mV)",
title = "Before and after band-pass + notch filtering")As we can see, this cleans things up a little (although isn’t perfect) so now let’s apply the same two filters to every waveform in both the training and test sets.
We keep the original trace as ecg_raw and overwrite
ecg with the cleaned signal.
clean_waveforms <- function(df) {
df |>
group_by(record, segment) |>
arrange(time, .by_group = TRUE) |>
mutate(
ecg_raw = ecg,
ecg = signal::filtfilt(notch, signal::filtfilt(bandpass, ecg))
) |>
ungroup()
}
waveforms_train <- clean_waveforms(waveforms_train)
waveforms_test <- clean_waveforms(waveforms_test)
glimpse(waveforms_train)## Rows: 20,000
## Columns: 6
## $ record <chr> "04746", "04746", "04746", "04746", "04746", "04746", "04746",…
## $ segment <chr> "04746_AF", "04746_AF", "04746_AF", "04746_AF", "04746_AF", "0…
## $ rhythm <fct> AF, AF, AF, AF, AF, AF, AF, AF, AF, AF, AF, AF, AF, AF, AF, AF…
## $ time <dbl> 0.000, 0.004, 0.008, 0.012, 0.016, 0.020, 0.024, 0.028, 0.032,…
## $ ecg <dbl> 0.07331563, 0.12585480, 0.15472892, 0.16593037, 0.17014741, 0.…
## $ ecg_raw <dbl> 0.190, 0.195, 0.200, 0.220, 0.225, 0.225, 0.240, 0.280, 0.280,…
Q3 (1 marks): As part of these filters we are dividing the sampling rate in half. What does fs/2 represent in terms of the signal we’ve captured?
Remember, we only explore the training set!
rr_train |>
distinct(record, window, rhythm) |>
count(rhythm) |>
ggplot(aes(rhythm, n, fill = rhythm)) +
geom_col(show.legend = FALSE) +
scale_fill_manual(values = c("AF" = "firebrick", "Normal" = "grey60")) +
labs(x = NULL, y = "Number of windows (train)", title = "Window class balance")A normal and an AF waveform snippet side by side - note the slightly more regular spacing in the Normal vs AF signal; the p-wave feature is less obviously different in this case:
waveforms_train |>
filter(time <= 4) |>
group_by(rhythm) |>
filter(segment == first(segment)) |>
ungroup() |>
ggplot(aes(time, ecg)) +
geom_line(linewidth = 0.3) +
facet_wrap(~rhythm, ncol = 1) +
labs(x = "Time (s)", y = "ECG (mV)", title = "Normal vs AF (training examples)")A few standard summaries of each window’s R-R series:
window_features <- rr_train |>
group_by(record, window, rhythm) |>
summarise(
mean_rr = mean(rr), # average interval (1 / mean_rr = mean rate)
sdnn = sd(rr), # overall variability
.groups = "drop"
)
window_features |>
pivot_longer(c(mean_rr, sdnn),
names_to = "feature", values_to = "value") |>
ggplot(aes(rhythm, value, fill = rhythm)) +
geom_boxplot(show.legend = FALSE) +
facet_wrap(~feature, scales = "free_y") +
scale_fill_manual(values = c("AF" = "firebrick", "Normal" = "grey60")) +
labs(x = NULL, y = NULL, title = "HRV features by rhythm (train)")Q4 (2 marks): Explain what this plot tells you about the R-R intervals in Normal vs AF samples.
To detect AF with a time-domain approach we are going to work out how predictable is the next interval given the preceding one(s) across each window?. A regular sinus rhythm is highly predictable, so our model should be able to forecast the next intervals well; whereas an AF window is close to noise, so the forecast error is large. This means we can use our model’s forecast error as the AF score.
We’ll use an ARIMA model to do this. ARIMA predicts the next value
from a linear combination of recent values (the AR - autoregressive
part) and recent forecast errors (the MA - moving average part), after
differencing the series to make it stationary (the I part).
Specifically, we are going to use forecast::auto.arima() to
automatically select (hyperparameter optimisation) how many previous
values to use for each part of this model (also known as the “order” of
the model component).
For each window we treat the R-R series as a tachogram (interval value indexed by beat number), fit ARIMA on the first 80% of beats, forecast the rest, and record the mean absolute error.
arima_error <- function(window_df) {
y <- window_df$rr
h <- max(5, round(0.2 * length(y))) # forecast horizon = last 20% of beats
cut <- length(y) - h
fit <- forecast::auto.arima(y[seq_len(cut)])
forecast <- forecast::forecast(fit, h = h)
mean(abs(as.numeric(forecast$mean) - y[(cut + 1):length(y)])) # forecast MAE
}Here is the idea on one window of each rhythm - the red forecast tracks the regular rhythm closely but cannot keep up with AF:
demo <- rr_train |>
group_by(rhythm) |>
filter(window == last(window)) |>
ungroup()
demo_forecast <- demo |>
group_by(rhythm) |>
group_modify(~ {
y <- .x$rr
h <- max(5, round(0.2 * length(y)))
cut <- length(y) - h
fit <- forecast::auto.arima(y[seq_len(cut)])
fc <- forecast::forecast(fit, h = h)
tibble(beat = seq_along(y),
rr = y,
forecast = c(rep(NA_real_, cut), as.numeric(fc$mean)))
}) |>
ungroup()
# forecast error (mean absolute error over the forecast horizon) for each rhythm
demo_error <- demo_forecast |>
filter(!is.na(forecast)) |>
group_by(rhythm) |>
summarise(mae = mean(abs(forecast - rr)), .groups = "drop") |>
mutate(label = paste0("MAE = ", round(mae, 3), " s"))
ggplot(demo_forecast, aes(beat)) +
geom_line(aes(y = rr), linewidth = 0.3) +
geom_point(aes(y = rr), size = 0.5) +
geom_line(aes(y = forecast), colour = "firebrick", linewidth = 0.6, na.rm = TRUE) +
geom_text(data = demo_error, aes(x = Inf, y = Inf, label = label),
hjust = 1.1, vjust = 1.5, size = 3.5, inherit.aes = FALSE) +
facet_wrap(~rhythm, scales = "free_x") +
labs(x = "Beat number", y = "R-R interval (s)",
title = "ARIMA forecast (Red) vs Actual")We build a per-window key table and a matching list of windows for the train and test sets separately. Each model below scores both sets - we look at the training scores to sanity-check that the score separates the classes, and keep the test scores untouched/unviewed for the final evaluation at the end. Now score every window. This fits one ARIMA model per window; it is cached so it only re-runs if the data changes.
train_grp <- rr_train |> group_by(record, window, rhythm)
train_keys <- group_keys(train_grp)
train_list <- group_split(train_grp)
test_grp <- rr_test |> group_by(record, window, rhythm)
test_keys <- group_keys(test_grp)
test_list <- group_split(test_grp)
train_keys$arima_err <- map_dbl(train_list, arima_error)
test_keys$arima_err <- map_dbl(test_list, arima_error)Now we can compare the forecast error in Normal vs AF across our training set:
train_keys |>
group_by(rhythm) |>
summarise(mean_forecast_error = mean(arima_err), .groups = "drop")The mean error is higher for AF, but how well does the forecast error
alone separate AF from Normal? We are treating
arima_err directly as an AF score (larger = more AF-like).
To make a prediction of AF vs Normal we to pick a threshold score i.e.,
if arima_err is greater than threshold we predict AF and
below Normal.
To do this we look at the ROC curve, which traces
sensitivity against 1 - specificity as we sweep the detection threshold
across every value of arima_err. We then pick the threshold
value that maximises the Area Under the Curve (or
AUC).
train_keys |>
roc_curve(truth = rhythm, arima_err, event_level = "first") |>
autoplot() +
labs(title = "ROC: ARIMA forecast error as an AF detector (train)")Q5 (1 marks): What other type of curve could we use to select the threshold? (Hint: think back to earlier labs)
The frequency-domain formulation we are going to use is does AF and Normal signals have distinguishable amounts of different frequencies in their signals?
We can infer the frequency distribution of a signal using the Fourier
transform (base R’s fft()). A regular rhythm varies slowly
(its R-R series wanders gently with breathing), so we expect its energy
sits at low frequencies in a single concentrated peak. AF jumps
from beat to beat, so its energy spreads out and up into higher
frequencies. Rather than collapse this into one hand-picked score, we
summarise each window’s spectrum with a handful of
frequency-space features and then let a logistic
regression learn how to weight them.
For each window we compute four interpretable frequency statistics:
hf_frac - fraction of power in the upper
half of the band (AF pushes power up here),spectral_centroid - the power-weighted mean
frequency bin (the spectrum’s “centre of mass”; higher for
AF),spectral_entropy - how spread out /
noisy the spectrum is, normalised to [0, 1] (a
flat, broadband AF spectrum scores near 1; a single sharp peak scores
near 0),peak_frac - the share of power in the single
biggest bin (a regular rhythm concentrates power in one peak,
so this is high for Normal).freq_features <- function(rr) {
x <- rr - mean(rr) # remove the DC offset
n <- length(x)
power <- (Mod(fft(x))^2)[2:floor(n / 2)] # one-sided power, dropping the DC term
if (sum(power) == 0) {
return(tibble(hf_frac = 0, spectral_centroid = 0,
spectral_entropy = 0, peak_frac = 0))
}
k <- length(power)
bins <- seq_len(k)
p <- power / sum(power) # spectrum as a probability distribution
tibble(
hf_frac = sum(power[round(k / 2):k]) / sum(power),
spectral_centroid = sum(bins * p),
spectral_entropy = -sum(p * log(p + 1e-12)) / log(k),
peak_frac = max(p)
)
}
# attach the features to each window's key table. Assigning by column name
# overwrites any existing copies, so re-running this block never duplicates or
# mangles column names (unlike bind_cols).
train_feats <- map_dfr(train_list, ~ freq_features(.x$rr))
test_feats <- map_dfr(test_list, ~ freq_features(.x$rr))
train_keys[names(train_feats)] <- train_feats
test_keys[names(test_feats)] <- test_feats
train_keys |>
group_by(rhythm) |>
summarise(across(c(hf_frac, spectral_centroid, spectral_entropy, peak_frac), mean),
.groups = "drop")Now we fit a simple logistic regression that predicts the probability
a window is AF from those four features, using the
training windows only. The fitted probability is the
frequency model’s AF score (freq_prob).
freq_model <- glm(
(rhythm == "AF") ~ hf_frac + spectral_centroid + spectral_entropy + peak_frac,
family = binomial,
data = train_keys
)
summary(freq_model)##
## Call:
## glm(formula = (rhythm == "AF") ~ hf_frac + spectral_centroid +
## spectral_entropy + peak_frac, family = binomial, data = train_keys)
##
## Coefficients:
## Estimate Std. Error z value Pr(>|z|)
## (Intercept) -9.9435 1.8149 -5.479 4.28e-08 ***
## hf_frac -5.3487 0.9058 -5.905 3.53e-09 ***
## spectral_centroid 0.6396 0.1053 6.073 1.26e-09 ***
## spectral_entropy 11.1099 1.7850 6.224 4.84e-10 ***
## peak_frac 0.6069 1.4757 0.411 0.681
## ---
## Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
##
## (Dispersion parameter for binomial family taken to be 1)
##
## Null deviance: 1934.5 on 1562 degrees of freedom
## Residual deviance: 1473.2 on 1558 degrees of freedom
## AIC: 1483.2
##
## Number of Fisher Scoring iterations: 5
# predicted probability of AF for every window
train_keys$freq_prob <- predict(freq_model, type = "response")
test_keys$freq_prob <- predict(freq_model, newdata = test_keys, type = "response")Q6 (2 marks) Based on the output from summary above which frequency-based features are the best predictors of AF in this dataset? Does this match our hypotheses (above) for what each of these features will capture?
Similarly to the time-based model above, we have to pick a threshold on the logistic regression output to decide when to make a prediction of AF. We can sweep possible thresholds from 0 to 1 to again trace out the ROC curve. Each point is one cut-off for calling a window AF; the dashed diagonal is chance, and the more the curve bows towards the top-left the better the model separates AF from Normal:
train_keys |>
roc_curve(truth = rhythm, freq_prob, event_level = "first") |>
autoplot() +
labs(title = "ROC: logistic-regression frequency detector (train)")Q7 (1 mark): What does it mean for the ROC curve to be below the dotted line?
Each model gives every window a score where larger means more
AF-like. To compare them fairly we rescale each score to
[0, 1] (so they share an axis) and evaluate on the
held-out test records. The ROC curve
and its AUC summarise how well each score separates AF
from normal across all possible cut-offs - the same yardstick both
papers report.
test_scored <- test_keys |>
mutate(across(c(arima_err, freq_prob, hmm_score), rescale)) |> # higher = more AF
pivot_longer(c(arima_err, freq_prob, hmm_score),
names_to = "model", values_to = "score") |>
mutate(
model = recode(model,
arima_err = "Time (ARIMA)",
freq_prob = "Frequency (logistic regression)",
hmm_score = "State-space (HMM)"),
truth = factor(rhythm, levels = c("AF", "Normal"))
)
# ROC curves, all three models on one set of axes
test_scored |>
group_by(model) |>
roc_curve(truth, score, event_level = "first") |>
autoplot() +
labs(title = "ROC: three AF detectors on the test set")test_scored |>
group_by(model) |>
roc_auc(truth, score, event_level = "first") |>
dplyr::select(model, .estimate) |> # dplyr::select - MASS (via depmixS4) also exports select()
arrange(desc(.estimate))Q9 (2 marks): How do these models compare to one another?
Q10 (3 marks): Suggest a way we could improve each of these models performance and suggest at least 3 additional analyses that it would be a good idea to perform before trying to use these in the real-world.
## R version 4.6.0 (2026-04-24)
## Platform: x86_64-pc-linux-gnu
## Running under: Arch Linux
##
## Matrix products: default
## BLAS: /usr/lib/libblas.so.3.12.0
## LAPACK: /usr/lib/liblapack.so.3.12.0 LAPACK version 3.12.0
##
## locale:
## [1] LC_CTYPE=en_US.UTF-8 LC_NUMERIC=C
## [3] LC_TIME=en_US.UTF-8 LC_COLLATE=en_US.UTF-8
## [5] LC_MONETARY=en_US.UTF-8 LC_MESSAGES=en_US.UTF-8
## [7] LC_PAPER=en_US.UTF-8 LC_NAME=C
## [9] LC_ADDRESS=C LC_TELEPHONE=C
## [11] LC_MEASUREMENT=en_US.UTF-8 LC_IDENTIFICATION=C
##
## time zone: America/Halifax
## tzcode source: system (glibc)
##
## attached base packages:
## [1] stats graphics grDevices utils datasets methods base
##
## other attached packages:
## [1] depmixS4_1.5-3 nlme_3.1-169 Rsolnp_2.0.1 MASS_7.3-65
## [5] nnet_7.3-20 scales_1.4.0 yardstick_1.4.0 HMM_1.0.2
## [9] fs_2.1.0 here_1.0.2 lubridate_1.9.5 forcats_1.0.1
## [13] stringr_1.6.0 dplyr_1.2.1 purrr_1.2.2 readr_2.2.0
## [17] tidyr_1.3.2 tibble_3.3.1 ggplot2_4.0.3 tidyverse_2.0.0
##
## loaded via a namespace (and not attached):
## [1] tidyselect_1.2.1 timeDate_4052.112 farver_2.1.2
## [4] S7_0.2.2 fastmap_1.2.0 digest_0.6.39
## [7] rpart_4.1.27 timechange_0.4.0 lifecycle_1.0.5
## [10] survival_3.8-6 magrittr_2.0.5 compiler_4.6.0
## [13] rlang_1.2.0 sass_0.4.10 tools_4.6.0
## [16] yaml_2.3.12 data.table_1.18.4 knitr_1.51
## [19] labeling_0.4.3 DiceDesign_1.10 RColorBrewer_1.1-3
## [22] parsnip_1.6.0 withr_3.0.2 numDeriv_2016.8-1.1
## [25] workflows_1.3.0 grid_4.6.0 stats4_4.6.0
## [28] tune_2.1.0 colorspace_2.1-2 future_1.70.0
## [31] globals_0.19.1 signal_1.8-1 cli_3.6.6
## [34] rmarkdown_2.31 generics_0.1.4 rstudioapi_0.18.0
## [37] future.apply_1.20.2 tzdb_0.5.0 cachem_1.1.0
## [40] splines_4.6.0 dials_1.4.3 forecast_9.0.2
## [43] parallel_4.6.0 urca_1.3-4 vctrs_0.7.3
## [46] hardhat_1.4.3 Matrix_1.7-5 jsonlite_2.0.0
## [49] hms_1.1.4 listenv_0.10.1 gower_1.0.2
## [52] jquerylib_0.1.4 recipes_1.3.2 glue_1.8.1
## [55] parallelly_1.47.0 codetools_0.2-20 rsample_1.3.2
## [58] stringi_1.8.7 gtable_0.3.6 furrr_0.4.0
## [61] pillar_1.11.1 htmltools_0.5.9 ipred_0.9-15
## [64] lava_1.9.1 truncnorm_1.0-9 R6_2.6.1
## [67] rprojroot_2.1.1 evaluate_1.0.5 lattice_0.22-9
## [70] fracdiff_1.5-4 bslib_0.10.0 class_7.3-23
## [73] Rcpp_1.1.1-1.1 prodlim_2026.03.11 xfun_0.57
## [76] zoo_1.8-15 pkgconfig_2.0.3