Repository: keras-team/keras-nlp

Issue:
XMLRobertaPreprocessor should handle lists of strings as a batch
Once #504 lands, we should mirror changes for XMLRoberta.

Discussion:

Patch:
diff --git a/keras_nlp/models/xlm_roberta/xlm_roberta_preprocessor.py b/keras_nlp/models/xlm_roberta/xlm_roberta_preprocessor.py
index 2b907a6f..31f7ad14 100644
--- a/keras_nlp/models/xlm_roberta/xlm_roberta_preprocessor.py
+++ b/keras_nlp/models/xlm_roberta/xlm_roberta_preprocessor.py
@@ -25,6 +25,10 @@ from keras_nlp.models.xlm_roberta.xlm_roberta_presets import backbone_presets
 from keras_nlp.models.xlm_roberta.xlm_roberta_tokenizer import (
     XLMRobertaTokenizer,
 )
+from keras_nlp.utils.keras_utils import (
+    convert_inputs_to_list_of_tensor_segments,
+)
+from keras_nlp.utils.keras_utils import pack_x_y_sample_weight
 from keras_nlp.utils.python_utils import classproperty
 from keras_nlp.utils.python_utils import format_docstring
 
@@ -35,23 +39,30 @@ class XLMRobertaPreprocessor(keras.layers.Layer):
 
     This preprocessing layer will do three things:
 
-    - Tokenize any number of inputs using `tokenizer`.
+    - Tokenize any number of input segments using the `tokenizer`.
     - Pack the inputs together with the appropriate `"
"`, `"
"` and
       `"<pad>"` tokens, i.e., adding a single `"
"` at the start of the
       entire sequence, `"

"` at the end of each segment, save the last
       and a `"
"` at the end of the entire sequence.
-    - Construct a dictionary with keys `"token_ids"` and `"padding_mask"`
+    - Construct a dictionary with keys `"token_ids"` and `"padding_mask"`,
       that can be passed directly to a XLM-RoBERTa model.
 
-    Note that the original fairseq implementation modifies the indices of the
-    SentencePiece tokenizer output. To preserve compatibility, we make the same
-    changes, i.e., `"
"`, `"<pad>"`, `"
"` and `"<unk>"` are mapped to
-    0, 1, 2, 3, respectively, and non-special token indices are shifted right
-    by one. Keep this in mind if generating your own vocabulary for tokenization.
+    This layer can be used directly with `tf.data.Dataset.map` to preprocess
+    string data in the `(x, y, sample_weight)` format used by
+    `keras.Model.fit`.
+
+    The call method of this layer accepts three arguments, `x`, `y`, and
+    `sample_weight`. `x` can be a python string or tensor representing a single
+    segment, a list of python strings representing a batch of single segments,
+    or a list of tensors representing multiple segments to be packed together.
+    `y` and `sample_weight` are both optional, can have any format, and will be
+    passed through unaltered.
 
-    This layer will accept either a tuple of (possibly batched) inputs, or a
-    single input tensor. If a single tensor is passed, it will be packed
-    equivalently to a tuple with a single element.
+    Special care should be taken when using `tf.data` to map over an unlabeled
+    tuple of string segments. `tf.data.Dataset.map` will unpack this tuple
+    directly into the call arguments of this layer, rather than forward all
+    argument to `x`. To handle this case, it is recommended to  explicitly call
+    the layer, e.g. `ds.map(lambda seg1, seg2: preprocessor(x=(seg1, seg2)))`.
 
     Args:
         tokenizer: A `keras_nlp.tokenizers.XLMRobertaTokenizer` instance.
@@ -75,28 +86,62 @@ class XLMRobertaPreprocessor(keras.layers.Layer):
         sequence_length=10,
     )
 
-    # Tokenize and pack a single sentence directly.
+    # Tokenize and pack a single sentence.
+    sentence = tf.constant("The quick brown fox jumped.")
+    preprocessor(sentence)
+    # Same output.
     preprocessor("The quick brown fox jumped.")
 
-    # Tokenize and pack a multiple sentence directly.
-    preprocessor(("The quick brown fox jumped.", "Call me Ishmael."))
+    # Tokenize and a batch of single sentences.
+    sentences = tf.constant(
+        ["The quick brown fox jumped.", "Call me Ishmael."]
+    )
+    preprocessor(sentences)
+    # Same output.
+    preprocessor(
+        ["The quick brown fox jumped.", "Call me Ishmael."]
+    )
+
+    # Tokenize and pack a sentence pair.
+    first_sentence = tf.constant("The quick brown fox jumped.")
+    second_sentence = tf.constant("The fox tripped.")
+    preprocessor((first_sentence, second_sentence))
 
     # Map a dataset to preprocess a single sentence.
