Performing math across Data.tables by groups with differing granularities

Viewed 75

background

I have 3 long-formatted datasets. Each shares the same columns: dates, multiple grouping columns like country+state+version+item, and a value column. Each dataset is unique in that the dates are recorded at a different time granularity (hourly, daily, monthly) and tracks different items.

goal

My desire is to create three new datasets with all items in all granularities -- but my trouble is with the breaking down of monthly/daily files to the hourly level.

example data.tables

Here is a simplified example of 2 months with only one group column, "country".

library(data.table)
library(lubridate)
dat_hourly <- as.data.table(expand.grid(dates=seq(ymd_h("2020-07-01 0"), ymd_h("2020-08-31 23"),"hour"), 
                                        country = state.abb[1:2],
                                        item = c("visits","clicks")))
dat_daily <- as.data.table(expand.grid(dates=seq(ymd("2020-07-01"), ymd("2020-08-31"),"day"), 
                                       country = state.abb[1:2],
                                       item = c("chats","conversations")))
dat_monthly <- as.data.table(expand.grid(dates=c(ymd("2020-07-01"), ymd("2020-08-01")), 
                                       country = state.abb[1:2],
                                       item = c("deals")))
set.seed(101)
dat_hourly[,value:=rnorm(.N, 10,2)]
dat_daily[,value:=runif(.N, 10,100)]
dat_monthly[,value:=rpois(.N,50)]

Rolling up hourly to daily + monthly, or daily to monthly is easy in data.table language with DT[,sum(value),by=list(floor_date(dates,"day"))] or floor_date(dates,"month")

However, to bring monthly and daily to hourly level, is harder. Assumptions must be made. In this case, I would like to assume that "chats", "conversations" and "deals" can be apportioned out to hourly using the hourly "visits" item. Here i save the variable at the hourly, daily and monthly granularity.

visits_hourly <- dat_hourly[item=="visits"]
visits_daily <- dat_hourly[item=="visits", sum(value), by = .(dates=floor_date(dates,"day"), country, item)]
visits_monthly <- dat_hourly[item=="visits", sum(value), by = .(dates=floor_date(dates,"month"), country, item)]

The math for a breaking a monthly item down to hourly would be to do (monthly_deals / monthly visits) * hourly visits = hourly deals and for daily, (daily_chats / daily visits) * hourly visits = daily chats

However, I can't achieve this at scale. This is the function working for a single month-country-item combination. My dataset has hundreds, with more columns to group than just country.

what I’ve done so far: manually doing one breakdown

#(AK July monthly deals / AK July monthly visits) * AK July hourly visits = AK July hourly deals

AK_monthly_deals_jul <- dat_monthly[country=="AK" & dates==ymd("2020-07-01"),value] 
AK_monthly_visits_jul <- visits_monthly[country=="AK" & dates==ymd("2020-07-01"),V1]
AK_hourly_visits_jul <- visits_hourly[country=="AK" & floor_date(dates,"month")==ymd("2020-07-01")]

AK_hourly_deals_jul_value <- (AK_monthly_deals_jul / AK_monthly_visits_jul) * AK_hourly_visits_jul$value

AK_hourly_deals_jul <- AK_hourly_visits_jul # copy the hourly frame over 
AK_hourly_deals_jul[,c("item","value") := .("deals",AK_hourly_deals_jul_value)] # but update item+value

# will be equal
AK_hourly_deals_jul[,sum(value)]
AK_monthly_deals_jul

what I imagine is possible

I could loop this for every country-item-month combo, and then again for every country-item-day combo, but it seems inefficient and clunky.

Perhaps storing the monthly and daily scalers (i.e, (monthly_items / monthly visits)) upfront in long format alongside values would be the best way, but then I can't figure out again how best to use DT with keys to join and do math across different data.tables.

How might I proceed with this? Open to a better title for this question.

3 Answers

For the sake of completeness, here are three data.table answers which use joins with by = .EACHI, i.e., joins with grouping by each i

  • update joins in wide format,
  • joins in long format without temporary tables
  • EDIT: a flexible solution to break down an arbitrary number of different items in dat_daily and dat_monthly

All answers include consistency checks to verify that the sum of the distributed values match the respective daily or monthly input values.

As mentioned by Evan, ymd_h() returns type POSIXct while ymd() returns type Date but joins need the same data type. Therefore, joins are performed on month and day helper columns of type Date.

Wide format using update joins with grouping by each i

This approach is different to Parfait's answer because it aggregates within the joins and not after the merges in a separate steps.

