Repository: NVIDIA-Merlin/NVTabular

Issue:
Decoupling ZeroFill from Relu op
Currently the ZeroFill op also implicitly does something akin to a Relu op i.e. replacing negative values with 0 via `z_gdf[z_gdf < 0] = 0`.  I think these two behaviours should be decoupled and Relu should be made into an explicit op.



```
class ZeroFill(TransformOperator):
    default_in = CONT
    default_out = CONT

    @annotate("ZeroFill_op", color="darkgreen", domain="nvt_python")
    def op_logic(self, gdf: cudf.DataFrame, target_columns: list, stats_context=None):
        cont_names = target_columns
        if not cont_names:
            return gdf
        z_gdf = gdf[cont_names].fillna(0)
        z_gdf.columns = [f"{col}_{self._id}" for col in z_gdf.columns]
        z_gdf[z_gdf < 0] = 0
        return z_gdf
```


Discussion:

Resolving patch:
diff --git a/examples/dask-nvtabular-criteo-benchmark.py b/examples/dask-nvtabular-criteo-benchmark.py
--- a/examples/dask-nvtabular-criteo-benchmark.py
+++ b/examples/dask-nvtabular-criteo-benchmark.py
@@ -114,7 +114,7 @@ def main(args):
     processor = Workflow(
         cat_names=cat_names, cont_names=cont_names, label_name=label_name, client=client
     )
