Repository: NVIDIA-Merlin/NVTabular

Issue:
[OP] Categorify multi-column support
**Is your feature request related to a problem? Please describe.**
Categorify is applied only to single columns - There are use cases, when a user want to apply Categorify to multiple columns.
For example in social media, where a user can write a post and another user can engage with a post.
The dataset has two columns:
- author: userid
- engaging user: userid

| Author  | Engaging User | Post |
| ------------- | ------------- | ------------- |
| User_A  | User_B  | 1 |
| User_A  | User_C  | 2 |
| User_B  | User_A  | 3 |
| User_C  | User_D  | 4 |

Both columns have userids, but I do not want to categorify them independently.

The result should be

| Author  | Engaging User | Post |
| ------------- | ------------- | ------------- |
| 0  | 1  | 1 |
| 0  | 2  | 2 |
| 1  | 0  | 3 |
| 2  | 3  | 4 |




**Describe the solution you'd like**
I want to categorify both column, together.
I concat Author and Engaging User, factorize them and then apply the mapping to both columns

**Additional context**
Pseudocode:
```
uid = pd.factorize( np.concatenate( ( df['a_user_id'].values, df['b_user_id'].values ) ) )[0]
df['a_user_id'] = uid[:df.shape[0] ]
df['b_user_id'] = uid[df.shape[0]:]
```



Discussion:

Resolving patch:
diff --git a/nvtabular/categorify.py b/nvtabular/categorify.py
--- a/nvtabular/categorify.py
+++ b/nvtabular/categorify.py
@@ -29,34 +29,51 @@
 from nvtabular.worker import fetch_table_data, get_worker_cache
 
 
-def _make_name(*args):
-    return "_".join(args)
+def _make_name(*args, sep="_"):
+    return sep.join(args)
 
 
 @annotate("top_level_groupby", color="green", domain="nvt_python")
-def _top_level_groupby(gdf, cat_cols, tree_width, cont_cols, sum_sq, on_host):
+def _top_level_groupby(
+    gdf, cat_col_groups, tree_width, cont_cols, sum_sq, on_host, concat_groups, name_sep
+):
     # Top-level operation for category-based groupby aggregations
     output = {}
     k = 0
-    for i, cat_col in enumerate(cat_cols):
+    for i, cat_col_group in enumerate(cat_col_groups):
+
+        if isinstance(cat_col_group, str):
+            cat_col_group = [cat_col_group]
+        cat_col_group_str = _make_name(*cat_col_group, sep=name_sep)
+
+        if concat_groups and len(cat_col_group) > 1:
+            # Concatenate columns and replace cat_col_group
+            # with the single name
+            df_gb = cudf.DataFrame()
+            ignore_index = True
+            df_gb[cat_col_group_str] = _concat([gdf[col] for col in cat_col_group], ignore_index)
+            cat_col_group = [cat_col_group_str]
+        else:
+            # Compile aggregation dictionary and add "squared-sum"
+            # column(s) (necessary when `cont_cols` is non-empty)
+            df_gb = gdf[cat_col_group + cont_cols].copy(deep=False)
 
-        # Compile aggregation dictionary and add "squared-sum"
-        # column(s) (necessary when `cont_cols` is non-empty)
-        df_gb = gdf[[cat_col] + cont_cols].copy(deep=False)
         agg_dict = {}
-        agg_dict[cat_col] = ["count"]
+        agg_dict[cat_col_group[0]] = ["count"]
         for col in cont_cols:
             agg_dict[col] = ["sum"]
             if sum_sq:
-                name = _make_name(col, "pow2")
+                name = _make_name(col, "pow2", sep=name_sep)
                 df_gb[name] = df_gb[col].pow(2)
                 agg_dict[name] = ["sum"]
 
         # Perform groupby and flatten column index
         # (flattening provides better cudf support)
-        gb = df_gb.groupby(cat_col, dropna=False).agg(agg_dict)
+        gb = df_gb.groupby(cat_col_group, dropna=False).agg(agg_dict)
         gb.columns = [
-            _make_name(*name) if name[0] == cat_col else _make_name(*((cat_col,) + name))
+            _make_name(*(tuple(cat_col_group) + name[1:]), sep=name_sep)
+            if name[0] == cat_col_group[0]
+            else _make_name(*(tuple(cat_col_group) + name), sep=name_sep)
             for name in gb.columns.to_flat_index()
         ]
         gb.reset_index(inplace=True, drop=False)
@@ -64,7 +81,7 @@ def _top_level_groupby(gdf, cat_cols, tree_width, cont_cols, sum_sq, on_host):
 
         # Split the result by the hash value of the categorical column
         for j, split in enumerate(
-            gb.partition_by_hash([cat_col], tree_width[cat_col], keep_index=False)
+            gb.partition_by_hash(cat_col_group, tree_width[cat_col_group_str], keep_index=False)
         ):
             if on_host:
                 output[k] = split.to_pandas()
@@ -76,37 +93,46 @@ def _top_level_groupby(gdf, cat_cols, tree_width, cont_cols, sum_sq, on_host):
 
 
 @annotate("mid_level_groupby", color="green", domain="nvt_python")
-def _mid_level_groupby(dfs, col, cont_cols, agg_list, freq_limit, on_host):
+def _mid_level_groupby(
+    dfs, col_group, cont_cols, agg_list, freq_limit, on_host, concat_groups, name_sep
+):
+
+    if isinstance(col_group, str):
+        col_group = [col_group]
+
+    if concat_groups and len(col_group) > 1:
+        col_group = [_make_name(*col_group, sep=name_sep)]
+
     ignore_index = True
     if on_host:
-        gb = cudf.from_pandas(_concat(dfs, ignore_index)).groupby(col, dropna=False).sum()
+        gb = cudf.from_pandas(_concat(dfs, ignore_index)).groupby(col_group, dropna=False).sum()
     else:
-        gb = _concat(dfs, ignore_index).groupby(col, dropna=False).sum()
+        gb = _concat(dfs, ignore_index).groupby(col_group, dropna=False).sum()
     gb.reset_index(drop=False, inplace=True)
 
