Rolling user-defined function
frollapply.RdFast rolling user-defined function (UDF) to calculate on a sliding window. Experimental. Please read, at least, caveats section below. For "time-aware" (irregularly spaced time series) rolling function see frolladapt.
Usage
frollapply(X, N, FUN, ..., by.column=TRUE, fill=NA,
align=c("right","left","center"), adaptive=FALSE, partial=FALSE,
give.names=FALSE, simplify=TRUE, x, n)Arguments
- X
Atomic vector,
data.frame,data.tableor aliston which sliding window calculatesFUNfunction. How theXis handled depends on theby.columnargument. It supports vectorized input, forby.column=TRUEit needs to be adata.table,data.frameor alist, and forby.column=FALSElist of data.frames/data.tables, but not a list of lists.- N
Integer, non-negative, non-NA, rolling window size. This is the total number of included values in aggregate function. In case of an adaptive rolling function window size has to be provided as a vector for each individual value of
X. It supports vectorized input, then it needs to be a vector, or in case of an adaptive rolling alistof vectors.- FUN
The function to be applied on subsets of
X.- ...
Extra arguments passed to
FUN. Note that argument names passed to ... should not overlap with arguments offrollapply.- by.column
Logical. When
TRUE(default) thenXof types list/data.frame/data.table is treated as vectorized input rather an object to apply rolling window on. Setting toFALSEallows rolling window to be applied on multiple variables, using data.frame, data.table or a list, as a whole. For details seeby.columnargument section below.- fill
An object; value to pad by for an incomplete window iteration. Defaults to
NA. Whenpartial=TRUEthis argument is ignored.- align
Character, specifying the "alignment" of the rolling window, defaulting to
"right". For details seefroll.- adaptive
Logical, default
FALSE. Should the rolling function be calculated adaptively? For details seefroll.- partial
Logical, default
FALSE. Should the rolling window size(s) provided inNbe trimmed to available observations? For details seefroll.- give.names
Logical, default
FALSE. WhenTRUE, names are automatically generated corresponding to names ofXand names ofN. If answer is an atomic vector, then the argument is ignored, see examples.- simplify
Logical or a function. When
TRUE(default) then internalsimplifylistfunction is applied on a list storing results of all computations. WhenFALSEthen list is returned without any post-processing. Argument can take a function as well, then the function is applied to a list that would have been returned whensimplify=FALSE. If results are not automatically simplified whensimplify=TRUEthen, for backward compatibility, one should usesimplify=FALSEexplicitly. Seesimplifyargument section below for details.- x
Deprecated, use
Xinstead.- n
Deprecated, use
Ninstead.
Value
Argument simplify impacts the type returned. Its default value TRUE is set for convenience and backward compatibility, but it is advised to use simplify=unlist (or other desired function) instead.
simplify=FALSEwill always return list where each element will be a result of each iteration.simplify=unlist(or any other function) will return object returned by provided function as supplied with results offrollapplyusingsimplify=FALSE.simplify=TRUEwill try to simplify results byunlist,rbindor other functions, its behavior is subject to change, seesimplifyargument section below for more details.
by.column argument
Setting by.column to FALSE allows to apply function on multiple variables rather than a single vector. Then X expects to be data.table, data.frame or a list of equal length vectors, and window size provided in N refers to number of rows (or length of a vectors in a list). See examples for use cases. Error "incorrect number of dimensions" can be commonly observed when by.column was not set to FALSE when FUN expects its input to be a data.table or data.frame.
simplify argument
When set to TRUE (default), then results from rolling function which are normally stored in a list may be simplified either with unlist or rbindlist. It also attempts to match type, size and names of fill argument to the results of a function.
One should avoid simplify=TRUE when writing robust code. One reason is performance, as explained in Performance consideration section below. Another is backward compatibility. For backward compatibility and performance one should always provide desired function to simplify explicitly. In future version we may change internal simplifylist function, then simplify=TRUE may return object of a different type, breaking downstream code.
Caveats
With great power comes great responsibility.
An optimization used to avoid repeated allocation of window subsets (explained more deeply in Implementation section below) may, in special cases, return rather surprising results:
setDTthreads(1) frollapply(c(1, 9), N=1L, FUN=identity) ## unexpected #[1] 9 9 frollapply(c(1, 9), N=1L, FUN=list) ## unexpected # V1 # <num> #1: 9 #2: 9 setDTthreads(2, throttle=1) ## disable throttle frollapply(c(1, 9), N=1L, FUN=identity) ## good only because threads >= input #[1] 1 9 ## on Linux and Macos frollapply(c(1, 5, 9), N=1L, FUN=identity) ## unexpected again #[1] 5 5 9Problem occurs, in rather unlikely scenarios for rolling computations, when objects returned from a function can be its input (i.e.
identity), or a reference to it (i.e.list), then one has to add extracopycall:setDTthreads(1) frollapply(c(1, 9), N=1L, FUN=function(x) copy(identity(x))) ## only 'copy' would be equivalent here #[1] 1 9 frollapply(c(1, 9), N=1L, FUN=function(x) copy(list(x))) # V1 # <num> #1: 1 #2: 9FUNcalls are internally passed toparallel::mcparallelto evaluate them in parallel. We inherit few limitations fromparallelpackage explained below. This optimization can be disabled completely by callingsetDTthreads(1), in which case the limitations listed below do not apply because all evaluations ofFUNwill be made sequentially without use ofparallelpackage. Note that on Windows platform this optimization is always disabled due to lack of fork used byparallelpackage. One can useoptions(datatable.verbose=TRUE)to get extra information iffrollapplyis running multithreaded or not.Warnings produced inside the function are silently ignored; for consistency we ignore warnings also when running single threaded path.
FUNshould not use any on-screen devices, GUI elements, tcltk, multithreaded libraries.setDTthreads(1L)is passed to forked processes, therefore any data.table code insideFUNwill be forced to be single threaded. It is advised not to callsetDTthreadsinsideFUN.frollapplyis already parallelized, and nested parallelism is rarely a good idea.Any operation that could misbehave when run in parallel has to be handled. For example, writing to the same file from multiple CPU threads.
old = setDTthreads(1L) frollapply(iris, 5L, by.column=FALSE, FUN=fwrite, file="rolling-data.csv", append=TRUE) setDTthreads(old)Objects returned from forked processes,
FUN, are serialized. This may cause problems for objects that are meant not to be serialized, like data.table. We are handling that for data.table class internally infrollapplywheneverFUNis returning data.table (which is checked on the results of the firstFUNcall so it assumes function is type stable). If data.table is nested in another object returned fromFUNthen the problem may still manifest, in such case one has to callsetDTon objects returned fromFUN. This can be also nicely handled viasimplifyargument when passing a function that callssetDTon nested data.table objects returned fromFUN. Anyway, returning data.table fromFUNshould, in majority of cases, be avoided from the performance reasons, see UDF optimization section for details.setDTthreads(2, throttle=1) ## disable throttle ## frollapply will fix DT in most cases ans = frollapply(1:2, 2, data.table) .selfref.ok(ans) #[1] TRUE ans = frollapply(1:2, 2, data.table, simplify=FALSE) .selfref.ok(ans[[2L]]) #[1] TRUE ## nested DT not fixed ans = frollapply(1:2, 2, function(x) list(data.table(x)), fill=list(data.table(NA)), simplify=FALSE) .selfref.ok(ans[[2L]][[1L]]) #[1] FALSE #### now if we want to use it set(ans[[2L]][[1L]],, "newcol", 1L) #Error in set(ans[[2L]][[1L]], , "newcol", 1L) : # This data.table has either been loaded from disk (e.g. using readRDS()/load()) or constructed manually (e.g. using structure()). Please run setDT() or setalloccol() on it first (to pre-allocate space for new columns) before assigning by reference to it. #### fix as explained in error message ans = lapply(ans, lapply, setDT) .selfref.ok(ans[[2L]][[1L]]) #[1] TRUE ## fix inside frollapply via simplify simplifix = function(x) lapply(x, lapply, setDT) ans = frollapply(1:2, 2, function(x) list(data.table(x)), fill=list(data.table(NA)), simplify=simplifix) .selfref.ok(ans[[2L]][[1L]]) #[1] TRUE ## automatic fix may not work for a non-type stable function f = function(x) (if (x[1L]==1L) data.frame else data.table)(x) ans = frollapply(1:3, 2, f, fill=data.table(NA), simplify=FALSE) .selfref.ok(ans[[3L]]) #[1] FALSE #### fix inside frollapply via simplify simplifix = function(x) lapply(x, function(y) if (is.data.table(y)) setDT(y) else y) ans = frollapply(1:3, 2, f, fill=data.table(NA), simplify=simplifix) .selfref.ok(ans[[3L]]) #[1] TRUE setDTthreads(2, throttle=1024) ## enable throttle
Due to possible future improvements of handling simplification of results returned from rolling function, the default
simplify=TRUEmay not be backward compatible for functions that produce results that haven't been already automatically simplified. See thesimplifyargument section for details.
Performance consideration
frollapply is meant to run any UDF function. If one needs to use a common function like mean, sum, max, etc., then we have highly optimized, implemented in C language, rolling functions described in froll manual.
Most crucial optimizations are the ones to be applied on UDF. Those are discussed below in section UDF optimization.
When using
by.column=FALSE, subset the dataset before passing it toXto keep only columns relevant for the computation:x = setDT(lapply(1:1000, function(x) as.double(rep.int(x,1e4L)))) f = function(x) sum(x$V1 * x$V2) system.time(frollapply(x, 100, f, by.column=FALSE)) # user system elapsed # 0.373 0.069 0.234 system.time(frollapply(x[, c("V1","V2"), with=FALSE], 100, f, by.column=FALSE)) # user system elapsed # 0.050 0.058 0.061Avoid
partialargument, seepartialargument section offrollmanual.Avoid
simplify=TRUEand provide a function instead:x = rnorm(1e5) system.time(frollapply(x, 2, function(x) 1L, simplify=TRUE)) # user system elapsed # 0.227 0.095 0.236 system.time(frollapply(x, 2, function(x) 1L, simplify=unlist)) # user system elapsed # 0.054 0.049 0.091CPU threads utilization in
frollapplycan be controlled bysetDTthreads, which by default uses half of available CPU threads. Usage of multiple CPU threads will be throttled for small input, as described insetDTthreadsmanual.Parallel computation of
FUNis handled byparallelpackage (part of R core since 2.14.0) and its fork mechanism. Fork is not available on Windows OS, therefore computations will always be single-threaded on that platform.
UDF optimization
FUN will be evaluated many times so should be highly optimized. Tips below are not specific to frollapply and can be applied to any code meant to run in many iterations.
It is usually better to return the most lightweight objects from
FUN, for example it will be faster to return a list rather a data.table. In the case presented below,simplify=TRUEis callingrbindliston the results anyway, which makes the results equal:fun1 = function(x) {tmp=range(x); data.table(min=tmp[1L], max=tmp[2L])} fun2 = function(x) {tmp=range(x); list(min=tmp[1L], max=tmp[2L])} fill1 = data.table(min=NA_integer_, max=NA_integer_) fill2 = list(min=NA_integer_, max=NA_integer_) system.time(a<-frollapply(1:1e4, 100, fun1, fill=fill1, simplify=rbindlist)) # user system elapsed # 0.934 0.347 0.706 system.time(b<-frollapply(1:1e4, 100, fun2, fill=fill2, simplify=rbindlist)) # user system elapsed # 0.010 0.033 0.094 all.equal(a, b) #[1] TRUECode that is not dependent on a rolling window should be taken out as pre or post computation:
x = c(1L,3L) system.time(for (i in 1:1e6) sum(x+1L)) # user system elapsed # 0.218 0.002 0.221 system.time({y = x+1L; for (i in 1:1e6) sum(y)}) # user system elapsed # 0.160 0.001 0.161Being strict about data types removes the need for R to handle them automatically:
x = vector("integer", 1e6) system.time(for (i in 1:1e6) x[i] = NA) # user system elapsed # 0.114 0.000 0.114 system.time(for (i in 1:1e6) x[i] = NA_integer_) # user system elapsed # 0.029 0.000 0.030If a function calls another function under the hood, it is usually better to call the latter one directly:
x = matrix(c(1L,2L,3L,4L), c(2L,2L)) system.time(for (i in 1:1e4) colSums(x)) # user system elapsed # 0.033 0.000 0.033 system.time(for (i in 1:1e4) .colSums(x, 2L, 2L)) # user system elapsed # 0.010 0.002 0.012There are many functions that may be optimized for scaling up with larger input, yet for a small input they may incur bigger overhead comparing to simpler counterparts. One may need to experiment on own data, but low overhead functions are likely to be faster when evaluated over many iterations:
## uniqueN x = c(1L,3L,5L) system.time(for (i in 1:1e4) uniqueN(x)) # user system elapsed # 0.078 0.001 0.080 system.time(for (i in 1:1e4) length(unique(x))) # user system elapsed # 0.018 0.000 0.018 ## column subset x = data.table(v1 = c(1L,3L,5L)) system.time(for (i in 1:1e4) x[, v1]) # user system elapsed # 1.952 0.011 1.964 system.time(for (i in 1:1e4) x[["v1"]]) # user system elapsed # 0.036 0.000 0.035
Implementation
Evaluation of UDF comes with very limited capabilities for optimizations, therefore speed improvements in frollapply should not be expected as good as in other data.table fast functions. frollapply is implemented almost exclusively in R, rather than C. Its speed improvement comes from two optimizations that have been applied:
No repeated allocation of a rolling window subset.
Object (type ofXand size ofN) is allocated once (for each CPU thread), and then for each iteration this object is being re-used by copying expected subset of data into it. This means we still have to subset data on each iteration, but we only copy data into pre-allocated window object, instead of allocating in each iteration. Allocation is carrying much bigger overhead than copy. The faster theFUNevaluates the more relative speedup we are getting, because allocation of a subset does not depend on how fast or slowFUNevaluates. See caveats section for possible edge cases caused by this optimization.Parallel evaluation of
FUNcalls.
Until September 2025 all the multithreaded code in data.table was using OpenMP. It can be used only in C language and it has very low overhead. Unfortunately it could not be applied infrollapplybecause to evaluate UDF from C code one has to call R's C api that is not thread safe (can be run only from single threaded C code). Thereforefrollapplyusesparallel-package, which is included in base R, to provide parallelism at the R language level. It uses fork parallelism, which has low overhead as well (unless results of computation are big in size which is not an issue for rolling statistics). Fork is not available on Windows OS. See caveats section for limitations caused by using this optimization.
Note
Be aware that rolling functions operate on the physical order of input. If the intent is to roll values in a vector by a logical window, for example an hour, or a day, then one has to ensure that there are no gaps in the input, or use an adaptive rolling function to handle gaps, for which we provide helper function frolladapt to generate adaptive window size.
Examples
frollapply(1:16, 4, median)
#> [1] NA NA NA 2.5 3.5 4.5 5.5 6.5 7.5 8.5 9.5 10.5 11.5 12.5 13.5
#> [16] 14.5
frollapply(1:9, 3, toString)
#> [1] NA NA "1, 2, 3" "2, 3, 4" "3, 4, 5" "4, 5, 6" "5, 6, 7"
#> [8] "6, 7, 8" "7, 8, 9"
## vectorized input
x = list(1:10, 10:1)
n = c(3, 4)
frollapply(x, n, sum)
#> [[1]]
#> [1] NA NA 6 9 12 15 18 21 24 27
#>
#> [[2]]
#> [1] NA NA NA 10 14 18 22 26 30 34
#>
#> [[3]]
#> [1] NA NA 27 24 21 18 15 12 9 6
#>
#> [[4]]
#> [1] NA NA NA 34 30 26 22 18 14 10
#>
## give names
x = list(data1 = 1:10, data2 = 10:1)
n = c(small = 3, big = 4)
frollapply(x, n, sum, give.names=TRUE)
#> $data1_small
#> [1] NA NA 6 9 12 15 18 21 24 27
#>
#> $data1_big
#> [1] NA NA NA 10 14 18 22 26 30 34
#>
#> $data2_small
#> [1] NA NA 27 24 21 18 15 12 9 6
#>
#> $data2_big
#> [1] NA NA NA 34 30 26 22 18 14 10
#>
## by.column=FALSE
x = as.data.table(iris)
flow = function(x) {
v1 = x[[1L]]
v2 = x[[2L]]
(v1[2L] - v1[1L] * (1+v2[2L])) / v1[1L]
}
x[, "flow" := frollapply(.(Sepal.Length, Sepal.Width), 2L, flow, by.column=FALSE),
by = Species][]
#> Sepal.Length Sepal.Width Petal.Length Petal.Width Species flow
#> <num> <num> <num> <num> <fctr> <num>
#> 1: 5.1 3.5 1.4 0.2 setosa NA
#> 2: 4.9 3.0 1.4 0.2 setosa -3.039216
#> 3: 4.7 3.2 1.3 0.2 setosa -3.240816
#> 4: 4.6 3.1 1.5 0.2 setosa -3.121277
#> 5: 5.0 3.6 1.4 0.2 setosa -3.513043
#> ---
#> 146: 6.7 3.0 5.2 2.3 virginica -3.000000
#> 147: 6.3 2.5 5.0 1.9 virginica -2.559701
#> 148: 6.5 3.0 5.2 2.0 virginica -2.968254
#> 149: 6.2 3.4 5.4 2.3 virginica -3.446154
#> 150: 5.9 3.0 5.1 1.8 virginica -3.048387
## rolling regression: by.column=FALSE
f = function(x) coef(lm(v2 ~ v1, data=x))
x = data.table(v1=rnorm(120), v2=rnorm(120))
frollapply(x, 4, f, by.column=FALSE)
#> (Intercept) v1
#> <num> <num>
#> 1: NA NA
#> 2: NA NA
#> 3: NA NA
#> 4: -0.7689705 -0.6347718
#> 5: -0.4903080 -1.5062131
#> ---
#> 116: 0.3520692 1.2413190
#> 117: -0.5799706 0.5531221
#> 118: -0.4525830 0.5763709
#> 119: -0.8670370 -0.3124939
#> 120: -0.6700778 -0.3203630