Repository: NVIDIA-Merlin/NVTabular

Issue:
[OP] Add min/max support to groupby op
We want to be able to calculate the minimum timestamp of an item from each interaction. This minimum timestamp will be used as the age of the item. The groupby op currently can compute several statistics like mean,std,var etc - but needs to be extended to support min/max to be able to calculate this.




Discussion:
@benfred this is actually required by Outbrain also. 

Resolving patch:
diff --git a/nvtabular/categorify.py b/nvtabular/categorify.py
--- a/nvtabular/categorify.py
+++ b/nvtabular/categorify.py
@@ -35,8 +35,12 @@ def _make_name(*args, sep="_"):
 
 @annotate("top_level_groupby", color="green", domain="nvt_python")
 def _top_level_groupby(
-    gdf, cat_col_groups, tree_width, cont_cols, sum_sq, on_host, concat_groups, name_sep
+    gdf, cat_col_groups, tree_width, cont_cols, agg_list, on_host, concat_groups, name_sep
 ):
+    sum_sq = "std" in agg_list or "var" in agg_list
+    calculate_min = "min" in agg_list
+    calculate_max = "max" in agg_list
+
     # Top-level operation for category-based groupby aggregations
     output = {}
     k = 0
@@ -67,6 +71,11 @@ def _top_level_groupby(
                 df_gb[name] = df_gb[col].pow(2)
                 agg_dict[name] = ["sum"]
 
+            if calculate_min:
+                agg_dict[col].append("min")
+            if calculate_max:
+                agg_dict[col].append("max")
+
         # Perform groupby and flatten column index
         # (flattening provides better cudf support)
         gb = df_gb.groupby(cat_col_group, dropna=False).agg(agg_dict)
@@ -103,11 +112,11 @@ def _mid_level_groupby(
     if concat_groups and len(col_group) > 1:
         col_group = [_make_name(*col_group, sep=name_sep)]
 
-    ignore_index = True
+    df = _concat(dfs, ignore_index=True)
     if on_host:
-        gb = cudf.from_pandas(_concat(dfs, ignore_index)).groupby(col_group, dropna=False).sum()
-    else:
-        gb = _concat(dfs, ignore_index).groupby(col_group, dropna=False).sum()
+        df = cudf.from_pandas(df)
+    groups = df.groupby(col_group, dropna=False)
+    gb = groups.agg({col: _get_aggregation_type(col) for col in df.columns if col not in col_group})
     gb.reset_index(drop=False, inplace=True)
 
     name_count = _make_name(*(col_group + ["count"]), sep=name_sep)
@@ -129,6 +138,14 @@ def _mid_level_groupby(
             required.append(name_mean)
             gb[name_mean] = gb[name_sum] / gb[name_count]
 
+        if "min" in agg_list:
+            name_min = _make_name(*(col_group + [cont_col, "min"]), sep=name_sep)
+            required.append(name_min)
+
+        if "max" in agg_list:
+            name_max = _make_name(*(col_group + [cont_col, "max"]), sep=name_sep)
+            required.append(name_max)
+
         if "var" in agg_list or "std" in agg_list:
             n = gb[name_count]
             x = gb[name_sum]
@@ -155,6 +172,15 @@ def _mid_level_groupby(
     return gb[required]
 
 
+def _get_aggregation_type(col):
+    if col.endswith("_min"):
+        return "min"
+    elif col.endswith("_max"):
+        return "max"
+    else:
+        return "sum"
+
+
 @annotate("write_gb_stats", color="green", domain="nvt_python")
 def _write_gb_stats(dfs, base_path, col_group, on_host, concat_groups, name_sep):
     if concat_groups and len(col_group) > 1:
@@ -275,7 +301,7 @@ def _groupby_to_disk(
             col_groups,
             tree_width,
             agg_cols,
-            ("std" in agg_list or "var" in agg_list),
+            agg_list,
             on_host,
             concat_groups,
             name_sep,
diff --git a/nvtabular/ops.py b/nvtabular/ops.py
--- a/nvtabular/ops.py
+++ b/nvtabular/ops.py
@@ -636,7 +636,7 @@ class GroupbyStatistics(StatOperator):
         List of statistics to calculate for each unique group. Note
         that "count" corresponds to the group itself, while all
         other statistics correspond to a specific continuous column.
-        Supported statistics include ["count", "sum", "mean", "std", "var"].
+        Supported statistics include ["count", "sum", "mean", "std", "var", "min", "max"].
     columns : list of str or list(str), default None
         Categorical columns (or "column groups") to collect statistics for.
         If None, the operation will target all known categorical columns.
@@ -709,7 +709,7 @@ def _id(self):
 
     def stat_logic(self, ddf, columns_ctx, input_cols, target_cols):
         col_groups = self.column_groups or self.get_columns(columns_ctx, input_cols, target_cols)
-        supported_ops = ["count", "sum", "mean", "std", "var"]
+        supported_ops = ["count", "sum", "mean", "std", "var", "min", "max"]
         for op in self.stats:
             if op not in supported_ops:
                 raise ValueError(op + " operation is not supported.")


Test patch:
diff --git a/tests/unit/test_dask_nvt.py b/tests/unit/test_dask_nvt.py
--- a/tests/unit/test_dask_nvt.py
+++ b/tests/unit/test_dask_nvt.py
@@ -147,7 +147,9 @@ def test_dask_groupby_stats(client, tmpdir, datasets, part_mem_fraction):
     )
 
     processor.add_preprocess(
-        ops.JoinGroupby(cont_names=cont_names, stats=["count", "sum", "std"], out_path=str(tmpdir))
+        ops.JoinGroupby(
+            cont_names=cont_names, stats=["count", "sum", "std", "min"], out_path=str(tmpdir)
+        )
     )
     processor.finalize()
 
@@ -173,6 +175,16 @@ def test_dask_groupby_stats(client, tmpdir, datasets, part_mem_fraction):
         check_names=False,
     )
 
+    # Check "min"
+    assert_eq(
+        result[["name-string", "name-string_x_min"]]
+        .drop_duplicates()
+        .sort_values("name-string")["name-string_x_min"],
+        df0.groupby("name-string").agg({"x": "min"})["x"],
+        check_index=False,
+        check_names=False,
+    )
+
     # Check "std"
     assert_eq(
         result[["name-string", "name-string_x_std"]]