Calculating number of employees by month given begins date and end date in R

Viewed 313

Here is a list of begin and end dates for employees. How can we calculate monthly number of Employees efficiently, given that there might be tens of thousands employees.

begin <- as.Date(c("2015-07-06","2015-07-06","2015-07-27","2015-07-06","2015-06-29",
               "2015-07-06","2015-07-06","2015-07-06","2015-07-13","2015-06-29",
               "2015-07-06","2015-07-06","2015-07-13","2015-07-01","2015-07-06",
               "2015-07-06","2015-07-06","2015-07-09","2015-07-13"),format = "%Y-%m-%d")

end <- as.Date(c("2018-08-03","2016-01-11","2999-12-31","2017-03-13","2999-12-31",
             "2015-10-20","2999-12-31","2018-09-24","2999-12-31","2015-09-25",
             "2019-11-01","2999-12-31","2018-03-26","2018-08-08","2015-10-13",
             "2999-12-31","2999-12-31","2021-02-11","2999-12-31"), format = "%Y-%m-%d")

smallEmp <- data.frame(begin,end)


method_currentsolution = function() {
date_df = tibble(Date = seq(from=as.Date('2015-01-01'), to=Sys.Date(), by =   "month"))
queue_history = merge(smallEmp, date_df, all=TRUE) %>% 
filter(Date >= begin, Date <= end) %>%
group_by(Date) %>% 
summarise(cnt = n())
}

result <- method_currentsolution()

smallEmp is the dataset that holds the Begin and End dates. This solution works, but it is slow for very large dataset since it repeats all employees for each month of interest. Any suggestion that would speed this up would be appreciated.

4 Answers

A base R solution is

date_df <- data.frame(
  Date = seq(from=as.Date('2015-01-01'), to=Sys.Date(), by = "month"))
date_df$count <- sapply(date_df$Date, function(x) sum(x >= begin & x <= end))
date_df <- subset(date_df, count > 0)
head(date_df, 10)
#R>          Date count
#R> 7  2015-07-01     3
#R> 8  2015-08-01    19
#R> 9  2015-09-01    19
#R> 10 2015-10-01    18
#R> 11 2015-11-01    16
#R> 12 2015-12-01    16
#R> 13 2016-01-01    16
#R> 14 2016-02-01    15
#R> 15 2016-03-01    15
#R> 16 2016-04-01    15

This is perhaps faster but it uses more peak memory

date_df_2 <- data.frame(
  Date = seq(from=as.Date('2015-01-01'), to=Sys.Date(), by = "month"))
date_df_2$count <- 
  rowSums(outer(date_df_2$Date, begin, `>=`) & outer(date_df_2$Date, end, `<=`))
date_df_2 <- subset(date_df_2, count > 0)

# we got the same
all.equal(date_df_2, date_df)
#R> [1] TRUE

The two solutions can be written a bit more nicely in R 4.1.0 or later as follows

date_df_3 <- data.frame(
  Date = seq(as.Date('2015-01-01'), Sys.Date(), by = "month")) |>
  transform(count = sapply(Date, \(x) sum(x >= begin & x <= end))) |>
  subset(count > 0)

date_df_4 <- data.frame(
  Date = seq(as.Date('2015-01-01'), Sys.Date(), by = "month")) |>
  transform(
    count = rowSums(outer(Date, begin, `>=`) & outer(Date, end, `<=`))) |>
  subset(count > 0)

# we got the same
all.equal(date_df_3, date_df)
#R> [1] TRUE
all.equal(date_df_4, date_df)
#R> [1] TRUE

You may want to change one of the >= and <= to a > or <.


Here is a simple benchmark which is created by repeating the data provided by the OP

# repeat the data a number of times to create a larger data set
begin <- rep(begin, 1000)
end <- rep(end, 1000)
smallEmp <- data.frame(begin,end)
small_emp <- data.table(begin = begin, end = end)

# perform the benchmark
bench::mark(
  OP = method_currentsolution(),
  sapply = data.frame(
    Date = seq(as.Date('2015-01-01'), Sys.Date(), by = "month")) |>
    transform(count = sapply(Date, \(x) sum(x >= begin & x <= end))) |>
    subset(count > 0),
  rowSums = data.frame(
    Date = seq(as.Date('2015-01-01'), Sys.Date(), by = "month")) |>
    transform(
      count = rowSums(outer(Date, begin, `>=`) & outer(Date, end, `<=`))) |>
    subset(count > 0),
  data.table = {
    all_dates <- data.table(
      date = seq(from = as.Date('2015-01-01'), to = Sys.Date(), by = "month"))
    all_dates[small_emp, .N, on = .(date >= begin, date <= end), keyby = .(date)]
  },
  `data.table (by)` = {
    all_dates <- data.table(
      date = seq(from = as.Date('2015-01-01'), to = Sys.Date(), by = "month"))
    all_dates[small_emp, .N, on = .(date >= begin, date <= end), by = .(date)]
  },
  fuzzy_inner_join = method_fuzzy(smallEmp), check = FALSE, memory = FALSE)