-    name_count = _make_name(col, "count")
+    name_count = _make_name(*(col_group + ["count"]), sep=name_sep)
     if freq_limit:
         gb = gb[gb[name_count] >= freq_limit]
 
-    required = [col]
+    required = col_group.copy()
     if "count" in agg_list:
         required.append(name_count)
 
     ddof = 1
     for cont_col in cont_cols:
-        name_sum = _make_name(col, cont_col, "sum")
+        name_sum = _make_name(*(col_group + [cont_col, "sum"]), sep=name_sep)
         if "sum" in agg_list:
             required.append(name_sum)
 
         if "mean" in agg_list:
-            name_mean = _make_name(col, cont_col, "mean")
+            name_mean = _make_name(*(col_group + [cont_col, "mean"]), sep=name_sep)
             required.append(name_mean)
             gb[name_mean] = gb[name_sum] / gb[name_count]
 
         if "var" in agg_list or "std" in agg_list:
             n = gb[name_count]
             x = gb[name_sum]
-            x2 = gb[_make_name(col, cont_col, "pow2", "sum")]
+            x2 = gb[_make_name(*(col_group + [cont_col, "pow2", "sum"]), sep=name_sep)]
             result = x2 - x ** 2 / n
             div = n - ddof
             div[div < 1] = 1
@@ -114,11 +140,11 @@ def _mid_level_groupby(dfs, col, cont_cols, agg_list, freq_limit, on_host):
             result[(n - ddof) == 0] = np.nan
 
             if "var" in agg_list:
-                name_var = _make_name(col, cont_col, "var")
+                name_var = _make_name(*(col_group + [cont_col, "var"]), sep=name_sep)
                 required.append(name_var)
                 gb[name_var] = result
             if "std" in agg_list:
-                name_std = _make_name(col, cont_col, "std")
+                name_std = _make_name(*(col_group + [cont_col, "std"]), sep=name_sep)
                 required.append(name_std)
                 gb[name_std] = np.sqrt(result)
 
@@ -130,43 +156,61 @@ def _mid_level_groupby(dfs, col, cont_cols, agg_list, freq_limit, on_host):
 
 
 @annotate("write_gb_stats", color="green", domain="nvt_python")
-def _write_gb_stats(dfs, base_path, col, on_host):
+def _write_gb_stats(dfs, base_path, col_group, on_host, concat_groups, name_sep):
+    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)
     if on_host:
         df = cudf.from_pandas(df)
-    rel_path = "cat_stats.%s.parquet" % (col)
+    if isinstance(col_group, str):
+        col_group = [col_group]
+    rel_path = "cat_stats.%s.parquet" % (_make_name(*col_group, sep=name_sep))
     path = os.path.join(base_path, rel_path)
     if len(df):
-        df = df.sort_values(col, na_position="first")
+        df = df.sort_values(col_group, na_position="first")
         df.to_parquet(path, write_index=False, compression=None)
     else:
-        df_null = cudf.DataFrame({col: [None]})
-        df_null[col] = df_null[col].astype(df[col].dtype)
+        df_null = cudf.DataFrame({c: [None] for c in col_group})
+        for c in col_group:
+            df_null[c] = df_null[c].astype(df[c].dtype)
         df_null.to_parquet(path, write_index=False, compression=None)
     del df
     return path
 
 
 @annotate("write_uniques", color="green", domain="nvt_python")
-def _write_uniques(dfs, base_path, col, on_host):
+def _write_uniques(dfs, base_path, col_group, on_host, concat_groups, name_sep):
+    if concat_groups and len(col_group) > 1:
+        col_group = [_make_name(*col_group, sep=name_sep)]
     ignore_index = True
+    if isinstance(col_group, str):
+        col_group = [col_group]
     df = _concat(dfs, ignore_index)
     if on_host:
         df = cudf.from_pandas(df)
-    rel_path = "unique.%s.parquet" % (col)
+    rel_path = "unique.%s.parquet" % (_make_name(*col_group, sep=name_sep))
     path = "/".join([base_path, rel_path])
     if len(df):
         # Make sure first category is Null
-        df = df.sort_values(col, na_position="first")
-        if not df[col]._column.has_nulls:
-            df = cudf.DataFrame(
-                {col: _concat([cudf.Series([None], dtype=df[col].dtype), df[col]], ignore_index)}
-            )
+        df = df.sort_values(col_group, na_position="first")
+        new_cols = {}
+        nulls_missing = False
+        for col in col_group:
+            if not df[col]._column.has_nulls:
+                nulls_missing = True
+                new_cols[col] = _concat(
+                    [cudf.Series([None], dtype=df[col].dtype), df[col]], ignore_index
+                )
+            else:
+                new_cols[col] = df[col].copy(deep=False)
+        if nulls_missing:
+            df = cudf.DataFrame(new_cols)
         df.to_parquet(path, write_index=False, compression=None)
     else:
-        df_null = cudf.DataFrame({col: [None]})
-        df_null[col] = df_null[col].astype(df[col].dtype)
+        df_null = cudf.DataFrame({c: [None] for c in col_group})
+        for c in col_group:
+            df_null[c] = df_null[c].astype(df[c].dtype)
         df_null.to_parquet(path, write_index=False, compression=None)
     del df
     return path