# append month and day dates to join on
dat_hourly[, month := as_date(floor_date(dates, "month"))]
dat_hourly[, day := as_date(floor_date(dates, "day"))]
# reshape from long to wide format
wide_h <- dcast(dat_hourly, ... ~ item, value.var = "value")
wide_d <- dcast(dat_daily, ... ~ item, value.var = "value")
wide_m <- dcast(dat_monthly, ... ~ item, value.var = "value")
# update joins
wide_h[wide_d, on = .(day = dates, country), chats := sum(chats) / sum(visits) * visits, by = .EACHI][]
wide_h[wide_m, on = .(month = dates, country), deals := sum(deals) / sum(visits) * visits, by = .EACHI][]
wide_h
                    dates country      month        day    visits    clicks     chats      deals
   1: 2020-07-01 00:00:00      AL 2020-07-01 2020-07-01  9.347927 10.796159 0.5266535 0.07363890
   2: 2020-07-01 00:00:00      AK 2020-07-01 2020-07-01  7.807009  7.595993 1.0106696 0.05887379
   3: 2020-07-01 01:00:00      AL 2020-07-01 2020-07-01 11.104924  9.170263 0.6256411 0.08747975
   4: 2020-07-01 01:00:00      AK 2020-07-01 2020-07-01 10.107210 10.641961 1.3084459 0.07621994
   5: 2020-07-01 02:00:00      AL 2020-07-01 2020-07-01  8.650112 10.070654 0.4873393 0.06814181
  ---                                                                                           
2972: 2020-08-31 21:00:00      AK 2020-08-01 2020-08-31  8.565175 11.706004 2.2555053 0.05001779
2973: 2020-08-31 22:00:00      AL 2020-08-01 2020-08-31 11.772985 10.519097 3.1636215 0.07388109
2974: 2020-08-31 22:00:00      AK 2020-08-01 2020-08-31 10.401169 10.422565 2.7389858 0.06073939
2975: 2020-08-31 23:00:00      AL 2020-08-01 2020-08-31 13.340285 12.818345 3.5847845 0.08371664
2976: 2020-08-31 23:00:00      AK 2020-08-01 2020-08-31 12.823834 12.752542 3.3769568 0.07488695
# consistency checks
all.equal(
  wide_h[, sum(chats), keyby = .(day, country)],
  dat_daily[item == "chats"][order(dates, country), -"item"], 
  check.attributes = FALSE
)
[1] TRUE
all.equal(
  wide_h[, sum(deals), keyby = .(month, country)],
  dat_monthly[item == "deals"][order(dates, country), -"item"], 
  check.attributes = FALSE
)
[1] TRUE
# reshape to long
melt(wide_h, id.vars = c("dates", "country"),
     measure.vars = c("visits", "clicks", "chats", "deals"), 
     variable.name = "item")
                     dates country   item       value
    1: 2020-07-01 00:00:00      AL visits  9.34792702
    2: 2020-07-01 00:00:00      AK visits  7.80700912
    3: 2020-07-01 01:00:00      AL visits 11.10492371
    4: 2020-07-01 01:00:00      AK visits 10.10720996
    5: 2020-07-01 02:00:00      AL visits  8.65011231
   ---                                               
11900: 2020-08-31 21:00:00      AK  deals  0.05001779
11901: 2020-08-31 22:00:00      AL  deals  0.07388109
11902: 2020-08-31 22:00:00      AK  deals  0.06073939
11903: 2020-08-31 23:00:00      AL  deals  0.08371664
11904: 2020-08-31 23:00:00      AK  deals  0.07488695

Joins in long format

This approach avoids the reshapes. It uses rbindlist() to append the newly created items to dat_hourly (a union in SQL speak). It also uses setkey() but differently to Evan.

# append month and day dates to join on
dat_hourly[, month := as_date(floor_date(dates, "month"))]
dat_hourly[, day := as_date(floor_date(dates, "day"))]
# set key on item for convenience and speed
setkey(dat_hourly, item)
setkey(dat_daily, item)
setkey(dat_monthly, item)

final <- rbindlist(
  list(
    dat_hourly,
    dat_hourly["visits"][dat_daily["chats"], on = .(day = dates, country), 
                         .(dates, item = "chats", value = i.value * x.value / sum(x.value), month), by = .EACHI],
    dat_hourly["visits"][dat_monthly["deals"], on = .(month = dates, country), 
                         .(dates, item = "deals", value = i.value * x.value / sum(x.value), day), by = .EACHI]
  ), use.names = TRUE)