-    features = ["The quick brown fox jumped.", "I forgot my homework."]
-    labels = [0, 1]
+    features = tf.constant(
+        ["The quick brown fox jumped.", "Call me Ishmael."]
+    )
+    labels = tf.constant([0, 1])
     ds = tf.data.Dataset.from_tensor_slices((features, labels))
-    ds = ds.map(
-        lambda x, y: (preprocessor(x), y),
-        num_parallel_calls=tf.data.AUTOTUNE,
+    ds = ds.map(preprocessor, num_parallel_calls=tf.data.AUTOTUNE)
+
+    # Map a dataset to preprocess sentence pairs.
+    first_sentences = tf.constant(
+        ["The quick brown fox jumped.", "Call me Ishmael."]
+    )
+    second_sentences = tf.constant(
+        ["The fox tripped.", "Oh look, a whale."]
+    )
+    labels = tf.constant([1, 1])
+    ds = tf.data.Dataset.from_tensor_slices(
+        (
+            (first_sentences, second_sentences), labels
+        )
     )
+    ds = ds.map(preprocessor, num_parallel_calls=tf.data.AUTOTUNE)
 
-    # Map a dataset to preprocess a multiple sentences.
-    first_sentences = ["The quick brown fox jumped.", "Call me Ishmael."]
-    second_sentences = ["The fox tripped.", "Oh look, a whale."]
-    labels = [1, 1]
-    ds = tf.data.Dataset.from_tensor_slices(((first_sentences, second_sentences), labels))
+    # Map a dataset to preprocess unlabeled sentence pairs.
+    first_sentences = tf.constant(
+        ["The quick brown fox jumped.", "Call me Ishmael."]
+    )
+    second_sentences = tf.constant(
+        ["The fox tripped.", "Oh look, a whale."]
+    )
+    ds = tf.data.Dataset.from_tensor_slices((first_sentences, second_sentences))
+    # Watch out for tf.data's default unpacking of tuples here!
+    # Best to invoke the `preprocessor` directly in this case.
     ds = ds.map(
-        lambda x, y: (preprocessor(x), y),
+        lambda s1, s2: preprocessor(x=(s1, s2)),
         num_parallel_calls=tf.data.AUTOTUNE,
     )
     ```
@@ -143,16 +188,15 @@ class XLMRobertaPreprocessor(keras.layers.Layer):
             config["tokenizer"] = keras.layers.deserialize(config["tokenizer"])
         return cls(**config)
 
-    def call(self, inputs):
-        if not isinstance(inputs, (list, tuple)):
-            inputs = [inputs]
-
-        inputs = [self.tokenizer(x) for x in inputs]
-        token_ids = self.packer(inputs)
-        return {
+    def call(self, x, y=None, sample_weight=None):
+        x = convert_inputs_to_list_of_tensor_segments(x)
+        x = [self.tokenizer(segment) for segment in x]
+        token_ids = self.packer(x)
+        x = {
             "token_ids": token_ids,
             "padding_mask": token_ids != self.tokenizer.pad_token_id,
         }
+        return pack_x_y_sample_weight(x, y, sample_weight)
 
     @classproperty
     def presets(cls):


Test patch:
diff --git a/keras_nlp/models/xlm_roberta/xlm_roberta_preprocessor_test.py b/keras_nlp/models/xlm_roberta/xlm_roberta_preprocessor_test.py
index daff5386..010c864d 100644
--- a/keras_nlp/models/xlm_roberta/xlm_roberta_preprocessor_test.py
+++ b/keras_nlp/models/xlm_roberta/xlm_roberta_preprocessor_test.py
@@ -52,9 +52,8 @@ class XLMRobertaPreprocessorTest(tf.test.TestCase, parameterized.TestCase):
             sequence_length=12,
         )
 
-    def test_tokenize(self):
-        input_data = ["the quick brown fox"]
-
+    def test_tokenize_strings(self):
+        input_data = "the quick brown fox"
         output = self.preprocessor(input_data)
         self.assertAllEqual(
             output["token_ids"], [0, 4, 9, 5, 7, 2, 1, 1, 1, 1, 1, 1]
@@ -63,29 +62,50 @@ class XLMRobertaPreprocessorTest(tf.test.TestCase, parameterized.TestCase):
             output["padding_mask"], [1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0]
         )
 
-    def test_tokenize_batch(self):
-        input_data = tf.constant(
-            [
-                "the quick brown fox",
-                "the quick brown fox",
-                "the quick brown fox",
-                "the quick brown fox",
-            ]
-        )
-
+    def test_tokenize_list_of_strings(self):
+        # We should handle a list of strings as as batch.
+        input_data = ["the quick brown fox"] * 4
         output = self.preprocessor(input_data)
         self.assertAllEqual(
-            output["token_ids"],
-            [[0, 4, 9, 5, 7, 2, 1, 1, 1, 1, 1, 1]] * 4,
+            output["token_ids"], [[0, 4, 9, 5, 7, 2, 1, 1, 1, 1, 1, 1]] * 4
         )
         self.assertAllEqual(
             output["padding_mask"], [[1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0]] * 4
         )
 
-    def test_tokenize_multiple_sentences(self):
-        sentence_one = "the quick brown fox"
-        sentence_two = "the earth"
+    def test_tokenize_labeled_batch(self):
+        x = tf.constant(["the quick brown fox"] * 4)
+        y = tf.constant([1] * 4)
+        sw = tf.constant([1.0] * 4)
+        x_out, y_out, sw_out = self.preprocessor(x, y, sw)
+        self.assertAllEqual(
+            x_out["token_ids"], [[0, 4, 9, 5, 7, 2, 1, 1, 1, 1, 1, 1]] * 4
+        )
+        self.assertAllEqual(
+            x_out["padding_mask"], [[1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0]] * 4
+        )
+        self.assertAllEqual(y_out, y)
+        self.assertAllEqual(sw_out, sw)
+
+    def test_tokenize_labeled_dataset(self):
+        x = tf.constant(["the quick brown fox"] * 4)
+        y = tf.constant([1] * 4)
+        sw = tf.constant([1.0] * 4)
+        ds = tf.data.Dataset.from_tensor_slices((x, y, sw))
+        ds = ds.map(self.preprocessor)
+        x_out, y_out, sw_out = ds.batch(4).take(1).get_single_element()
+        self.assertAllEqual(
+            x_out["token_ids"], [[0, 4, 9, 5, 7, 2, 1, 1, 1, 1, 1, 1]] * 4
+        )
+        self.assertAllEqual(
+            x_out["padding_mask"], [[1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0]] * 4
+        )
+        self.assertAllEqual(y_out, y)
+        self.assertAllEqual(sw_out, sw)
 
+    def test_tokenize_multiple_sentences(self):
+        sentence_one = tf.constant("the quick brown fox")
+        sentence_two = tf.constant("the earth")
         output = self.preprocessor((sentence_one, sentence_two))
         self.assertAllEqual(
             output["token_ids"], [0, 4, 9, 5, 7, 2, 2, 4, 6, 2, 1, 1]
@@ -95,51 +115,33 @@ class XLMRobertaPreprocessorTest(tf.test.TestCase, parameterized.TestCase):
         )
 
     def test_tokenize_multiple_batched_sentences(self):
-        sentence_one = tf.constant(
-            [
-                "the quick brown fox",
-                "the quick brown fox",
-                "the quick brown fox",
-                "the quick brown fox",
-            ]
-        )
-        sentence_two = tf.constant(
-            [
-                "the earth",
-                "the earth",
-                "the earth",
-                "the earth",
-            ]
-        )
-
+        sentence_one = tf.constant(["the quick brown fox"] * 4)
+        sentence_two = tf.constant(["the earth"] * 4)
+        # The first tuple or list is always interpreted as an enumeration of
+        # separate sequences to concatenate.
         output = self.preprocessor((sentence_one, sentence_two))
         self.assertAllEqual(
-            output["token_ids"],
-            [[0, 4, 9, 5, 7, 2, 2, 4, 6, 2, 1, 1]] * 4,
+            output["token_ids"], [[0, 4, 9, 5, 7, 2, 2, 4, 6, 2, 1, 1]] * 4
         )
         self.assertAllEqual(
             output["padding_mask"], [[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0]] * 4
         )
 
-    def test_detokenize(self):
-        input_data = tf.constant([[0, 4, 9, 5, 7, 2]])
-
-        output = self.preprocessor.tokenizer.detokenize(input_data)
-        self.assertEqual(output, tf.constant(["the quick brown fox"]))
+    def test_errors_for_2d_list_input(self):
+        ambiguous_input = [["one", "two"], ["three", "four"]]
+        with self.assertRaises(ValueError):
+            self.preprocessor(ambiguous_input)
 
     @parameterized.named_parameters(
         ("save_format_tf", "tf"), ("save_format_h5", "h5")
     )
     def test_saving_model(self, save_format):
         input_data = tf.constant(["the quick brown fox"])
-
         inputs = keras.Input(dtype="string", shape=())
         outputs = self.preprocessor(inputs)
         model = keras.Model(inputs, outputs)
-
         path = os.path.join(self.get_temp_dir(), "model")
         model.save(path, save_format=save_format)
-
         restored_model = keras.models.load_model(path)
         self.assertAllEqual(
             model(input_data)["token_ids"],