@@ -179,7 +223,7 @@ def _finish_labels(paths, cols):
 def _groupby_to_disk(
     ddf,
     write_func,
-    cols,
+    col_groups,
     agg_cols,
     agg_list,
     out_path,
@@ -187,19 +231,30 @@ def _groupby_to_disk(
     tree_width,
     on_host,
     stat_name="categories",
+    concat_groups=False,
+    name_sep="_",
 ):
-    if not cols:
+    if not col_groups:
         return {}
 
+    if concat_groups:
+        if agg_list and agg_list != ["count"]:
+            raise ValueError("Cannot use concat_groups=True with aggregations other than count")
+        if agg_cols:
+            raise ValueError("Cannot aggregate continuous-column stats with concat_groups=True")
+
     # Update tree_width
-    if tree_width is None:
-        tree_width = {c: 8 for c in cols}
-    elif isinstance(tree_width, int):
-        tree_width = {c: tree_width for c in cols}
-    else:
-        for col in cols:
-            if col not in tree_width:
-                tree_width[col] = 8
+    tw = {}
+    for col in col_groups:
+        col = [col] if isinstance(col, str) else col
+        col_str = _make_name(*col, sep=name_sep)
+        if tree_width is None:
+            tw[col_str] = 8
+        elif isinstance(tree_width, int):
+            tw[col_str] = tree_width
+        else:
+            tw[col_str] = tree_width.get(col_str, None) or 8
+    tree_width = tw
 
     # Make dedicated output directory for the categories
     fs = get_fs_token_paths(out_path)[0]
@@ -207,7 +262,7 @@ def _groupby_to_disk(
     fs.mkdirs(out_path, exist_ok=True)
 
     dsk = {}
-    token = tokenize(ddf, cols, out_path, freq_limit, tree_width, on_host)
+    token = tokenize(ddf, col_groups, out_path, freq_limit, tree_width, on_host)
     level_1_name = "level_1-" + token
     split_name = "split-" + token
     level_2_name = "level_2-" + token
@@ -217,20 +272,28 @@ def _groupby_to_disk(
         dsk[(level_1_name, p)] = (
             _top_level_groupby,
             (ddf._name, p),
-            cols,
+            col_groups,
             tree_width,
             agg_cols,
             ("std" in agg_list or "var" in agg_list),
             on_host,
+            concat_groups,
+            name_sep,
         )
         k = 0
-        for c, col in enumerate(cols):
-            for s in range(tree_width[col]):
+        for c, col in enumerate(col_groups):
+            col = [col] if isinstance(col, str) else col
+            col_str = _make_name(*col, sep=name_sep)
+            for s in range(tree_width[col_str]):
                 dsk[(split_name, p, c, s)] = (getitem, (level_1_name, p), k)
                 k += 1
 
-    for c, col in enumerate(cols):
-        for s in range(tree_width[col]):
+    col_groups_str = []
+    for c, col in enumerate(col_groups):
+        col = [col] if isinstance(col, str) else col
+        col_str = _make_name(*col, sep=name_sep)
+        col_groups_str.append(col_str)
+        for s in range(tree_width[col_str]):
             dsk[(level_2_name, c, s)] = (
                 _mid_level_groupby,
                 [(split_name, p, c, s) for p in range(ddf.npartitions)],
@@ -239,27 +302,41 @@ def _groupby_to_disk(
                 agg_list,
                 freq_limit,
                 on_host,
+                concat_groups,
+                name_sep,
             )
 
         dsk[(level_3_name, c)] = (
             write_func,
-            [(level_2_name, c, s) for s in range(tree_width[col])],
+            [(level_2_name, c, s) for s in range(tree_width[col_str])],
             out_path,
             col,
             on_host,
+            concat_groups,
+            name_sep,
         )
 
     dsk[finalize_labels_name] = (
         _finish_labels,
-        [(level_3_name, c) for c, col in enumerate(cols)],
-        cols,
+        [(level_3_name, c) for c, col in enumerate(col_groups)],
+        col_groups_str,
     )
     graph = HighLevelGraph.from_collections(finalize_labels_name, dsk, dependencies=[ddf])
     return graph, finalize_labels_name
 
 
 def _category_stats(
-    ddf, cols, agg_cols, agg_list, out_path, freq_limit, tree_width, on_host, stat_name="categories"
+    ddf,
+    col_groups,
+    agg_cols,
+    agg_list,
+    out_path,
+    freq_limit,
+    tree_width,
+    on_host,
+    stat_name="categories",
+    concat_groups=False,
+    name_sep="_",
 ):
     # Check if we only need categories
     if agg_cols == [] and agg_list == []:
@@ -267,7 +344,7 @@ def _category_stats(
         return _groupby_to_disk(
             ddf,
             _write_uniques,
-            cols,
+            col_groups,
             agg_cols,
             agg_list,
             out_path,
@@ -275,6 +352,8 @@ def _category_stats(
             tree_width,
             on_host,
             stat_name=stat_name,
+            concat_groups=concat_groups,
+            name_sep=name_sep,
         )
 
     # Otherwise, getting category-statistics
@@ -285,7 +364,7 @@ def _category_stats(
     return _groupby_to_disk(
         ddf,
         _write_gb_stats,
-        cols,
+        col_groups,
         agg_cols,
         agg_list,
         out_path,
@@ -293,40 +372,51 @@ def _category_stats(
         tree_width,
         on_host,
         stat_name=stat_name,
+        concat_groups=concat_groups,
+        name_sep=name_sep,
     )
 
 
-def _encode(name, path, gdf, cat_cache, na_sentinel=-1, freq_threshold=0):
+def _encode(name, storage_name, path, gdf, cat_cache, na_sentinel=-1, freq_threshold=0):
     value = None
+    selection_l = name if isinstance(name, list) else [name]
+    selection_r = name if isinstance(name, list) else [storage_name]
     if path:
         if cat_cache is not None:
-            cat_cache = cat_cache if isinstance(cat_cache, str) else cat_cache.get(name, "disk")
+            cat_cache = (
+                cat_cache if isinstance(cat_cache, str) else cat_cache.get(storage_name, "disk")
+            )
             if len(gdf):
                 with get_worker_cache("cats") as cache:
                     value = fetch_table_data(
-                        cache, path, columns=[name], cache=cat_cache, cats_only=True
+                        cache, path, columns=selection_r, cache=cat_cache, cats_only=True
                     )
         else:
-            value = cudf.io.read_parquet(path, index=False, columns=[name])
+            value = cudf.io.read_parquet(path, index=False, columns=selection_r)
             value.index.name = "labels"
             value.reset_index(drop=False, inplace=True)
 
-    vals = gdf[name].copy(deep=False)
     if value is None:
-        value = cudf.DataFrame({name: [None]})
-        value[name] = value[name].astype(vals.dtype)
+        value = cudf.DataFrame()
+        for c in selection_r:
+            typ = gdf[selection_l[0]].dtype if len(selection_l) == 1 else gdf[c].dtype
+            value[c] = cudf.Series([None], dtype=typ)
         value.index.name = "labels"
         value.reset_index(drop=False, inplace=True)
 
     if freq_threshold > 0:
-        codes = cudf.DataFrame({name: vals.copy(), "order": cp.arange(len(vals))})
-        codes = codes.merge(value, on=name, how="left").sort_values("order")["labels"]
+        codes = cudf.DataFrame({"order": cp.arange(len(gdf))})
+        for c in selection_l:
+            codes[c] = gdf[c].copy()
+        codes = codes.merge(
+            value, left_on=selection_l, right_on=selection_r, how="left"
+        ).sort_values("order")["labels"]
         codes.fillna(na_sentinel, inplace=True)
         return codes.values
     else:
         # Use `searchsorted` if we are using a "full" encoding
-        labels = value[name].searchsorted(vals, side="left", na_position="first")
-        labels[labels >= len(value[name])] = na_sentinel
+        labels = value[selection_r].searchsorted(gdf[selection_l], side="left", na_position="first")
+        labels[labels >= len(value[selection_r])] = na_sentinel
         return labels
 
 
@@ -337,3 +427,19 @@ def _read_groupby_stat_df(path, name, cat_cache):
             if cache:
                 return fetch_table_data(cache, path, cache=cat_cache)
     return cudf.io.read_parquet(path, index=False)
+
+
+def _get_multicolumn_names(column_groups, gdf_columns, name_sep):
+    cat_names = []
+    multi_col_group = {}
+    for col_group in column_groups:
+        if isinstance(col_group, list):
+            name = _make_name(*col_group, sep=name_sep)
+            if name not in cat_names:
+                cat_names.append(name)
+                # TODO: Perhaps we should check that all columns from the group
+                #       are in gdf here?
+                multi_col_group[name] = col_group
+        elif col_group in gdf_columns:
+            cat_names.append(col_group)
+    return cat_names, multi_col_group
diff --git a/nvtabular/io.py b/nvtabular/io.py
--- a/nvtabular/io.py
+++ b/nvtabular/io.py
@@ -329,12 +329,10 @@ def add_data(self, gdf):
             gdf.scatter_by_map(ind, map_size=self.num_out_files, keep_index=False)
         ):
             self.num_samples[x] += len(group)
-            # It seems that the `copy()` operations here are necessary
-            # (test_io.py::test_mulifile_parquet fails otherwise)...
             if self.num_threads > 1:
-                self.queue.put((x, group.copy()))
+                self.queue.put((x, group))
             else:
-                self._write_table(x, group.copy())
+                self._write_table(x, group)
 
         # wait for all writes to finish before exiting
         # (so that we aren't using memory)
diff --git a/nvtabular/ops.py b/nvtabular/ops.py
--- a/nvtabular/ops.py
+++ b/nvtabular/ops.py
@@ -17,6 +17,7 @@
 import cupy
 import numpy as np
 from cudf._lib.nvtx import annotate
+from dask.core import flatten
 from dask.delayed import Delayed
 
 from nvtabular import categorify as nvt_cat
@@ -470,7 +471,7 @@ class Normalize(DFOperator):
 
     @property
     def req_stats(self):
-        return [Moments()]
+        return [Moments(columns=self.columns)]
 
     @annotate("Normalize_op", color="darkgreen", domain="nvt_python")
     def op_logic(self, gdf: cudf.DataFrame, target_columns: list, stats_context=None):
@@ -606,7 +607,7 @@ def op_logic(self, gdf: cudf.DataFrame, target_columns: list, stats_context=None
         return new_gdf
 
 
-class CategoryStatistics(StatOperator):
+class GroupbyStatistics(StatOperator):
     """
     Uses groupby aggregation to determine the unique groups of a categorical
     feature and calculates the desired statistics of requested continuous
@@ -624,9 +625,16 @@ class CategoryStatistics(StatOperator):
         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"].
-    columns : list of str, default None
-        Categorical columns to collect statistics for.  If None,
-        the operation will target all known categorical columns.
+    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.
+    concat_groups : bool, default False
+        Applies only if there are list elements in the ``columns`` input. If True,
+        the values within these column groups will be concatenated, and the
+        new (temporary) columns will be used to perform the groupby.  The purpose of
+        this option is to enable multiple columns to be label-encoded jointly.
+        (see Categorify). Note that this option is only allowed for the "count"
+        statistics (with cont_names == None).
     tree_width : dict or int, optional
         Tree width of the hash-based groupby reduction for each categorical
         column. High-cardinality columns may require a large `tree_width`,
@@ -638,12 +646,15 @@ class CategoryStatistics(StatOperator):
         parquet format.
     freq_threshold : int, default 0
         Categories with a `count` statistic less than this number will
-        be omitted from the `CategoryStatistics` output.
+        be omitted from the `GroupbyStatistics` output.
     on_host : bool, default True
         Whether to convert cudf data to pandas between tasks in the hash-based
         groupby reduction. The extra host <-> device data movement can reduce
         performance.  However, using `on_host=True` typically improves stability
         (by avoiding device-level memory pressure).
+    name_sep : str, default "_"
+        String separator to use between concatenated column names
+        for multi-column groups.
     """
 
     def __init__(
@@ -656,8 +667,18 @@ def __init__(
         on_host=True,
         freq_threshold=None,
         stat_name=None,
+        concat_groups=False,
+        name_sep="_",
     ):
-        super(CategoryStatistics, self).__init__(columns)
+        # Set column_groups if the user has passed in a list of columns
+        self.column_groups = None
+        if isinstance(columns, str):
+            columns = [columns]
+        if isinstance(columns, list):
+            self.column_groups = columns
+            columns = list(set(flatten(columns, container=list)))
+
+        super(GroupbyStatistics, self).__init__(columns)
         self.cont_names = cont_names or []
         self.stats = stats or []
         self.categories = {}
@@ -666,15 +687,16 @@ def __init__(
         self.freq_threshold = freq_threshold or 0
         self.out_path = out_path or "./"
         self.stat_name = stat_name or "categories"
-        self.op_name = "CategoryStatistics-" + self.stat_name
+        self.op_name = "GroupbyStatistics-" + self.stat_name
+        self.concat_groups = concat_groups
+        self.name_sep = name_sep
 
     @property
     def _id(self):
         return str(self.op_name)
 
     def stat_logic(self, ddf, columns_ctx, input_cols, target_cols):
-        cols = self.get_columns(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"]
         for op in self.stats:
             if op not in supported_ops:
@@ -684,7 +706,7 @@ def stat_logic(self, ddf, columns_ctx, input_cols, target_cols):
         agg_list = self.stats
         dsk, key = nvt_cat._category_stats(
             ddf,
-            cols,
+            col_groups,
             agg_cols,
             agg_list,
             self.out_path,
@@ -692,6 +714,8 @@ def stat_logic(self, ddf, columns_ctx, input_cols, target_cols):
             self.tree_width,
             self.on_host,
             stat_name=self.stat_name,
+            concat_groups=self.concat_groups,
+            name_sep=self.name_sep,
         )
         return Delayed(key, dsk)
 
@@ -711,11 +735,11 @@ def clear(self):
         return
 
 
-class GroupBy(DFOperator):
+class JoinGroupby(DFOperator):
     """
     One of the ways to create new features is to calculate
-    the basic statistics of the data that is grouped by a categorical
-    feature. This operator groups the data by the given categorical
+    the basic statistics of the data that is grouped by categorical
+    features. This operator groups the data by the given categorical
     feature(s) and calculates the desired statistics of requested continuous
     features (along with the count of rows in each group). The aggregated
     statistics are merged with the data (by joining on the desired
@@ -735,19 +759,22 @@ class GroupBy(DFOperator):
         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"].
-    columns : list of str, default None
-        Categorical columns to target for this operation.  If None,
-        the operation will target all known categorical columns.
+    columns : list of str or list(str), default None
+        Categorical columns (or multi-column "groups") to target for this op.
+        If None, the operation will target all known categorical columns.
     preprocessing : bool, default True
         Sets if this is a pre-processing operation or not
     replace : bool, default False
         This parameter is ignored
     tree_width : dict or int, optional
-        Passed to `CategoryStatistics` dependency.
+        Passed to `GroupbyStatistics` dependency.
     out_path : str, optional
-        Passed to `CategoryStatistics` dependency.
+        Passed to `GroupbyStatistics` dependency.
     on_host : bool, default True
-        Passed to `CategoryStatistics` dependency.
+        Passed to `GroupbyStatistics` dependency.
+    name_sep : str, default "_"
+        String separator to use between concatenated column names
+        for multi-column groups.
     """
 
     default_in = CAT
@@ -764,7 +791,22 @@ def __init__(
         cat_cache="host",
         out_path=None,
         on_host=True,
+        name_sep="_",
     ):
+        self.column_groups = None
+        self.storage_name = {}
+        self.name_sep = name_sep
+        if isinstance(columns, str):
+            columns = [columns]
+        if isinstance(columns, list):
+            self.column_groups = columns
+            columns = list(set(flatten(columns, container=list)))
+            for group in self.column_groups:
+                if isinstance(group, list) and len(group) > 1:
+                    name = nvt_cat._make_name(*group, sep=self.name_sep)
+                    for col in group:
+                        self.storage_name[col] = name
+
         super().__init__(columns=columns, preprocessing=preprocessing, replace=False)
         self.cont_names = cont_names
         self.stats = stats
@@ -777,26 +819,45 @@ def __init__(
     @property
     def req_stats(self):
         return [
-            CategoryStatistics(
-                columns=self.columns,
+            GroupbyStatistics(
+                columns=self.column_groups or self.columns,
+                concat_groups=False,
                 cont_names=self.cont_names,
                 stats=self.stats,
                 tree_width=self.tree_width,
                 out_path=self.out_path,
                 on_host=self.on_host,
                 stat_name=self.stat_name,
+                name_sep=self.name_sep,
             )
         ]
 
     def op_logic(self, gdf: cudf.DataFrame, target_columns: list, stats_context=None):
+
         new_gdf = cudf.DataFrame()
         tmp = "__tmp__"  # Temporary column for sorting
         gdf[tmp] = cupy.arange(len(gdf), dtype="int32")
-        for col, path in stats_context[self.stat_name].items():
-            stat_gdf = nvt_cat._read_groupby_stat_df(path, col, self.cat_cache)
-            tran_gdf = gdf[[col, tmp]].merge(stat_gdf, on=col, how="left")
+        if self.column_groups:
+            cat_names, multi_col_group = nvt_cat._get_multicolumn_names(
+                self.column_groups, gdf.columns, self.name_sep
+            )
+        else:
+            multi_col_group = {}
+            cat_names = [name for name in target_columns if name in gdf.columns]
+
+        for name in cat_names:
+            storage_name = self.storage_name.get(name, name)
+            name = multi_col_group.get(name, name)
+            path = stats_context[self.stat_name][storage_name]
+            selection_l = name.copy() if isinstance(name, list) else [name]
+            selection_r = name if isinstance(name, list) else [storage_name]
+
+            stat_gdf = nvt_cat._read_groupby_stat_df(path, storage_name, self.cat_cache)
+            tran_gdf = gdf[selection_l + [tmp]].merge(
+                stat_gdf, left_on=selection_l, right_on=selection_r, how="left"
+            )
             tran_gdf = tran_gdf.sort_values(tmp)
-            tran_gdf.drop(columns=[col, tmp], inplace=True)
+            tran_gdf.drop(columns=selection_l + [tmp], inplace=True)
             new_cols = [c for c in tran_gdf.columns if c not in new_gdf.columns]
             new_gdf[new_cols] = tran_gdf[new_cols].reset_index(drop=True)
         gdf.drop(columns=[tmp], inplace=True)
@@ -937,20 +998,29 @@ class Categorify(DFOperator):
         Categories with a count/frequency below this threshold will be
         ommited from the encoding and corresponding data will be mapped
         to the "null" category.
-    columns : list of str, default None
-        Categorical columns to target for this operation.  If None,
-        the operation will target all known categorical columns.
+    columns : list of str or list(str), default None
+        Categorical columns (or multi-column "groups") to target for this op.
+        If None, the operation will target all known categorical columns.
+        If columns contains 1+ list(str) elements, the columns within each
+        list/group will be encoded according to the `encode_type` setting.
+    encode_type : {"joint", "combo"}, default "joint"
+        If "joint", the columns within any multi-column group will be
+        jointly encoded. If "combo", the combination of values will be
+        encoded as a new column. Note that replacement is not allowed for
+        "combo", because the same column name can be included in
+        multiple groups.
     preprocessing : bool, default True
         Sets if this is a pre-processing operation or not
     replace : bool, default True
-        Replaces the transformed column with the original input
-        if set Yes
+        Replaces the transformed column with the original input.
+        Note that this does not apply to multi-column groups with
+        `encoded_type="combo"`.
     tree_width : dict or int, optional
-        Passed to `CategoryStatistics` dependency.
+        Passed to `GroupbyStatistics` dependency.
     out_path : str, optional
-        Passed to `CategoryStatistics` dependency.
+        Passed to `GroupbyStatistics` dependency.
     on_host : bool, default True
-        Passed to `CategoryStatistics` dependency.
+        Passed to `GroupbyStatistics` dependency.
     na_sentinel : default 0
         Label to use for null-category mapping
     cat_cache : {"device", "host", "disk"} or dict
@@ -961,6 +1031,9 @@ class Categorify(DFOperator):
     dtype :
         If specified, categorical labels will be cast to this dtype
         after encoding is performed.
+    name_sep : str, default "_"
+        String separator to use between concatenated column names
+        for multi-column groups.
     """
 
     default_in = CAT
@@ -978,7 +1051,71 @@ def __init__(
         cat_cache="host",
         dtype=None,
         on_host=True,
+        encode_type="joint",
+        name_sep="_",
     ):
+
+        # We need to handle three types of encoding here:
+        #
+        #   (1) Conventional encoding. There are no multi-column groups. So,
+        #       each categorical column is separately transformed into a new
+        #       "encoded" column (1-to-1).  The unique values are calculated
+        #       separately for each column.
+        #
+        #   (2) Multi-column "Joint" encoding (there are multi-column groups
+        #       in `columns` and `encode_type="joint"`).  Still a
+        #       1-to-1 transofrmation of categorical columns.  However,
+        #       we concatenate column groups to determine uniques (rather
+        #       than getting uniques of each categorical column separately).
+        #
+        #   (3) Multi-column "Group" encoding (there are multi-column groups
+        #       in `columns` and `encode_type="combo"`). No longer
+        #       a 1-to-1 transformation of categorical columns. Each column
+        #       group will be transformed to a single "encoded" column.  This
+        #       means the unique "values" correspond to unique combinations.
+        #       Since the same column may be included in multiple groups,
+        #       replacement is not allowed for this transform.
+
+        # Set column_groups if the user has passed in a list of columns.
+        # The purpose is to capture multi-column groups. If the user doesn't
+        # specify `columns`, there are no multi-column groups to worry about.
+        self.column_groups = None
+        self.name_sep = name_sep
+
+        # For case (2), we need to keep track of the multi-column group name
+        # that will be used for the joint encoding of each column in that group.
+        # For case (3), we also use this "storage name" to signify the name of
+        # the file with the required "combination" groupby statistics.
+        self.storage_name = {}
+
+        if isinstance(columns, str):
+            columns = [columns]
+        if isinstance(columns, list):
+            # User passed in a list of column groups. We need to figure out
+            # if this list contains any multi-column groups, and if there
+            # are any (obvious) problems with these groups
+            self.column_groups = columns
+            columns = list(set(flatten(columns, container=list)))
+            columns_all = list(flatten(columns, container=list))
+            if sorted(columns_all) != sorted(columns) and encode_type == "joint":
+                # If we are doing "joint" encoding, there must be unique mapping
+                # between input column names and column groups.  Otherwise, more
+                # than one unique-value table could be used to encode the same
+                # column.
+                raise ValueError("Same column name included in multiple groups.")
+            for group in self.column_groups:
+                if isinstance(group, list) and len(group) > 1:
+                    # For multi-column groups, we concatenate column names
+                    # to get the "group" name.
+                    name = nvt_cat._make_name(*group, sep=self.name_sep)
+                    for col in group:
+                        self.storage_name[col] = name
+
+        # Only support two kinds of multi-column encoding
+        if encode_type not in ("joint", "combo"):
+            raise ValueError(f"encode_type={encode_type} not supported.")
+
+        # Other self-explanatory intialization
         super().__init__(columns=columns, preprocessing=preprocessing, replace=replace)
         self.freq_threshold = freq_threshold
         self.out_path = out_path or "./"
@@ -988,12 +1125,14 @@ def __init__(
         self.on_host = on_host
         self.cat_cache = cat_cache
         self.stat_name = "categories"
+        self.encode_type = encode_type
 
     @property
     def req_stats(self):
         return [
-            CategoryStatistics(
-                columns=self.columns,
+            GroupbyStatistics(
+                columns=self.column_groups or self.columns,
+                concat_groups=self.encode_type == "joint",
                 cont_names=[],
                 stats=[],
                 freq_threshold=self.freq_threshold,
@@ -1001,23 +1140,57 @@ def req_stats(self):
                 out_path=self.out_path,
                 on_host=self.on_host,
                 stat_name=self.stat_name,
+                name_sep=self.name_sep,
             )
         ]
 
     @annotate("Categorify_op", color="darkgreen", domain="nvt_python")
-    def op_logic(self, gdf: cudf.DataFrame, target_columns: list, stats_context={}):
-        cat_names = target_columns
-        new_gdf = cudf.DataFrame()
-        if not cat_names:
-            return gdf
-        cat_names = [name for name in cat_names if name in gdf.columns]
-        new_cols = []
+    def apply_op(
+        self,
+        gdf: cudf.DataFrame,
+        columns_ctx: dict,
+        input_cols,
+        target_cols=["base"],
+        stats_context={},
+    ):
+        new_gdf = gdf.copy(deep=False)
+        target_columns = self.get_columns(columns_ctx, input_cols, target_cols)
+        if not target_columns:
+            return new_gdf
+
+        if self.column_groups and not self.encode_type == "joint":
+            # Case (3) - We want to track multi- and single-column groups separately
+            #            when we are NOT performing a joint encoding. This is because
+            #            there is not a 1-to-1 mapping for columns in multi-col groups.
+            #            We use `multi_col_group` to preserve the list format of
+            #            multi-column groups only, and use `cat_names` to store the
+            #            string representation of both single- and multi-column groups.
+            #
+            cat_names, multi_col_group = nvt_cat._get_multicolumn_names(
+                self.column_groups, gdf.columns, self.name_sep
+            )
+        else:
+            # Case (1) & (2) - Simple 1-to-1 mapping
+            multi_col_group = {}
+            cat_names = [name for name in target_columns if name in gdf.columns]
+
+        # Encode each column-group separately
         for name in cat_names:
             new_col = f"{name}_{self._id}"
-            new_cols.append(new_col)
-            path = stats_context[self.stat_name][name]
+
+            # Use the column-group `list` directly (not the string name)
+            use_name = multi_col_group.get(name, name)
+            # Storage name may be different than group for case (2)
+            # Only use the "aliased" `storage_name` if we are dealing with
+            # a multi-column group, or if we are doing joint encoding
+            if use_name != name or self.encode_type == "joint":
+                storage_name = self.storage_name.get(name, name)
+            else:
+                storage_name = name
+            path = stats_context[self.stat_name][storage_name]
             new_gdf[new_col] = nvt_cat._encode(
-                name,
+                use_name,
+                storage_name,
                 path,
                 gdf,
                 self.cat_cache,
@@ -1026,6 +1199,15 @@ def op_logic(self, gdf: cudf.DataFrame, target_columns: list, stats_context={}):
             )
             if self.dtype:
                 new_gdf[new_col] = new_gdf[new_col].astype(self.dtype, copy=False)
+
+        # Deal with replacement
+        if self.replace:
+            for name in cat_names:
+                new_col = f"{name}_{self._id}"
+                new_gdf[name] = new_gdf[new_col]
+                new_gdf.drop(columns=[new_col], inplace=True)
+
+        self.update_columns_ctx(columns_ctx, input_cols, new_gdf.columns, target_columns)
         return new_gdf
 
 


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,7 @@ def test_dask_groupby_stats(client, tmpdir, datasets, part_mem_fraction):
     )
 
     processor.add_preprocess(
-        ops.GroupBy(cont_names=cont_names, stats=["count", "sum", "std"], out_path=str(tmpdir))
+        ops.JoinGroupby(cont_names=cont_names, stats=["count", "sum", "std"], out_path=str(tmpdir))
     )
     processor.finalize()
 
@@ -205,7 +205,7 @@ def test_cats_and_groupby_stats(client, tmpdir, datasets, part_mem_fraction, use
     processor.add_preprocess(ops.Categorify(out_path=str(tmpdir), freq_threshold=10, on_host=True))
 
     processor.add_cat_feature(
-        ops.GroupBy(cont_names=cont_names, stats=["count", "sum"], out_path=str(tmpdir))
+        ops.JoinGroupby(cont_names=cont_names, stats=["count", "sum"], out_path=str(tmpdir))
     )
 
     processor.finalize()
diff --git a/tests/unit/test_ops.py b/tests/unit/test_ops.py
--- a/tests/unit/test_ops.py
+++ b/tests/unit/test_ops.py
@@ -14,9 +14,11 @@
 # limitations under the License.
 #
 import math
+import os
 
 import cudf
 import numpy as np
+import pandas as pd
 import pytest
 from cudf.tests.utils import assert_eq
 from pandas.api.types import is_integer_dtype
@@ -99,7 +101,7 @@ def test_encoder(tmpdir, df, dataset, gpu_memory_frac, engine, op_columns):
     cont_names = ["x", "y", "id"]
     label_name = ["label"]
 
-    encoder = ops.CategoryStatistics(columns=op_columns)
+    encoder = ops.GroupbyStatistics(columns=op_columns)
     config = nvt.workflow.get_new_config()
     config["PP"]["categorical"] = [encoder]
 
@@ -118,6 +120,37 @@ def test_encoder(tmpdir, df, dataset, gpu_memory_frac, engine, op_columns):
     assert cats1.tolist() == [None] + cats_expected1.tolist()
 
 
+@pytest.mark.parametrize("engine", ["parquet"])
+@pytest.mark.parametrize("groups", [[["name-cat", "name-string"], "name-cat"], "name-string"])
+@pytest.mark.parametrize("concat_groups", [True, False])
+def test_multicolumn_cats(tmpdir, df, dataset, engine, groups, concat_groups):
+    cat_names = ["name-cat", "name-string"]
+    cont_names = ["x", "y", "id"]
+    label_name = ["label"]
+
+    encoder = ops.GroupbyStatistics(
+        columns=groups,
+        cont_names=None if concat_groups else ["x"],
+        stats=None if concat_groups else ["count", "mean"],
+        out_path=str(tmpdir),
+        concat_groups=concat_groups,
+    )
+    config = nvt.workflow.get_new_config()
+    config["PP"]["categorical"] = [encoder]
+
+    processor = nvt.Workflow(
+        cat_names=cat_names, cont_names=cont_names, label_name=label_name, config=config
+    )
+    processor.update_stats(dataset)
+
+    groups = [groups] if isinstance(groups, str) else groups
+    for group in groups:
+        group = [group] if isinstance(group, str) else group
+        prefix = "unique." if concat_groups else "cat_stats."
+        fn = prefix + "_".join(group) + ".parquet"
+        cudf.read_parquet(os.path.join(tmpdir, "categories", fn))
+
+
 @pytest.mark.parametrize("gpu_memory_frac", [0.01, 0.1])
 @pytest.mark.parametrize("engine", ["parquet", "csv", "csv-no-header"])
 @pytest.mark.parametrize("op_columns", [["x"], None])
@@ -230,7 +263,7 @@ def test_normalize(tmpdir, df, dataset, gpu_memory_frac, engine, op_columns):
     label_name = ["label"]
 
     config = nvt.workflow.get_new_config()
-    config["PP"]["continuous"] = [ops.Moments()]
+    config["PP"]["continuous"] = [ops.Moments(columns=op_columns)]
 
     processor = nvtabular.Workflow(
         cat_names=cat_names, cont_names=cont_names, label_name=label_name, config=config
@@ -242,7 +275,7 @@ def test_normalize(tmpdir, df, dataset, gpu_memory_frac, engine, op_columns):
 
     columns_ctx = {}
     columns_ctx["continuous"] = {}
-    columns_ctx["continuous"]["base"] = cont_names
+    columns_ctx["continuous"]["base"] = op_columns or cont_names
 
     new_gdf = op.apply_op(df, columns_ctx, "continuous", stats_context=processor.stats)
     df["x"] = (df["x"] - processor.stats["means"]["x"]) / processor.stats["stds"]["x"]
@@ -518,6 +551,110 @@ def test_lambdaop(tmpdir, df, dataset, gpu_memory_frac, engine, client):
     assert np.sum(df_pp["x_mul0_add100"] < 100) == 0
 
 
+@pytest.mark.parametrize("groups", [[["Author", "Engaging User"]], None])
+@pytest.mark.parametrize("kind", ["joint", "combo"])
+def test_categorify_multi(tmpdir, groups, kind):
+
+    df = pd.DataFrame(
+        {
+            "Author": ["User_A", "User_E", "User_B", "User_C"],
+            "Engaging User": ["User_B", "User_B", "User_A", "User_D"],
+            "Post": [1, 2, 3, 4],
+        }
+    )
+
+    cat_names = ["Author", "Engaging User"]
+    cont_names = []
+    label_name = ["Post"]
+
+    processor = nvt.Workflow(cat_names=cat_names, cont_names=cont_names, label_name=label_name)
+
+    processor.add_preprocess(ops.Categorify(columns=groups, out_path=str(tmpdir), encode_type=kind))
+    processor.finalize()
+    processor.apply(nvt.Dataset(df), output_format=None)
+    df_out = processor.get_ddf().compute(scheduler="synchronous")
+
+    if groups:
+        if kind == "joint":
+            # Columns are encoded jointly
+            assert df_out["Author"].to_arrow().to_pylist() == [1, 5, 2, 3]
+            assert df_out["Engaging User"].to_arrow().to_pylist() == [2, 2, 1, 4]
+        else:
+            # Column combinations are encoded
+            assert df_out["Author_Engaging User"].to_arrow().to_pylist() == [1, 4, 2, 3]
+    else:
+        # Columns are encoded independently
+        assert df_out["Author"].to_arrow().to_pylist() == [1, 4, 2, 3]
+        assert df_out["Engaging User"].to_arrow().to_pylist() == [2, 2, 1, 3]
+
+
+def test_categorify_multi_combo(tmpdir):
+
+    groups = [["Author", "Engaging User"], ["Author"], "Engaging User"]
+    kind = "combo"
+    df = pd.DataFrame(
+        {
+            "Author": ["User_A", "User_E", "User_B", "User_C"],
+            "Engaging User": ["User_B", "User_B", "User_A", "User_D"],
+            "Post": [1, 2, 3, 4],
+        }
+    )
+
+    cat_names = ["Author", "Engaging User"]
+    cont_names = []
+    label_name = ["Post"]
+
+    processor = nvt.Workflow(cat_names=cat_names, cont_names=cont_names, label_name=label_name)
+
+    processor.add_preprocess(ops.Categorify(columns=groups, out_path=str(tmpdir), encode_type=kind))
+    processor.finalize()
+    processor.apply(nvt.Dataset(df), output_format=None)
+    df_out = processor.get_ddf().compute(scheduler="synchronous")
+
+    # Column combinations are encoded
+    assert df_out["Author"].to_arrow().to_pylist() == [1, 4, 2, 3]
+    assert df_out["Engaging User"].to_arrow().to_pylist() == [2, 2, 1, 3]
+    assert df_out["Author_Engaging User"].to_arrow().to_pylist() == [1, 4, 2, 3]
+
+
+@pytest.mark.parametrize("groups", [[["Author", "Engaging-User"]], "Author"])
+def test_joingroupby_multi(tmpdir, groups):
+
+    df = pd.DataFrame(
+        {
+            "Author": ["User_A", "User_A", "User_A", "User_B"],
+            "Engaging-User": ["User_B", "User_B", "User_C", "User_C"],
+            "Cost": [100.0, 200.0, 300.0, 400.0],
+            "Post": [1, 2, 3, 4],
+        }
+    )
+
+    cat_names = ["Author", "Engaging-User"]
+    cont_names = ["Cost"]
+    label_name = ["Post"]
+
+    processor = nvt.Workflow(cat_names=cat_names, cont_names=cont_names, label_name=label_name)
+
+    processor.add_preprocess(
+        ops.JoinGroupby(columns=groups, out_path=str(tmpdir), stats=["sum"], cont_names=["Cost"])
+    )
+    processor.finalize()
+    processor.apply(nvt.Dataset(df), output_format=None)
+    df_out = processor.get_ddf().compute(scheduler="synchronous")
+
+    if isinstance(groups, list):
+        # Join on ["Author", "Engaging-User"]
+        assert df_out["Author_Engaging-User_Cost_sum"].to_arrow().to_pylist() == [
+            300.0,
+            300.0,
+            300.0,
+            400.0,
+        ]
+    else:
+        # Join on ["Author"]
+        assert df_out["Author_Cost_sum"].to_arrow().to_pylist() == [600.0, 600.0, 600.0, 400.0]
+
+
 @pytest.mark.parametrize("engine", ["parquet"])
 @pytest.mark.parametrize("kind_ext", ["cudf", "pandas", "arrow", "parquet", "csv"])
 @pytest.mark.parametrize("cache", ["host", "device"])