final
                     dates country   item       value      month        day
    1: 2020-07-01 00:00:00      AL visits  9.34792702 2020-07-01 2020-07-01
    2: 2020-07-01 01:00:00      AL visits 11.10492371 2020-07-01 2020-07-01
    3: 2020-07-01 02:00:00      AL visits  8.65011231 2020-07-01 2020-07-01
    4: 2020-07-01 03:00:00      AL visits 10.42871892 2020-07-01 2020-07-01
    5: 2020-07-01 04:00:00      AL visits 10.62153843 2020-07-01 2020-07-01
   ---                                                                     
11900: 2020-08-31 19:00:00      AK  deals  0.04896503 2020-08-01 2020-08-31
11901: 2020-08-31 20:00:00      AK  deals  0.06868816 2020-08-01 2020-08-31
11902: 2020-08-31 21:00:00      AK  deals  0.05001779 2020-08-01 2020-08-31
11903: 2020-08-31 22:00:00      AK  deals  0.06073939 2020-08-01 2020-08-31
11904: 2020-08-31 23:00:00      AK  deals  0.07488695 2020-08-01 2020-08-31
# consistency checks
setkey(final, item)
all.equal(
  final["chats", sum(value), keyby = .(day, country)],
  dat_daily["chats"][order(dates, country), -"item"], 
  check.attributes = FALSE
)
[1] TRUE
all.equal(
  final["deals", sum(value), keyby = .(month, country)],
  dat_monthly["deals"][order(dates, country), -"item"], 
  check.attributes = FALSE
)
[1] TRUE

After the checks, we may remove the month and day helper columns by

final[, c("month", "day") := NULL][]

EDIT: Flexible solution for arbitrary number of items

The OP has mentioned that "there may be dozens of different items".

The approach below computes the break down values for each item in dat_daily and dat_monthly (including a consistency check). As the code for handling dat_daily and dat_monthly is quite similar (and to comply with the don't repeat youself principle) there is an outer loop over day and month from which the names of the input data.tables dat_daily and dat_monthly are being derived.

It also will deal with item given as factor (as in OP's sample dataset) or character.

Please note that piping (%>% and %T>%) is used to improve readability and because data.table chaining is not sufficient, here.

# append month and day dates to join on
dat_hourly[, month := as_date(floor_date(dates, "month"))]
dat_hourly[, day := as_date(floor_date(dates, "day"))]
# set key on item for convenience and speed
setkey(dat_hourly, item)

library(magrittr)
periods <- c("day", "month")
lapply(
  periods,
  function(p) {
    # create variable name
    dtp <- p %>% 
      stringr::str_replace("day", "dai") %>% 
      sprintf("dat_%sly", .) %>% 
      get()
    setkey(dtp, item)
    # loop over items
    lapply(
      with(dtp, if (is.factor(item)) levels(item) else unique(item)), 
      function(x) {
        dat_hourly["visits"][
          dtp[x], on = c(sprintf("%s == dates", p), "country"), 
          .(dates, item = x, value = i.value * x.value / sum(x.value)), by = .EACHI] %>% 
          setkey(item) %T>% # T pipe
          { # consistency check
            all.equal(
              .[x, sum(value), keyby = c(p, "country")],
              dtp[x][order(dates, country), -"item"], 
              check.attributes = FALSE) %>% 
              stopifnot()
          } 
      }) %>% 
      rbindlist() %>% 
      .[, (p) := NULL]
  }) %>% 
  c(list(dat_hourly), .) %>% 
  rbindlist(fill = TRUE) %>% 
  .[, (periods) := NULL] %>% .[]

Consider a merge approach where you merge monthly and daily by month and then hourly by day but first reshape data to wide to have multiple metric columns (i.e., deals, chats, conversations, visits, clicks) instead of Item-Value pairing.

Final merged data table should be no more than rows in hourly (at wide level). For formulas, first run aggregate columns with ave and then calculate accordingly in final step:

# ADD GRANULAR DATE FIELDS
dat_hourly[, `:=`(day_date=ymd(paste(year(dates), month(dates), day(dates), sep="-")),
                  hour_date=dates)]
dat_daily[, `:=`(month_date=ymd(paste(year(dates), month(dates), 1, sep="-")),
                 day_date=dates)]
dat_monthly[, month_date:=dates]

# REFORMAT WIDE FOR MULTIPLE METRIC COLUMNS
dat_hourly_wide <- dcast(dat_hourly, hour_date + day_date + country  ~ item)
dat_daily_wide <- dcast(dat_daily, day_date + month_date + country ~ item)
dat_monthly_wide <- dcast(dat_monthly, month_date + country ~ item)

# MERGE DATASETS WITH AGG COLUMNS
mdt1 <- merge.data.table(dat_monthly_wide, dat_daily_wide, 
                         by=c("month_date", "country"))

mdt2 <- merge.data.table(mdt1, dat_hourly_wide, by=c("day_date", "country"))[,
            `:=`(monthly_deals = ave(visits, month_date, FUN=sum), 
                 monthly_visits = ave(visits, month_date, FUN=sum),
                 day_chats = ave(chats, day_date, FUN=sum),
                 day_convo = ave(conversations, day_date, FUN=sum))
]

# RUN FINAL FORMULAS
final_dt <- mdt2[, :=`(hourly_deals = (monthly_deals / monthly_visits) * visits,
                       daily_chats = (day_chats / monthly_visits) * visits)]

Output

(Not sure of your final desired result but everything below is at hourly level with higher granularity values repeating. Subset columns and dedupe rows for monthly or daily results.)

> final_dt
        day_date country month_date deals    chats conversations           hour_date    visits    clicks monthly_deals monthly_visits day_chats day_convo hourly_deals daily_chats
   1: 2020-07-01      AL 2020-07-01    58 13.16830      56.90823 2020-07-01 00:00:00  9.347927 10.796159      14788.61       14788.61  1000.617  3235.963     9.347927   0.6324931
   2: 2020-07-01      AL 2020-07-01    58 13.16830      56.90823 2020-07-01 01:00:00 11.104924  9.170263      14788.61       14788.61  1000.617  3235.963    11.104924   0.7513738
   3: 2020-07-01      AL 2020-07-01    58 13.16830      56.90823 2020-07-01 02:00:00  8.650112 10.070654      14788.61       14788.61  1000.617  3235.963     8.650112   0.5852781
   4: 2020-07-01      AL 2020-07-01    58 13.16830      56.90823 2020-07-01 03:00:00 10.428719  9.094955      14788.61       14788.61  1000.617  3235.963    10.428719   0.7056209
   5: 2020-07-01      AL 2020-07-01    58 13.16830      56.90823 2020-07-01 04:00:00 10.621538  8.267015      14788.61       14788.61  1000.617  3235.963    10.621538   0.7186674
  ---                                                                                                                                                                             
2972: 2020-08-31      AK 2020-08-01    44 64.79743      36.97404 2020-08-31 19:00:00  8.384897 10.024813      15024.14       15024.14  3197.743  1965.373     8.384897   1.7846436
2973: 2020-08-31      AK 2020-08-01    44 64.79743      36.97404 2020-08-31 20:00:00 11.762337 11.911283      15024.14       15024.14  3197.743  1965.373    11.762337   2.5034987
2974: 2020-08-31      AK 2020-08-01    44 64.79743      36.97404 2020-08-31 21:00:00  8.565175 11.706004      15024.14       15024.14  3197.743  1965.373     8.565175   1.8230139
2975: 2020-08-31      AK 2020-08-01    44 64.79743      36.97404 2020-08-31 22:00:00 10.401169 10.422565      15024.14       15024.14  3197.743  1965.373    10.401169   2.2137874
2976: 2020-08-31      AK 2020-08-01    44 64.79743      36.97404 2020-08-31 23:00:00 12.823834 12.752542      15024.14       15024.14  3197.743  1965.373    12.823834   2.7294280

I was able to solve what I was looking for with the merge advice from @Parfait.

First the example data:

library(data.table)
library(lubridate)
set.seed(101) # add some random data
dat_hourly <- as.data.table(expand.grid(dates=seq(ymd_h("2020-07-01 0"), ymd_h("2020-08-31 23"),"hour"), 
                                        country = state.abb[1:2],
                                        item = c("visits","clicks")))
dat_daily <- as.data.table(expand.grid(dates=seq(ymd("2020-07-01"), ymd("2020-08-31"),"day"), 
                                       country = state.abb[1:2],
                                       item = c("chats","conversations")))
dat_monthly <- as.data.table(expand.grid(dates=c(ymd("2020-07-01"), ymd("2020-08-01")), 
                                         country = state.abb[1:2],
                                         item = c("deals")))
dat_hourly[,value:=rnorm(.N, 10,2)]
dat_daily[,value:=runif(.N, 10,100)]
dat_monthly[,value:=rpois(.N,50)]
# and one non-country to breakout for fun
dat_monthly_noncountry <- as.data.table(expand.grid(dates=c(ymd("2020-07-01"), ymd("2020-08-01")), 
                                                    item = c("people")))
dat_monthly_noncountry[,value:=rpois(.N,1000)]

# get visits_hourly vector
visits_hourly <- dat_hourly[item=="visits"]
visits_daily <- visits_hourly[, .(value=sum(value)), by = .(dates = as.Date(floor_date(dates,"day")), country, item)]
visits_monthly <- visits_daily[, .(value=sum(value)), by = .(dates= floor_date(dates,"month"), country, item)]
visits_monthly_nocountry <- visits_daily[, .(value=sum(value)), by = .(dates= floor_date(dates,"month"), item)]
# I used as.Date() because it looks like merges require columns to be of the same datatype
# and visits_hourly has dates in "POSIXct" and "POSIXt" type to handle time

Then adding keys for those juicy fast joins

setkeyv(dat_hourly,c("dates","country"))
setkeyv(dat_daily, c("dates","country"))
setkeyv(dat_monthly, c("dates","country"))
setkeyv(dat_monthly_noncountry, "dates")
setkeyv(visits_hourly, c("dates","country"))
setkeyv(visits_daily, c("dates","country")) 
setkeyv(visits_monthly, c("dates","country")) 
setkeyv(visits_monthly_nocountry, c("dates")) 

the merging happens and the scalers are made - love the chaining capability of data.table

# merge by keys "dates","country"
daily_scalers <- dat_daily[visits_daily][,scaler:=value/i.value][,.(dates, country, item, scaler)]
monthly_scalers <- dat_monthly[visits_monthly][,scaler:=value/i.value][,.(dates, country, item, scaler)]
monthly_noncountry_scalers <- dat_monthly_noncountry[visits_monthly_nocountry][,scaler:=value/i.value][,.(dates,item,scaler)]

Then I make a shell data.table for joining hourly visits with the scaler tables. Allowing Cartesian joins is key for adding every combination of hourly-country vs item

breakdown_hourly <- visits_hourly[,.(dates,
                                     month=as.Date(floor_date(dates,"month")),
                                     day=as.Date(floor_date(dates,"day")),
                                     country,
                                     visits=value)]
# merge daily, allowing cartesian will do exactly what I'm looking for 
# with repeating all hourly for each non-hourly item
breakdown_hourly_scalers <- breakdown_hourly[daily_scalers, on=.(day=dates,country=country), allow.cartesian=TRUE]
breakdown_hourly_scalers2 <- breakdown_hourly[monthly_scalers, on=.(month=dates,country=country), allow.cartesian=TRUE]
breakdown_hourly_scalers3 <- breakdown_hourly[monthly_noncountry_scalers, on=.(month=dates), allow.cartesian=TRUE]

Final output

output <- rbind(breakdown_hourly_scalers, breakdown_hourly_scalers2, breakdown_hourly_scalers3)
output[,value:=visits*scaler] 
output_cleaned <- output[,.(dates,country,item,value)]
all_hourly <- rbind(dat_hourly, output_cleaned)
all_daily <- output_cleaned[,.(value=sum(value)), by = .(floor_date(dates,"day"),country,item)]
all_monthly <- output_cleaned[,.(value=sum(value)), by = .(floor_date(dates,"month"),country,item)]


all_monthly
#    floor_date country          item     value
# 1: 2020-07-01      AL         chats 1607.8052
# 2: 2020-07-01      AL conversations 1669.7379
# 3: 2020-07-01      AK         chats 1791.4692
# 4: 2020-07-01      AK conversations 1861.3247
# 5: 2020-08-01      AL         chats 1365.7116
# 6: 2020-08-01      AL conversations 1552.5201
# 7: 2020-08-01      AK         chats 1580.7589
# 8: 2020-08-01      AK conversations 1821.5866
# 9: 2020-07-01      AL         deals   58.0000
#10: 2020-07-01      AK         deals   56.0000
#11: 2020-08-01      AL         deals   47.0000
#12: 2020-08-01      AK         deals   44.0000
#13: 2020-07-01      AL        people  503.8360
#14: 2020-07-01      AK        people  508.1640
#15: 2020-08-01      AL        people  483.5408
#16: 2020-08-01      AK        people  486.4592

dat_monthly # matches perfectly with original data
#        dates country  item value
#1: 2020-07-01      AL deals    58
#2: 2020-07-01      AK deals    56
#3: 2020-08-01      AL deals    47
#4: 2020-08-01      AK deals    44
Related