The aim of this project is to create a robust pipeline in R that automates the detection of seasonal patterns in English prescribing using data from OpenPrescribing. The ten steps of the pipeline are depicted in Figure 1.
# bank holidays in the data perioduk_holidays <-as.Date(holidayLONDON(year(DATE_START):year(DATE_END)))dispensing_days <-create.calendar(name ="UK",weekdays =c("sunday"),holidays = uk_holidays)month_work_days <-function(date) { month_start <-as.Date(date) month_end <-seq(month_start, by ="month", length.out =2)[2] -1length(bizseq(month_start, month_end, dispensing_days))}
The data is then aggregated by month, chemical_code, and chemical columns to create a monthly items_per_wd column that holds the total items of that chemical dispensed in a given month across English primary care, normalised by the number of dispensing days. Table 2 shows a sample of the resulting data following aggregation.
visualise_ts <-function(ts) {if (!inherits(ts, "tbl_ts")) {stop("Argument `ts` must be a time series object") } chems <-unique(ts$chemical) plots <-list()for (i inseq_along(chems)) { chem <- chems[i] chem_ts <-filter(ts, chemical == chem) time_plot <- chem_ts |>autoplot(items_per_wd) +theme(legend.position ="none") +theme_minimal() +labs(y ="Items per work day", x ="Month [1M]") seasonal_plot <- chem_ts |>gg_season( items_per_wd,labels ="right",labels_repel =TRUE ) +theme_minimal() +labs(y ="Items per work day", x ="Month [1M]") subseries_plot <- chem_ts |>gg_subseries(items_per_wd) +theme_minimal() +theme(axis.text.x =element_text(angle =90, hjust =1),strip.text.y =element_blank(),strip.background =element_blank() ) +labs(y ="Items per work day", x ="Year") acf_plot <- chem_ts |>ACF(items_per_wd) |>autoplot() +theme_minimal() +labs(y ="ACF", x ="Lag [1M]") p <- (time_plot / seasonal_plot / subseries_plot) +plot_layout(axis_titles ="collect_y") p <- (p / acf_plot) +plot_annotation(title = chem) save_file <-glue("{chem}_plots_{DATE_START}_{DATE_END}.png")ggsave(here(PLOTS_DIR, save_file),width =7,height =7,units ="in" )print(p) }}
Figure 2 shows the output of the visualising function for one chemical (Sertraline Hydrochloride, BNF code 0403030Q0).
Code
visualise_ts(df_fexofenadine)
Figure 2: Example output plots
Step 4: Detrending
In this step, the detrended time series is obtained from either an STL decompoisition, LOESS, or linear model of the original time series. All three methods were tested and the results compared. The detrended series is then plotted as shown in Figure 7. Seasonal strength scores, trough and peak months are also computed using the feats::feat_stl() function.
Step 5 & 6: FFT filtering and candidate periods selection
A fast fourier transform (FFT) of the detrended time series is taken to see the component signals (seasonalities) and their respective amplitudes/strength. For computing the FFT of the series, we use the last \(N\) months of the series that makes up full years. \(N\) is given by:
\[
N = 12 \left\lfloor \frac{total\ months}{12} \right\rfloor
\tag{2}\] After computing the FFT, we only keep the coefficients for periods bins between 2 and 24 months. This is because the Nyquist for monthly data is 0.5 Hz (2 months) and we are mostly interested in annual and subannual seasonalities.
Code
compute_fft <-function(ts) {if (!is.vector(ts)) {stop("Argument `ts` must be a vector") } len_ts <-length(ts) ts <- ts[(len_ts - N_YEAR_MONTHS):len_ts] len_ts <- N_YEAR_MONTHS /2 amps <-fft(ts) |>abs() amps[2:(len_ts +1)]}filter_fft <-function(fft_amps, top_k =1) {if (!is.vector(fft_amps)) {stop("Argument `fft_amps` must be a vector") }if (top_k >length(fft_amps)) {stop("`top_k` cannot exceed the length of `fft_amps` amplitudes vector") } sort_idx <-order(fft_amps, decreasing =TRUE) min_freq <- N_YEAR_MONTHS %/%24 max_freq <- N_YEAR_MONTHS %/%2 sort_idx <- sort_idx[(sort_idx >= min_freq) & (sort_idx <= max_freq)] candidates <- sort_idx[1:top_k] amps <- fft_amps[candidates]list(freqs = candidates, amps = amps)}plot_fft <-function(fft_amps, chemical) {if (!is.vector(fft_amps)) {stop("Argument `fft_amps` must be a vector") } len_ts <-length(fft_amps) freqs <-1:len_ts spectral_plot <-ggplot(mapping =aes(x = freqs, y = fft_amps)) +geom_segment(aes(xend = freqs, y =0, yend = fft_amps),colour ="steelblue" ) +geom_point(colour ="steelblue", size =1) +theme_minimal() +labs(x ="Frequency (Hz)",y ="Power",title = chemical ) save_file <-glue("{chemical}_fft_{DETREND_TYPE}_{DATE_START}_{DATE_END}.png")ggsave(here(PLOTS_DIR, save_file),width =7,height =7,units ="in" ) spectral_plot}
Figure 9 shows the periodograms for the chemicals in the current data. It shows the frequecies/wave number of the seasonal signals present in the time series, along with their respective amplitdes/power. The seasonal period, \(p\), which is the number of month after which the seasonal signal repeats is given by:
\[
p = N / f
\tag{3}\] where \(N\) is the number of months from Equation 2 and \(f\) is the frequenc/wave number from the x axis of the periodograms. So for frequency 10, seasonal period is \(120 / 10 = 12\ months\), for frequency 20, \(p\) is \(120 / 20 = 6\ months\), etc.
In this step, we fit Dynamic Harmonic Regression(DHR) models to the aggregated time series data. Each model uses the each candidate period (\(p\)) as its fundamental frequency/period and set \(K\) (the number of harmonics to include) to be a maximum of \(p // 2\) (its Nyquist). Therefore, the maximum number of models fitted is given by:
\[
N \leq \sum_{i=1}^{n} \left\lfloor \frac{p_i}{2} \right\rfloor
\] and each model is represented as:
Within each period, we used the likelihood ratio test (LRT) to determine if the difference between successively fitted models (with varying \(k\)’s) are statistically significant and stop fitting models when the BIC gets worse. We then select the model with the best BIC that is significantly better than its predecessor (\(k - 1\)).
if (N_YEAR_MONTHS <60) {cat("::: {.callout-important}\n")cat("## Halted\n\n")cat("**Analysis terminated:** fewer than 5 years of data are available.\n")cat(":::\n") knitr::knit_exit()}
Table 4: Candidate periods found and their signal strength from FFT (Top 5)
chemical
period_1
freq_1
score_1
period_2
freq_2
score_2
period_3
freq_3
score_3
period_4
freq_4
score_4
period_5
freq_5
score_5
Loratadine
12
10
60657.688
6
20
21094.584
13.33
9
15966.179
10.91
11
14701.558
15.0
8
10346.915
Fexofenadine hydrochloride
12
10
198440.066
6
20
68348.143
3.00
40
29171.101
2.00
60
29048.628
2.4
50
23102.810
Sertraline hydrochloride
4
30
99811.825
12
10
76369.914
6.00
20
73763.278
2.31
52
32601.888
3.0
40
27239.876
Escitalopram
4
30
7883.784
6
20
6181.532
12.00
10
5617.502
2.31
52
2444.073
3.0
40
2338.871
Modelling
Code
models_results <- DF_PRESC_AGG |>fit_models(candidates)write_parquet( models_results,here(TBL_DIR, glue("modelling_results_{DETREND_TYPE}.parquet")))slice_head(models_results, n =3) |>gt()
Table 5: Top 3 performing models for each chemical
.model
period
k
lrt_stat
p_value
sigma2
log_lik
AIC
AICc
BIC
0304010D0 - Loratadine
P12_K2
12
2
76.83161
<2e-16
101774.59
-852.9381
1725.876
1727.913
1753.667
K0
NA
0
NA
-
201490.68
-894.2347
1800.469
1801.219
1817.144
0304010E0 - Fexofenadine hydrochloride
P12_K2
12
2
63.46607
1.7e-14
902631.08
-982.3694
1978.739
1979.748
1998.193
P12_K1
12
1
103.46077
< 2e-16
1521728.92
-1014.1024
2040.205
2040.955
2056.880
P6_K3
6
3
26.58814
1.7e-06
1750012.19
-1019.0031
2062.006
2064.950
2095.356
0403030Q0 - Sertraline hydrochloride
P12_K3
12
3
24.94745
3.8e-06
1775974.64
-1020.9344
2061.869
2063.906
2089.660
P12_K2
12
2
55.07462
1.1e-12
2172889.07
-1033.4081
2088.816
2091.283
2119.387
P4_K2
4
2
17.34475
0.00017
2884639.00
-1051.2955
2122.591
2124.628
2150.382
0403030X0 - Escitalopram
P12_K3
12
3
19.52244
5.8e-05
12797.71
-727.2842
1474.568
1476.605
1502.360
P12_K2
12
2
61.12903
5.3e-14
14928.53
-737.0455
1496.091
1498.558
1526.661
P6_K1
6
1
49.80296
1.5e-11
20878.50
-758.4120
1532.824
1534.133
1555.057
Summary report
Code
summary_tbl <-generate_report(ts_original = DF_PRESC_AGG,candidates = candidates,models = models_results,strength = strength_scores,top_k = TOP_K)write_parquet( summary_tbl,here(TBL_DIR, glue("summary_table_{DETREND_TYPE}.parquet")))gt(summary_tbl) |>tab_header(title ="Seasonality Detection Summary",subtitle =glue("Data period: {DATE_START} to {DATE_END} | Detrending: {DETREND_TYPE}" ) ) |>fmt_number(columns =`Seasonal strength`, decimals =2) |>tab_style(style =cell_fill(color ="#e6f2ff"),locations =cells_body(columns =`Model periods`) ) |>tab_footnote(footnote =paste0("Dominant periods from FFT (strongest amplitudes), shown independently as a sanity check. ","Model periods are the harmonic components (period/1, period/2, ...) actually retained ","in the BIC-selected model. Peak/Trough months are the calendar months with highest/lowest ","average dispensing. Best model from ARIMA errors + Fourier terms, selected by BIC. ","Seasonal strength from STL decomposition." ) ) |>tab_style(style =cell_text(style ="italic", size =px(12)),locations =cells_footnotes() )
Seasonality Detection Summary
Data period: 2016-05-01 to 2026-04-01 | Detrending: stl
Chemical
Dominant periods (FFT)
Model periods
Peak month
Trough month
Seasonal strength
Best model
LRT p-value
Escitalopram
4, 6, 12
12, 6, 4
Dec
Oct
0.80
P12_K3 (P=12, K=3)
5.8e-05
Fexofenadine hydrochloride
12, 6, 3
12, 6
Jun
Nov
0.96
P12_K2 (P=12, K=2)
1.7e-14
Loratadine
12, 6, 13.3
12, 6
Jun
Nov
0.91
P12_K2 (P=12, K=2)
<2e-16
Sertraline hydrochloride
4, 12, 6
12, 6, 4
Dec
Oct
0.79
P12_K3 (P=12, K=3)
3.8e-06
Dominant periods from FFT (strongest amplitudes), shown independently as a sanity check. Model periods are the harmonic components (period/1, period/2, ...) actually retained in the BIC-selected model. Peak/Trough months are the calendar months with highest/lowest average dispensing. Best model from ARIMA errors + Fourier terms, selected by BIC. Seasonal strength from STL decomposition.
References
1. Richardson, N., Cook, I., Crane, N., Dunnington, D., François, R., Keane, J., Mecum, B., Moldovan-Grünfeld, D., Ooms, J., Wujciak-Jens, J., & Apache Arrow. (2026). Arrow: Integration to apache ’arrow’. https://github.com/apache/arrow/
6. Iannone, R., Cheng, J., Schloerke, B., Haughton, S., Hughes, E., Lauer, A., Fran?ois, R., Seo, J., Brevoort, K., & Roy, O. (2026). Gt: Easily create presentation-ready display tables. https://gt.rstudio.com
11. Wuertz, D., Setz, T., Chalabi, Y., & Boshnakov, G. N. (2026). timeDate: Rmetrics - chronological and calendar objects. https://geobosh.github.io/timeDateDoc/
12. Pfaff, B. (2024). Urca: Unit root and cointegration tests for time series data.
14. Wickham, H., Averick, M., Bryan, J., Chang, W., McGowan, L. D., François, R., Grolemund, G., Hayes, A., Henry, L., Hester, J., Kuhn, M., Pedersen, T. L., Miller, E., Bache, S. M., Müller, K., Ooms, J., Robinson, D., Seidel, D. P., Spinu, V., … Yutani, H. (2019). Welcome to the tidyverse. Journal of Open Source Software, 4(43), 1686. https://doi.org/10.21105/joss.01686
15. Pfaff, B. (2008). Analysis of integrated and cointegrated time series with r (Second). Springer. https://www.pfaffikus.de