#R> # A tibble: 6 x 13
#R>   expression            min   median `itr/sec` mem_alloc `gc/sec` n_itr  n_gc total_time result memory time            gc               
#R>   <bch:expr>       <bch:tm> <bch:tm>     <dbl> <bch:byt>    <dbl> <int> <dbl>   <bch:tm> <list> <list> <list>          <list>           
#R> 1 OP                  2.87s    2.87s    0.349         NA     1.74     1     5      2.87s <NULL> <NULL> <bench_tm [1]>  <tibble [1 × 3]> 
#R> 2 sapply             8.91ms   9.42ms   55.9           NA     7.71    29     4   518.53ms <NULL> <NULL> <bench_tm [29]> <tibble [29 × 3]>
#R> 3 rowSums           28.26ms  56.98ms   15.9           NA    24.7      9    14   566.25ms <NULL> <NULL> <bench_tm [9]>  <tibble [9 × 3]> 
#R> 4 data.table        23.34ms  23.89ms   25.8           NA     7.95    13     4   503.36ms <NULL> <NULL> <bench_tm [13]> <tibble [13 × 3]>
#R> 5 data.table (by)   23.22ms   28.4ms   33.9           NA     3.99    17     2   500.82ms <NULL> <NULL> <bench_tm [17]> <tibble [17 × 3]>
#R> 6 fuzzy_inner_join   16.09s   16.09s    0.0622        NA     8.21     1   132     16.09s <NULL> <NULL> <bench_tm [1]>  <tibble [1 × 3]> 

The sapply solution is the fastest. It is about >2 times faster then the seconds fastest which is the data.table version. Thing may look different for other sized data (say millions of data points instead of 19000).

You can try with fuzzjoin -

library(dplyr)

method_fuzzy = function(df) {
  date_df = tibble(Date = seq(from=as.Date('2015-01-01'), 
                              to=Sys.Date(), by = "month"))

  fuzzyjoin::fuzzy_inner_join(date_df, df, 
                              by = c('Date' = 'begin', 'Date' = 'end'), 
                              match_fun = c(`>=`, `<=`)) %>%
    count(Date)
}

method_fuzzy(smallEmp)

You can use data.table to perform the same operation very efficiently, using a "non-equi join":

small_emp <- data.table(
  begin = as.Date(
    c(
      "2015-07-06","2015-07-06","2015-07-27","2015-07-06","2015-06-29",
      "2015-07-06","2015-07-06","2015-07-06","2015-07-13","2015-06-29",
      "2015-07-06","2015-07-06","2015-07-13","2015-07-01","2015-07-06",
      "2015-07-06","2015-07-06","2015-07-09","2015-07-13"
    ),
    format = "%Y-%m-%d"
  ),
  end = as.Date(
    c(
      "2018-08-03","2016-01-11","2999-12-31","2017-03-13","2999-12-31",
      "2015-10-20","2999-12-31","2018-09-24","2999-12-31","2015-09-25",
      "2019-11-01","2999-12-31","2018-03-26","2018-08-08","2015-10-13",
      "2999-12-31","2999-12-31","2021-02-11","2999-12-31"
    ),
    format = "%Y-%m-%d"
  )
)

all_dates <- data.table(
  date = seq(from = as.Date('2015-01-01'), to = Sys.Date(), by = "month")
)

result2 <- all_dates[
    small_emp, .N, on = .(date >= begin, date <= end), keyby = .(date)
]

Note that the on specification for a non-equi join is a bit tricky. It is not an arbitrary R expression! It is a list of "operations" of the form lhs_column %binop% rhs_column. Specifically date >= begin is valid because date is a column in the left-hand table all_dates, and begin is a column in the right-hand table small_emp.

Moreover, the names of the columns specified in on will be discarded, and in this case replaced with date.1. I actually don't quite understand the rule here, and it's not well-documented from what I saw. Maybe someone can explain how this works in the comments!

Here is a data.table approach using foverlaps(). Note that that this solution will perform much faster if you reduce your 2999-years to another (closer) unbelievable number like 2099.

library(data.table)
DT <- data.table(start  = begin, end = end)
answer <- data.table(date = seq(min(DT$start), max(DT$end), by = "1 days"))
answer[, dummy := date]
#set keys
setkey(DT, start, end)
setkey(answer, date, dummy)
#overlap join ans summarise
final <- foverlaps(DT, answer)[, .N, by = date]
# 1: 2015-06-29 2
# 2: 2015-06-30 2
# 3: 2015-07-01 3
# 4: 2015-07-02 3
# 5: 2015-07-03 3
# ---             
# 359581: 2999-12-27 8
# 359582: 2999-12-28 8
# 359583: 2999-12-29 8
# 359584: 2999-12-30 8
# 359585: 2999-12-31 8

#create nicer looking intervals
final[, .(from = min(date), to = max(date), N = min(N)), by = (id = rleid(N))]
#    id       from         to  N
# 1:  1 2015-06-29 2015-06-30  2
# 2:  2 2015-07-01 2015-07-05  3
# 3:  3 2015-07-06 2015-07-08 14
# 4:  4 2015-07-09 2015-07-12 15
# 5:  5 2015-07-13 2015-07-26 18
# 6:  6 2015-07-27 2015-09-25 19
# 7:  7 2015-09-26 2015-10-13 18
# 8:  8 2015-10-14 2015-10-20 17
# 9:  9 2015-10-21 2016-01-11 16
#10: 10 2016-01-12 2017-03-13 15
#11: 11 2017-03-14 2018-03-26 14
#12: 12 2018-03-27 2018-08-03 13
#13: 13 2018-08-04 2018-08-08 12
#14: 14 2018-08-09 2018-09-24 11
#15: 15 2018-09-25 2019-11-01 10
#16: 16 2019-11-02 2021-02-11  9
#17: 17 2021-02-12 2999-12-31  8
Related