-    processor.add_feature([ops.ZeroFill(), ops.LogOp()])
+    processor.add_feature([ops.FillMissing(), ops.Clip(min_value=0), ops.LogOp()])
     processor.add_preprocess(
         ops.Categorify(
             out_path=out_path,
diff --git a/nvtabular/ops.py b/nvtabular/ops.py
--- a/nvtabular/ops.py
+++ b/nvtabular/ops.py
@@ -336,26 +336,38 @@ def clear(self):
         return
 
 
-class ZeroFill(TransformOperator):
+class Clip(TransformOperator):
     """
-    This operation sets negative values to zero.
-
-    Although you can directly call methods of this class to
-    transform your continuous features, it's typically used within a
-    Workflow class.
+    This operation clips values continous values so that they are with a min/max bound.
+    For instance by setting the min value to 0, you can replace all negative values with 0.
+    This is helpful in cases where you want to log normalize values.
     """
 
     default_in = CONT
     default_out = CONT
 
-    @annotate("ZeroFill_op", color="darkgreen", domain="nvt_python")
+    def __init__(
+        self, min_value=None, max_value=None, columns=None, preprocessing=True, replace=True
+    ):
+        if min_value is None and max_value is None:
+            raise ValueError("Must specify a min or max value to clip to")
+        super().__init__(columns=columns, preprocessing=preprocessing, replace=replace)
+        self.min_value = min_value
+        self.max_value = max_value
+
+    @annotate("Clip_op", color="darkgreen", domain="nvt_python")
     def op_logic(self, gdf: cudf.DataFrame, target_columns: list, stats_context=None):
         cont_names = target_columns
         if not cont_names:
             return gdf
-        z_gdf = gdf[cont_names].fillna(0)
+
+        z_gdf = gdf[cont_names]
         z_gdf.columns = [f"{col}_{self._id}" for col in z_gdf.columns]
-        z_gdf[z_gdf < 0] = 0
+        if self.min_value is not None:
+            z_gdf[z_gdf < self.min_value] = self.min_value
+        if self.max_value is not None:
+            z_gdf[z_gdf > self.max_value] = self.max_value
+
         return z_gdf
 
 
diff --git a/nvtabular/workflow.py b/nvtabular/workflow.py
--- a/nvtabular/workflow.py
+++ b/nvtabular/workflow.py
@@ -150,7 +150,7 @@ def add_cont_feature(self, operators):
         Parameters
         -----------
         operators : object
-            continuous objects such as ZeroFill and LogOp
+            continuous objects such as FillMissing, Clip and LogOp
         """
 
         operators = self.op_default_check(operators, "continuous")


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
@@ -75,7 +75,7 @@ def test_dask_workflow_api_dlrm(
         client=client, cat_names=cat_names, cont_names=cont_names, label_name=label_name
     )
 
-    processor.add_feature([ops.ZeroFill(), ops.LogOp()])
+    processor.add_feature([ops.FillMissing(), ops.Clip(min_value=0), ops.LogOp()])
     processor.add_preprocess(
         ops.Categorify(
             freq_threshold=freq_threshold,
diff --git a/tests/unit/test_io.py b/tests/unit/test_io.py
--- a/tests/unit/test_io.py
+++ b/tests/unit/test_io.py
@@ -119,7 +119,13 @@ def test_hugectr(
     processor = nvt.Workflow(
         client=client, cat_names=cat_names, cont_names=cont_names, label_name=label_names
     )
-    processor.add_feature([ops.ZeroFill(columns=op_columns), ops.LogOp()])
+    processor.add_feature(
+        [
+            ops.FillMissing(columns=op_columns),
+            ops.Clip(min_value=0, columns=op_columns),
+            ops.LogOp(),
+        ]
+    )
     processor.add_preprocess(ops.Normalize())
     processor.add_preprocess(ops.Categorify())
     processor.finalize()
diff --git a/tests/unit/test_s3.py b/tests/unit/test_s3.py
--- a/tests/unit/test_s3.py
+++ b/tests/unit/test_s3.py
@@ -56,7 +56,7 @@ def test_s3_dataset(s3, paths, engine, df):
 
     processor = nvt.Workflow(cat_names=cat_names, cont_names=cont_names, label_name=label_name,)
 
-    processor.add_feature([ops.ZeroFill(), ops.LogOp()])
+    processor.add_feature([ops.FillMissing(), ops.Clip(min_value=0), ops.LogOp()])
     processor.add_preprocess(ops.Normalize())
     processor.add_preprocess(ops.Categorify(cat_cache="host"))
     processor.finalize()
diff --git a/tests/unit/test_workflow.py b/tests/unit/test_workflow.py
--- a/tests/unit/test_workflow.py
+++ b/tests/unit/test_workflow.py
@@ -50,7 +50,9 @@ def test_gpu_workflow_api(
         client=client if use_client else None,
     )
 
-    processor.add_feature([ops.ZeroFill(columns=op_columns), ops.LogOp()])
+    processor.add_feature(
+        [ops.FillMissing(), ops.Clip(min_value=0, columns=op_columns), ops.LogOp()]
+    )
     processor.add_preprocess(ops.Normalize())
     processor.add_preprocess(ops.Categorify(cat_cache="host"))
     processor.finalize()
@@ -69,7 +71,7 @@ def get_norms(tar: cudf.Series):
         gdf = np.log(gdf + 1)
         return gdf
 
-    # Check mean and std - No good right now we have to add all other changes; Zerofill, Log
+    # Check mean and std - No good right now we have to add all other changes; Clip, Log
 
     if not op_columns:
         assert math.isclose(get_norms(df.y).mean(), processor.stats["means"]["y"], rel_tol=1e-1)
@@ -120,8 +122,8 @@ def test_gpu_workflow(tmpdir, client, df, dataset, gpu_memory_frac, engine, dump
     label_name = ["label"]
 
     config = nvt.workflow.get_new_config()
-    config["FE"]["continuous"] = [ops.ZeroFill()]
-    config["PP"]["continuous"] = [[ops.ZeroFill(), ops.Normalize()]]
+    config["FE"]["continuous"] = [ops.FillMissing(), ops.Clip(min_value=0)]
+    config["PP"]["continuous"] = [[ops.FillMissing(), ops.Clip(min_value=0), ops.Normalize()]]
     config["PP"]["categorical"] = [ops.Categorify()]
 
     processor = nvt.Workflow(
@@ -146,12 +148,8 @@ def get_norms(tar: cudf.Series):
 
     assert math.isclose(get_norms(df.x).mean(), processor.stats["means"]["x"], rel_tol=1e-4)
     assert math.isclose(get_norms(df.y).mean(), processor.stats["means"]["y"], rel_tol=1e-4)
-    #     assert math.isclose(get_norms(df.id).mean(),
-    #                         processor.stats["means"]["id_ZeroFill_LogOp"], rel_tol=1e-4)
     assert math.isclose(get_norms(df.x).std(), processor.stats["stds"]["x"], rel_tol=1e-3)
     assert math.isclose(get_norms(df.y).std(), processor.stats["stds"]["y"], rel_tol=1e-3)
-    #     assert math.isclose(get_norms(df.id).std(),
-    #                         processor.stats["stds"]["id_ZeroFill_LogOp"], rel_tol=1e-3)
 
     # Check that categories match
     if engine == "parquet":
@@ -218,7 +216,7 @@ def get_norms(tar: cudf.Series):
         gdf = np.log(gdf + 1)
         return gdf
 
-    # Check mean and std - No good right now we have to add all other changes; Zerofill, Log
+    # Check mean and std - No good right now we have to add all other changes; Clip, Log
 
     concat_ops = "_FillMissing_LogOp"
     if replace: