Skip to content

Implement DT[, across(.SD, fun1, fun2, fun3), by=group] #4970

Description

@mattdowle

Inspired by dplyr::across and triggered by JuliaData/DataFrames.jl#2725 (comment)

Instead of :

DT[, unlist(lapply(.SD, function(x) c(max=max(x), min=min(x)))), by=group]

it could be

DT[, across(.SD, min, max), by=group]

I didn't find any related issues or PRs in a quick search. If there are any, and S.O. questions, please link them here.

Activity

  1. avimallu commented on Apr 29, 2021

    @avimallu
    Contributor

    My 2¢:
    While having across implemented similarly to dplyr::across will have utility if implemented only with .SD, I think its utility can be increased further if we could get across to work with a character, patterns or .SD to allow it to work the way dplyr::across allows. Example from here:

    df %>%
      group_by(g1, g2) %>% 
      summarise(
        across(where(is.numeric), mean), 
        across(where(is.factor), nlevels),
        n = n(), 
      )

    Where is this useful?
    Creating different summary statistics (mean, median, unique counts, count of rows, % of any x over y) instead of doing a join after creating those columns, or chaining. Something that gives good flexibility (like a lapply and .SD currently do):

    as.data.table(Lahman::Batting)[, .(
      across(patterns("$R^|(X.B)|HR"), .(sum, mean)),
      across(c("stint", "teamID"), .(last, uniqueN)),
      across(.SD, .(uniqueN, \(x) sum(x)/uniqueN(yearID))),
      playerID,
      .SDcols = c("R", "IBB", "SO")]

    My understanding of baseball is a little rusty. What I was aiming for was to create a single table by payer that gives me

    1. the sum and mean of runs, doubles, triples and home-runs, followed by
    2. the last and unique count of stints and teams player for, followed by
    3. the number of runs, international walks and strikeouts per year

    all in one shot. I often have to revert to summarizing my data in Excel or, if the data is too big, calculate all these independently in R and do a join at the end. In this specific case, we could even employ the proposed mergelist #4370 to create these efficiently, perhaps in parallel? It'll be amazing to have the ability to create this in R.

    P.S. - I realize that .SD provides a list, while the others provide character vectors. I was aiming for more flexibility as opposed to using only .SD. It might also work if there was a way to split .SD into .SD_1, .SD_2 etc, but that would probably be a bit much.

  2. myoung3 commented on Jul 1, 2021

    @myoung3
    Contributor

    Hi @mattdowle, I think what you're proposing here is closely related to what's being discussed in #1063, specifically the discussion around "colwise". #1063 groups together row operations and column operations into one issue, but they seem pretty separate to me (and I think rowwise operations would be better solved by implementing more functions like pmin/pmax as you suggested for psum in #3467).

    I also like your suggestion of across (rather than colwise), and the proposed syntax since it will be familiar to dplyr users. It seems like you're suggesting the second argument be "..." to take an arbitrary number of functions, but I think it would be better if the second argument took a single function or a list like dplyr across (https://dplyr.tidyverse.org/reference/across.html).

    Before we can implement across, I think we should solve #2311 by merging my PR #4883, since this addresses how columns are named in this situation.

    Once #4883 is merged, we could just implement across so that it expands into several lapply(.SD,) calls concatenated together with c(). This will ensure GForce optimization is used without any additional work. E.g:

    x <- data.table(a=1:3,b=1:3)
    x[, across(.SD, list(min=min, max=max)]  
    

    would just internally be expanded to

    x[, c(min=lapply(.SD, min), max=lapply(.SD, max))]
    

    and the resulting column names would be c("min.a", "min.b", "max.a", "max.b") which is consistent with base R and how naming is implemented in #4883.

    An outstanding question is how we might name columns when functions are not explicitly named:

    x[, across(.SD, list(min, max)] 
    

    Interactively it would be convenient for them to be c("min.a", "min.b", "max.a", "max.b") without explicitly tagging each function, but this might break down when the list of functions is specified programmatically so perhapsc("F1.a", "F1.b", "F2.a", "F2.b") is more predictable.

  3. myoung3 commented on Jul 1, 2021

    @myoung3
    Contributor

    Also see this discussion with Hadley (tidyverse/dtplyr#173) on translating dplyr::across to data.table syntax for the dtplyr package.

    Note that the dplyr across allows arbitrary specification of how the function name and input column names are combined to determine how the output columns are named (specifying both order and the separator) but I'm not sure that's a road we want to go down (see the names argument here: https://dplyr.tidyverse.org/reference/across.html) . Sticking with base R's naming behavior (e.g. c(A=list(a=3,2), B=list(a=1,b=2)) ) will be much easier to maintain since naming in across will just rely directly on how naming works for x[, c(A=lapply(.SD), B=lapply(.SD))] (once #4883 is merged) without any additional magic code unique to across.

  4. r2evans commented on Nov 17, 2024

    @r2evans
    Contributor

    Any thought to reinvigorating this? #4883 was merged a couple of months ago. The code there is a little more detailed than I want to dive in on to try to implement this myself (at least, not at this moment).

    BTW, I further suggest the first argument should default to the current .SD, such as

    across = function(x = .SD, funs) {
    }

    As for the notion of funs = list(min, max), I suggest one of two paths:

    • require non-empty names, fail if is.null(names(funs)) || any(!nzchar(names(funs))); or
    • for empty names, use V1, V2, and/or other counting names.

    Part of me wants to go with the first option to remove any/all ambiguity, but I'm conscious of interactive usability.

  5. MichaelChirico commented on Jul 6, 2025

    @MichaelChirico
    Member

    As of now, I'm not sure we should implement this. {dtplyr} does a decent job of acting as translation layer:

    library(dplyr)
    library(dtplyr)
    
    iris |>
      lazy_dt() |>
      summarize(across(matches("^Sepal"), sum)) |>
      show_query()
    # `_DT6`[, .(Sepal.Length = sum(Sepal.Length), Sepal.Width = sum(Sepal.Width))]
    
    iris |>
      lazy_dt() |>
      summarize(across(matches("^Sepal"), sum), .by="Species") |>
      show_query()
    # `_DT8`[, .(Sepal.Length = sum(Sepal.Length), Sepal.Width = sum(Sepal.Width)), 
    #     keyby = .(Species)]
    
    # showing that GForce still gets invoked & is not lost in translation
    options(datatable.verbose=TRUE)
    iris |>
      lazy_dt() |>
      summarize(across(matches("^Sepal"), sum), .by="Species")
    # ...
    # ... GForce optimized j to 'list(gsum(Sepal.Length), gsum(Sepal.Width))' (see ?GForce)
    # ...

    I haven't tested it extensively, but I'm loath to take on new maintenance overhead for something another package already does well. If there are specific feature requests/poor translations, those can be addressed.

    Leaving this open for now for further input.

  6. jangorecki commented on Jul 7, 2025

    @jangorecki
    Member

    When closing this, maybe we could write, here in a comment, example across implementation that redirects to unlist lapply, so whoever needs can just copy to their package. It is possibly a one liner.

  7. r2evans commented on Jul 7, 2025

    @r2evans
    Contributor

    @jangorecki, do you mean a rough comparison like this?

    library(dtplyr)
    lazy_dt(iris) |>
      summarize(across(matches("^Sepal"), list(sum=sum, med=median)), .by = Species) |>
      collect()
    # # A tibble: 3 × 5
    #   Species    Sepal.Length_sum Sepal.Length_med Sepal.Width_sum Sepal.Width_med
    #   <fct>                 <dbl>            <dbl>           <dbl>           <dbl>
    # 1 setosa                 250.              5              171.             3.4
    # 2 versicolor             297.              5.9            139.             2.8
    # 3 virginica              329.              6.5            149.             3  

    versus

    as.data.table(iris) |>
      _[, Map(\(fun, nm) setNames(lapply(.SD, fun), paste0(names(.SD), "_", nm)),
              list(sum, median), c("sum", "med")) |> unlist(recursive = FALSE),
         .SDcols = patterns("Sepal"), by = "Species"]
    #       Species Sepal.Length_sum Sepal.Width_sum Sepal.Length_med Sepal.Width_med
    #        <fctr>            <num>           <num>            <num>           <num>
    # 1:     setosa            250.3           171.4              5.0             3.4
    # 2: versicolor            296.8           138.5              5.9             2.8
    # 3:  virginica            329.4           148.7              6.5             3.0

    (There is likely an easier to do this?)

    There's also a hybrid approach, though I don't think this addresses the OP, though it does not return a data.table:

    library(dplyr)
    as.data.table(iris) |>
      _[, summarize(.SD, across(matches("Sepal"), list(sum=sum, med=median))), by = "Species" ]
    #       Species Sepal.Length_sum Sepal.Length_med Sepal.Width_sum Sepal.Width_med
    #        <fctr>            <num>            <num>           <num>           <num>
    # 1:     setosa            250.3              5.0           171.4             3.4
    # 2: versicolor            296.8              5.9           138.5             2.8
    # 3:  virginica            329.4              6.5           148.7             3.0
    
    ### Note that this is a data.frame, not a data.table or tibble
    as.data.table(iris) |>
      _[, summarize(.SD, .by = Species, across(matches("Sepal"), list(sum=sum, med=median)))]
    #      Species Sepal.Length_sum Sepal.Length_med Sepal.Width_sum Sepal.Width_med
    # 1     setosa            250.3              5.0           171.4             3.4
    # 2 versicolor            296.8              5.9           138.5             2.8
    # 3  virginica            329.4              6.5           148.7             3.0

    (I wish R had two native functions: lapply where the inner func knows the names similar to purrr::imap, and a form of outer that doesn't use simple vectors. Wishlist. Not for data.table.)

  8. MichaelChirico commented on Jul 7, 2025

    @MichaelChirico
    Member

    The challenge will be that:

    1. .cols= argument builds on the rich {tidyselect} column selection language. It will be nontrivial to replicate that with {base} -- essentially equivalent to re-writing a whole package
    2. .names= argument also packages glue()-formatted column name generation

    https://dplyr.tidyverse.org/reference/across.html

    So I think producing a fully-compatible across() equivalent will be a major undertaking. Something like Bill's comment that hints at how to do it will suffice to get people going and then adapt to their specific needs.

  9. r2evans commented on Jul 7, 2025

    @r2evans
    Contributor

    While I'm not arguing that onerous maintenance should not be avoided, I don't think those two challenges are an issue unless I'm missing something in NSE nuances. For 1 (.cols=) we can use existing .SDcols= mechanisms; and for 2 (.names=) I think the glue-mechanism is a nice convenience but not strictly required, I think .sep= may suffice?

    facross <- function(x, .funs, .sep = "_") {
      if (is.null(names(.funs))) names(.funs) <- ""
      noname <- !nzchar(names(.funs))
      names(.funs)[noname] <- paste0("V", cumsum(noname[noname]))
      Map(\(fun, nm) setNames(lapply(x, unname(fun)), paste(names(x), nm, sep = .sep)),
          .funs, names(.funs)) |>
        unname() |>
        unlist(recursive = FALSE)
    }
    
    as.data.table(iris) |>
      _[, facross(.SD, list(sum=sum, median)), by = "Species", .SDcols = patterns("Sepal")]
    #       Species Sepal.Length_sum Sepal.Width_sum Sepal.Length_V1 Sepal.Width_V1
    #        <fctr>            <num>           <num>           <num>          <num>
    # 1:     setosa            250.3           171.4             5.0            3.4
    # 2: versicolor            296.8           138.5             5.9            2.8
    # 3:  virginica            329.4           148.7             6.5            3.0
    
    ### same thing, pipeless
    as.data.table(iris)[, facross(.SD, list(sum=sum, median)), by = "Species", .SDcols = patterns("Sepal")]
    
    ### same thing, pipeless formatted
    as.data.table(iris)[,
      facross(.SD, list(sum=sum, median)),
      by = "Species",
      .SDcols = patterns("Sepal")]

    (This could be extended to support ... instead of .funs=, though if we're looking for some basic similarity with dplyr::across, its allowance of named/unnamed arguments in ... is deprecated. I suspect the rationale for the deprecation is covering a reasonable (though likely rare) corner case that somebody would want to name a column one of "x", ".funs", or ".sep" here.)

    Edit: to better align with some data.table function-naming conventions, I renamed this from dt_across() to facross(). :-)

  10. jangorecki commented on Jul 7, 2025

    @jangorecki
    Member

    I think average user does not need missing names handling version, then it can be simpler.
    Pipes make code less clear actually - why not dt_across(as.data.table(iris), ...)

  11. r2evans commented on Jul 7, 2025

    @r2evans
    Contributor

    I find utility in both piped and pipeless: using pipes can be visually simplifying, one step per line, so the line with dt_across(.) doesn't have much space preserved for precursor requirements (not that as.data.table(.) is onerous). Using the pipe there reduces it so that the "only thing" on the line is just what is required for the operation. But there are times when I prefer pipe-less as well, no judgement really, as always "it depends".

    However, doing dt_across(as.data.table(iris), ..) disables my suggested use of .SDcols= to address MichaelChirico's first issue of convenient column selection. I think suggesting dt_across() should have its own internal column-selection mechanism is as MichaelChirico suggested more complicated than is strictly necessary. Demonstrating it within [.data.table allows us to capitalize on the existing data.table-methodology.

    I updated my comment above for a pipeless alternative, is that more along the lines of what you were thinking, @jangorecki ?

    Edit: see my renaming of it, my OCD was clicking a little hard just now :-)

  12. jangorecki commented on Jul 8, 2025

    @jangorecki
    Member

    Sorry I won't be able to really look at this anytime soon. Others can join and move it forward.

  13. trobx commented on Dec 30, 2025

    @trobx

    @r2evans

    • Unfortunately facross as it stands blocks GForce optimisation
    • You'd need the ... but reserved for additional args to the funs like na.rm
  14. trobx commented on Dec 31, 2025

    @trobx

    @r2evans Worth thinking about the possibilities of env here too. I actually visited this issue because I've been looking more at env thanks to that SO question last week about programming a multi-argument (variadic) condition in i, and your question to me about how we could generalise the solution I gave. (I ought to thank you for that because it sent me to the programming vignette for a close read!)

    What I came up with was pairing do.call with the fact that, when the character names are in a list, substitute2 not only converts them to names but also constructs a list call:

    as.data.table(iris)[do.call(pmin, cols) < 3, .N, by=Species,
                        env=list(cols=as.list(c("Sepal.Length","Sepal.Width")))]
    
    # Argument 'i' after substitute: do.call(pmin, list(Sepal.Length, Sepal.Width)) < 3
    

    But playing around with env and a couple of helper functions (make_call and ucalls, at the bottom), the possibilities are very rich. For a start we can just write the expression directly:

    as.data.table(iris)[T < 3, .N, by=Species, verbose=TRUE,
                        env=list(T=make_call("pmin", c("Sepal.Length","Sepal.Width")))]
    
    # Argument 'i' after substitute: pmin(Sepal.Length, Sepal.Width) < 3
    

    And we can also use env to achieve the across type of query you are talking about, allowing for extra common arguments and not obstructing GForce. Switching to the now-inbuilt penguins since it has NAs:

    as.data.table(penguins)[T < 20, j, by="species",
                            env=list(T=make_call("pmin", c("bill_len","bill_dep")),
                                     j=c(".N", ucalls(c("mean","median"),
                                                      c("flipper_len","body_mass"),
                                                      na.rm=TRUE,
                                                      over="cols")))]
    
    Argument 'j' after substitute: list(.N, flipper_len_mean = mean(flipper_len, na.rm = TRUE), flipper_len_median = median(flipper_len, na.rm = TRUE), body_mass_mean = mean(body_mass, na.rm = TRUE), body_mass_median = median(body_mass, na.rm = TRUE))
    Argument 'i' after substitute: pmin(bill_len, bill_dep) < 20
    (...)
    GForce optimized j to 'list(.N, gmean(flipper_len, na.rm = TRUE), gmedian(flipper_len, na.rm = TRUE), gmean(body_mass, na.rm = TRUE), gmedian(body_mass, na.rm = TRUE))' (see ?GForce)
    (...)
         species     N flipper_len_mean flipper_len_median body_mass_mean body_mass_median
          <fctr> <int>            <num>              <num>          <num>            <num>
    1:    Adelie   133         189.4211                190       3644.173             3600
    2:    Gentoo   123         217.1870                216       5076.016             5000
    3: Chinstrap    63         195.3810                195       3700.397             3700
    

    These are the helpers - simple stuff. The "u" is for unary:

    make_call <- function(fun, cols, ...) {
      dots <- as.list(substitute(list(...)))[-1]
      as.call(c(list(as.name(fun)), lapply(cols, as.name), dots))
    }
    
    ucalls <- function(funs, cols, over=c("funs","cols"), named=TRUE, ...) {
      if (match.arg(over)=="funs") {
        ans <- lapply(funs, \(f) lapply(cols, \(c) make_call(f,c,...)))
        if (isTRUE(named)) nms <- paste(rep(cols,times=length(funs)),rep(funs,each=length(cols)),sep="_")
      } else {
        ans <- lapply(cols, \(c) lapply(funs, \(f) make_call(f,c,...)))
        if (isTRUE(named)) nms <- paste(rep(cols,each=length(funs)),rep(funs,times=length(cols)),sep="_")
      }
      ans <- do.call(c, ans)
      if (isTRUE(named)) names(ans) <- nms
      ans
    }
    

    Not the same as an inbuilt solution (and uses standard evaluation) but might be food for thought anyway. It's certainly brought home to me just how amazingly useful and well conceived env is.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions