Repository: sql-machine-learning/elasticdl

Issue:
Support convert Tensor to RaggedTensor dropping the ignored value.
The format of the sequence is usually a string with separator such as the sentence in a text with whitespace separator. And the number of words in each sentence is not the same. We can split the sentences to a `tf.RaggedTensor`. Then we may want to drop some values like the empty string.

For example:
```python
data = np.array([["Hello world"], [""], ["Welcome to ElasticDL"]])
```
1. Split strings to 
```python
data = tf.RaggedTensor([["Hello", "world"], [""], ["Welcome", "to", "ElasticDL"]])
```
2. Drop the empty string to
```python
data = tf.RaggedTensor([["Hello", "world"], [], ["Welcome", "to", "ElasticDL"]])
```
3. Transform strings in the ragged tensor to integer ids by `Lookup` or `Hashing`
```python
data = tf.RaggedTensor([[0, 1], [], [2, 3, 4]])
```
4. Convert it to `Tensor` with padding by `tf.RaggedTensor.to_tensor(shape=(None, 3))`
```python
data = data.to_tensor(shape=(None,3))
tf.Tensor([[0, 1, 0], [0, 0 ,0], [2, 3, 4]])
```


Discussion:

Resolving patch:
diff --git a/elasticdl_preprocessing/layers/to_ragged.py b/elasticdl_preprocessing/layers/to_ragged.py
new file mode 100644
--- /dev/null
+++ b/elasticdl_preprocessing/layers/to_ragged.py
@@ -0,0 +1,75 @@
+import tensorflow as tf
+
+_COMMA_SEP = ","
+
+
+class ToRagged(tf.keras.layers.Layer):
+    """Converts a `Tensor` or `RaggedTensor` to a `RaggedTensor`,
+    dropping ignore_value cells. If the input's dtype is string, split
+    the string elements to convert the input to `RaggedTensor` firstly.
+
+    Note that the TensorFlow version with the layer must be greater than 2.0.0.
+
+    Example (Integer):
+    ```python
+        layer = ToRagged()
+        input_tensor = tf.constant([[1], [-1], [4]], tf.int64)
+        out = layer(input_tensor)
+        [[1], [], [4]]
+    ```
+
+    Example (String):
+    ```python
+        layer = ToRagged()
+        input_tensor = tf.constant([["1", "2", "3"], ["4", "5"], [""]])
+        out = layer(input_tensor)
+    ```
+    The expected output is `[["1", "2", "3"], ["4", "5"], []]`
+
+    Arguments:
+        sep: Valid if the input's dtype is string.
+        ignore_value: Entries in inputs equal to this value will be
+            absent from the output `RaggedTensor`. If `None`, default value of
+            input's dtype will be used ('' for `str`, -1 for `int`).
+
+    Input shape: A numeric or string `Tensor` or `RaggedTensor` of shape
+        `[batch_size, d1, ..., dm]`
+
+    Output shape: An `RaggedTensor` with the same shape as inputs
+    """
+
+    def __init__(self, sep=_COMMA_SEP, ignore_value=None):
+        super(ToRagged, self).__init__()
+        self.sep = sep
+        self.ignore_value = ignore_value
+
+    def call(self, inputs):
+        if not isinstance(inputs, (tf.Tensor, tf.RaggedTensor)):
+            raise TypeError(
+                "The inputs must be a Tensor or RaggedTensor and "
+                "the type of inputs is {}".format(type(inputs))
+            )
+
+        if isinstance(inputs, tf.Tensor):
+            inputs = tf.RaggedTensor.from_tensor(inputs)
+
+        ignore_value = self._get_ignore_value(inputs.dtype)
+        if ignore_value is None:
+            return inputs
+        else:
+            return tf.ragged.boolean_mask(
+                inputs, tf.not_equal(inputs, ignore_value)
+            )
+
+    def _get_ignore_value(self, input_dtype):
+        ignore_value = self.ignore_value
+
+        if ignore_value is None:
+            if input_dtype == tf.string:
+                ignore_value = ""
+            elif input_dtype.is_integer:
+                ignore_value = -1
+            else:
+                return None
+
+        return tf.cast(ignore_value, input_dtype)


Test patch:
diff --git a/elasticdl_preprocessing/tests/test_utils.py b/elasticdl_preprocessing/tests/test_utils.py
--- a/elasticdl_preprocessing/tests/test_utils.py
+++ b/elasticdl_preprocessing/tests/test_utils.py
@@ -32,21 +32,10 @@ def ragged_tensor_equal(rt_a, rt_b):
     if rt_a.shape.as_list() != rt_b.shape.as_list():
         return False
 
-    for i in range(rt_a.shape[0]):
-        sub_rt_a = rt_a[i]
-        sub_rt_b = rt_b[i]
-        if isinstance(sub_rt_a, tf.RaggedTensor) and isinstance(
-            sub_rt_b, tf.RaggedTensor
-        ):
-            if not ragged_tensor_equal(sub_rt_a, sub_rt_b):
-                return False
-        elif isinstance(sub_rt_a, tf.Tensor) and isinstance(
-            sub_rt_b, tf.Tensor
-        ):
-            if sub_rt_a.dtype != sub_rt_b.dtype:
-                return False
-            if not np.array_equal(sub_rt_a.numpy(), sub_rt_b.numpy()):
-                return False
-        else:
-            return False
+    if rt_a.dtype != rt_b.dtype:
+        return False
+
+    if rt_a.to_list() != rt_b.to_list():
+        return False
+
     return True
diff --git a/elasticdl_preprocessing/tests/to_ragged_test.py b/elasticdl_preprocessing/tests/to_ragged_test.py
new file mode 100644
--- /dev/null
+++ b/elasticdl_preprocessing/tests/to_ragged_test.py
@@ -0,0 +1,34 @@
+import unittest
+
+import tensorflow as tf
+
+from elasticdl_preprocessing.layers.to_ragged import ToRagged
+from elasticdl_preprocessing.tests.test_utils import ragged_tensor_equal
+
+
+class ToRaggedTest(unittest.TestCase):
+    def test_dense_to_ragged(self):
+        layer = ToRagged()
+        input_data = tf.constant([[1], [-1], [4]], tf.int64)
+        out = layer(input_data)
+        expected_out = tf.ragged.constant([[1], [], [4]], tf.int64)
+        self.assertTrue(ragged_tensor_equal(out, expected_out))
+
+    def test_string_split_to_ragged(self):
+        layer = ToRagged()
+        input_data = tf.ragged.constant([["1", "2", "3"], ["4", "5"], [""]])
+        out = layer(input_data)
+        expected_out = tf.ragged.constant([["1", "2", "3"], ["4", "5"], []])
+        self.assertTrue(ragged_tensor_equal(out, expected_out))
+
+    def test_model_with_ragged(self):
+        inputs = tf.keras.Input(shape=(1,), dtype=tf.int32)
+        ragged = ToRagged(ignore_value=-1)(inputs)
+        sum_out = tf.reduce_sum(ragged)
+        model = tf.keras.Model(inputs=inputs, outputs=sum_out)
+        sum_value = model.call(tf.constant([[1], [-1], [4]])).numpy()
+        self.assertEqual(sum_value, 5.0)
+
+
+if __name__ == "__main__":
+    unittest.main()