Repository: sdv-dev/RDT
Pull request #971: Add `cardinality_rule='match'` option to `RegexGenerator`
Author: login: fealho
Created: 2025-04-01T14:14:54Z | Merged: 2025-04-07T20:02:44Z

CU-86b47q01w, Resolve #950.

Notes:

This PR doesn't change the behavior of passing `regex_format=''`, ie it will still generate an array of empty strings, regardless of the `cardinality_rule`.

If `cardinality_rule='match'`, every `reverse_transform` call generates values from the beginning of the `regex_format`, while if `cardinality_rule!='match'`, every call attempts to start from where the previous one left off.

Linked issue #950: For `RegexGenerator`, add add an option for `cardinality_rule='match'` to match the exact cardinality
### Problem Description
In RDT #949, we will add a `cardinality_rule` parameter to the `RegexGenerator`. We are doing this to allow multiple ways of controlling the cardinality of the generated column.

This issue is for adding an option called `'match'` for the cardinality rule. When selected, this option will generate synthetic data with the exact same cardinality (# of unique values) that the real data had.

### Expected behavior
Add an option called `'match'` for the `cardinality_rule` parameter. When selected, the transformer should do the following:

- During **fit**: It should learn the number of unique values that were in the real data and save this info.
    - _It can also go ahead and generate Regexes of that same size, to be used every time during `reverse_transform`._
- During **transform**: No changes. It should drop the column.
- During **reverse_transform**: It should generated Regexes from scratch. Make sure that the # of unique Regexes match the value learned during `fit`.
    - **This should be global.** So if I apply `reverse_transform` multiple times and concatenate all the data together, I will only ever get the same # of unique Regexes as were present in the fit data. _Note that AnonymizedFaker currently has a bug when it comes to this functionality. See [RDT #951](https://github.com/sdv-dev/RDT/issues/951)._

After `fit`, I should be able to access the number of unique values learned using the `_data_cardinality` attribute. If `cardinality_rule` was something else other than `'match'`, then this attribute will be `None`.

```python
>>> transformer = RegexGenerator(
         regex_format='[A-Za-z1-9]{5}',
         cardinality_rule='match')
>>> transformer.fit(data)
>>> transformer._data_cardinality
10

# if you use a different cardinality rule, this attribute is None
>>> transformer = RegexGenerator(
         regex_format='[A-Za-z1-9]{5}',
         cardinality_rule=None)
>>> transformer.fit(data)
>>> transformer._data_cardinality
None
```

### Additional context
This issue is blocked on RDT #949.

Diff:
diff --git a/rdt/transformers/id.py b/rdt/transformers/id.py
index ffa24a259..0f046bcaa 100644
--- a/rdt/transformers/id.py
+++ b/rdt/transformers/id.py
@@ -82,16 +82,19 @@ class RegexGenerator(BaseTransformer):
     ``regex`` format.
 
     Args:
-        regex (str):
+        regex_format (str):
             String representing the regex function.
         enforce_uniqueness (bool):
             **DEPRECATED** Whether or not to ensure that the new generated data is all unique.
             If it isn't possible to create the requested number of rows, then an error will
             be raised. Defaults to ``None``.
         cardinality_rule (str):
-            Rule that the generated data must follow. If set to ``unique``, the generated
-            data must be unique. If set to ``None``, then the generated data may contain
-            duplicates. Defaults to ``None``.
+            Rule that the generated data must follow.
+            - If set to 'unique', the generated data must be unique.
+            - If set to 'match', the generated data must have the exact same cardinality
+              (# of unique values) as the real data.
+            - If set to ``None``, then the generated data may contain duplicates.
+            Defaults to ``None``.
         generation_order (str):
             String defining how to generate the output. If set to ``alphanumeric``, it will
             generate the output in alphanumeric order (ie. 'aaa', 'aab' or '1', '2'...). If
@@ -139,6 +142,8 @@ def __init__(
         self.cardinality_rule = _handle_enforce_uniqueness_and_cardinality_rule(
             enforce_uniqueness, cardinality_rule
         )
+        self._data_cardinality = None
+        self._unique_regex_values = None
         self.data_length = None
         self.generator = None
         self.generator_size = None
@@ -154,6 +159,40 @@ def reset_randomization(self):
         self.generator, self.generator_size = strings_from_regex(self.regex_format)
         self.generated = 0
 
+    def _generate_fallback_samples(self, num_samples, template_samples):
+        """Generate values such that they are all unique, disregarding the regex."""
+        try:
+            # Integer-based fallback: attempt to convert the last template sample to an integer
+            # and then generate values in a sequential manner.
+            start = int(template_samples[-1]) + 1
+            return [str(i) for i in range(start, start + num_samples)]
+
+        except ValueError:
+            # String-based fallback: if the integer conversion fails, it uses the template
+            # samples as a base and appends a counter to make each value unique.
+            counter = 0
+            samples = []
+            while num_samples > 0:
+                samples.extend([f'{i}({counter})' for i in template_samples[:num_samples]])
+                num_samples -= len(template_samples)
+                counter += 1
+
+            return samples
+
+    def _generate_unique_regex_values(self):
+        regex_values = []
+        try:
+            while len(regex_values) < self._data_cardinality:
+                regex_values.append(next(self.generator))
+        except (RuntimeError, StopIteration):
+            fallback_samples = self._generate_fallback_samples(
+                num_samples=self._data_cardinality - len(regex_values),
+                template_samples=regex_values,
+            )
+            regex_values.extend(fallback_samples)
+
+        return regex_values
+
     def _fit(self, data):
         """Fit the transformer to the data.
 
@@ -164,11 +203,15 @@ def _fit(self, data):
         self.reset_randomization()
         self.data_length = len(data)
 
+        if self.cardinality_rule == 'match':
+            is_nan = int(pd.isna(data).any())  # nans count as a unique value
+            self._data_cardinality = data.nunique() + is_nan
+
     def _transform(self, _data):
         """Drop the input column by returning ``None``."""
         return None
 
-    def _warn_not_enough_unique_values(self, sample_size, unique_condition):
+    def _warn_not_enough_unique_values(self, sample_size, unique_condition, match_cardinality):
         """Warn the user that the regex cannot generate enough unique values.
 
         Args:
@@ -176,15 +219,18 @@ def _warn_not_enough_unique_values(self, sample_size, unique_condition):
                 Number of samples to be generated.
             unique_condition (bool):
                 Whether or not to enforce uniqueness.
+            match_cardinality (bool):
+                Whether or not to match the cardinality of the data.
         """
         warned = False
+        warn_msg = (
+            f"The regex for '{self.get_input_column()}' can only generate "
+            f'{int(self.generator_size)} unique values. Additional values may not exactly '
+            'follow the provided regex.'
+        )
         if sample_size > self.generator_size:
             if unique_condition:
-                warnings.warn(
-                    f"The regex for '{self.get_input_column()}' can only generate "
-                    f'{self.generator_size} unique values. Additional values may not exactly '
-                    'follow the provided regex.'
-                )
+                warnings.warn(warn_msg)
                 warned = True
             else:
                 LOGGER.info(
@@ -203,6 +249,70 @@ def _warn_not_enough_unique_values(self, sample_size, unique_condition):
                 f'values (only {max(remaining, 0)} unique values left).'
             )
 
+        if match_cardinality:
+            if self._data_cardinality > sample_size:
+                warnings.warn(
+                    f'Only {sample_size} values can be generated. Cannot match the cardinality '
+                    f'of the data, it requires {self._data_cardinality} values.'
+                )
+            if sample_size > self.generator_size and self._data_cardinality > self.generator_size:
+                warnings.warn(warn_msg)
+
+    def _generate_as_many_as_possible(self, num_samples):
+        """Generate samples.
+
+        Generate values following the regex until either the ``num_samples`` is reached or
+        the generator is exhausted.
+        """
+        generated_values = []
+        try:
+            while len(generated_values) < num_samples:
+                generated_values.append(next(self.generator))
+                self.generated += 1
+        except (RuntimeError, StopIteration):
+            pass
+
+        return generated_values
+
+    def _generate_num_samples(self, num_samples, template_samples):
+        """Generate num_samples values from template_samples.
+
+        Eg: num_samples = 5, template_samples = ['a', 'b']
+        The output will be ['a', 'b', 'a', 'b', 'a']
+        """
+        if num_samples <= 0:
+            return []
+
+        repeats = num_samples // len(template_samples) + 1
+        return np.tile(template_samples, repeats)[:num_samples].tolist()
+
+    def _generate_match_cardinality(self, num_samples):
+        """Generate values until the sample size is reached, while respecting the cardinality."""
+        template_samples = self._unique_regex_values[:num_samples]
+        samples = self._generate_num_samples(num_samples - len(template_samples), template_samples)
+
+        return template_samples + samples
+
+    def _generate_samples(self, num_samples, match_cardinality_values, unique_condition):
+        """Generate samples until the sample size is reached."""
+        if match_cardinality_values is not None:
+            return self._generate_match_cardinality(num_samples)
+
+        # If there aren't enough values left in the generator, reset it
+        if num_samples > self.generator_size - self.generated:
+            self.reset_randomization()
+
+        samples = self._generate_as_many_as_possible(num_samples)
+        num_samples -= len(samples)
+        if num_samples > 0:
+            if unique_condition:
+                new_samples = self._generate_fallback_samples(num_samples, samples)
+            else:
+                new_samples = self._generate_num_samples(num_samples, samples)
+            samples.extend(new_samples)
+
+        return samples
+
     def _reverse_transform(self, data):
         """Generate new data using the provided ``regex_format``.
 
@@ -215,56 +325,26 @@ def _reverse_transform(self, data):
         """
         if hasattr(self, 'cardinality_rule'):
             unique_condition = self.cardinality_rule == 'unique'
+            if self.cardinality_rule == 'match' and self._unique_regex_values is None:
+                self._unique_regex_values = self._generate_unique_regex_values()
         else:
             unique_condition = self.enforce_uniqueness
 
-        if data is not None and len(data):
-            sample_size = len(data)
+        if hasattr(self, '_unique_regex_values'):
+            match_cardinality_values = self._unique_regex_values
         else:
-            sample_size = self.data_length
-
-        self._warn_not_enough_unique_values(sample_size, unique_condition)
-
-        remaining = self.generator_size - self.generated
-        if sample_size > remaining:
-            self.reset_randomization()
-            remaining = self.generator_size
-
-        generated_values = []
-        while len(generated_values) < sample_size:
-            try:
-                generated_values.append(next(self.generator))
-                self.generated += 1
-            except (RuntimeError, StopIteration):
-                # Can't generate more rows without collision so breaking out of loop
-                break
+            match_cardinality_values = None
 
-        reverse_transformed = generated_values[:]
-
-        if len(reverse_transformed) < sample_size:
-            if unique_condition:
-                try:
-                    remaining_samples = sample_size - len(reverse_transformed)
-                    start = int(generated_values[-1]) + 1
-                    reverse_transformed.extend([
-                        str(i) for i in range(start, start + remaining_samples)
-                    ])
-
-                except ValueError:
-                    counter = 0
-                    while len(reverse_transformed) < sample_size:
-                        remaining_samples = sample_size - len(reverse_transformed)
-                        reverse_transformed.extend([
-                            f'{i}({counter})' for i in generated_values[:remaining_samples]
-                        ])
-                        counter += 1
+        if data is not None and len(data):
+            num_samples = len(data)
+        else:
+            num_samples = self.data_length
 
-            else:
-                while len(reverse_transformed) < sample_size:
-                    remaining_samples = sample_size - len(reverse_transformed)
-                    reverse_transformed.extend(generated_values[:remaining_samples])
+        match_condition = match_cardinality_values is not None
+        self._warn_not_enough_unique_values(num_samples, unique_condition, match_condition)
+        samples = self._generate_samples(num_samples, match_cardinality_values, unique_condition)
 
         if getattr(self, 'generation_order', 'alphanumeric') == 'scrambled':
-            np.random.shuffle(reverse_transformed)
+            np.random.shuffle(samples)
 
-        return np.array(reverse_transformed, dtype=object)
+        return np.array(samples, dtype=object)
diff --git a/tests/integration/transformers/test_id.py b/tests/integration/transformers/test_id.py
index edfe9b92d..f76835756 100644
--- a/tests/integration/transformers/test_id.py
+++ b/tests/integration/transformers/test_id.py
@@ -335,6 +335,97 @@ def test_cardinality_rule_not_enough_values_numerical(self):
         expected = pd.DataFrame({'id': ['2', '3', '4', '5', '6']}, dtype=object)
         pd.testing.assert_frame_equal(reverse_transform, expected)
 
+    def test_cardinality_rule_match(self):
+        """Test with cardinality_rule='match'."""
+        # Setup
+        data = pd.DataFrame({'id': [1, 2, 3, 4, 5]})
+        instance = RegexGenerator('[1-3]{1}', cardinality_rule='match')
+
+        # Run
+        transformed = instance.fit_transform(data, 'id')
+        reverse_transform = instance.reverse_transform(transformed)
+
+        # Assert
+        expected = pd.DataFrame({'id': ['1', '2', '3', '4', '5']}, dtype=object)
+        pd.testing.assert_frame_equal(reverse_transform, expected)
+
+    def test_cardinality_rule_match_not_enough_values(self):
+        """Test with cardinality_rule='match' but insufficient regex values."""
+        # Setup
+        data = pd.DataFrame({'id': [1, 2, 3, 4, 5]})
+        instance = RegexGenerator('[1-3]{1}', cardinality_rule='match')
+
+        # Run
+        transformed = instance.fit_transform(data, 'id')
+        reverse_transform = instance.reverse_transform(transformed)
+
+        # Assert
+        expected = pd.DataFrame({'id': ['1', '2', '3', '4', '5']}, dtype=object)
+        pd.testing.assert_frame_equal(reverse_transform, expected)
+
+    def test_called_multiple_times_cardinality_rule_match(self):
+        """Test calling multiple times when ``cardinality_rule`` is ``match``."""
+        # Setup
+        data = pd.DataFrame({'my_column': [1, 2, 3, 4, 5] * 3})
+        generator = RegexGenerator(cardinality_rule='match')
+
+        # Run
+        transformed_data = generator.fit_transform(data, 'my_column')
+        first_reverse_transform = generator.reverse_transform(transformed_data.head(3))
+        second_reverse_transform = generator.reverse_transform(transformed_data.head(4))
+        third_reverse_transform = generator.reverse_transform(transformed_data.head(5))
+        fourth_reverse_transform = generator.reverse_transform(transformed_data.head(11))
+
+        # Assert
+        expected_first_reverse_transform = pd.DataFrame({'my_column': ['AAAAA', 'AAAAB', 'AAAAC']})
+        expected_second_reverse_transform = pd.DataFrame({
+            'my_column': ['AAAAA', 'AAAAB', 'AAAAC', 'AAAAD']
+        })
+        expected_third_reverse_transform = pd.DataFrame({
+            'my_column': ['AAAAA', 'AAAAB', 'AAAAC', 'AAAAD', 'AAAAE']
+        })
+        expected_fourth_reverse_transform = pd.DataFrame({
+            'my_column': [
+                'AAAAA',
+                'AAAAB',
+                'AAAAC',
+                'AAAAD',
+                'AAAAE',
+                'AAAAA',
+                'AAAAB',
+                'AAAAC',
+                'AAAAD',
+                'AAAAE',
+                'AAAAA',
+            ]
+        })
+        pd.testing.assert_frame_equal(first_reverse_transform, expected_first_reverse_transform)
+        pd.testing.assert_frame_equal(second_reverse_transform, expected_second_reverse_transform)
+        pd.testing.assert_frame_equal(third_reverse_transform, expected_third_reverse_transform)
+        pd.testing.assert_frame_equal(fourth_reverse_transform, expected_fourth_reverse_transform)
+
+    def test_cardinality_rule_match_empty_regex(self):
+        """Test with cardinality_rule='match' but insufficient regex values."""
+        # Setup
+        data = pd.DataFrame({'id': [1, 2, 3, 4, 5]})
+        instance_unique = RegexGenerator('', cardinality_rule='unique')
+        instance_match = RegexGenerator('', cardinality_rule='match')
+        instance_none = RegexGenerator('')
+
+        # Run
+        transformed_unique = instance_unique.fit_transform(data, 'id')
+        transformed_match = instance_match.fit_transform(data, 'id')
+        transformed_none = instance_none.fit_transform(data, 'id')
+        reverse_transform_unique = instance_unique.reverse_transform(transformed_unique)
+        reverse_transform_match = instance_match.reverse_transform(transformed_match)
+        reverse_transform_none = instance_none.reverse_transform(transformed_none)
+
+        # Assert
+        expected = pd.DataFrame({'id': ['', '', '', '', '']})
+        pd.testing.assert_frame_equal(reverse_transform_unique, expected)
+        pd.testing.assert_frame_equal(reverse_transform_match, expected)
+        pd.testing.assert_frame_equal(reverse_transform_none, expected)
+
 
 class TestHyperTransformer:
     def test_end_to_end_scrambled(self):
diff --git a/tests/unit/transformers/test_id.py b/tests/unit/transformers/test_id.py
index f2227c85d..e17ac1863 100644
--- a/tests/unit/transformers/test_id.py
+++ b/tests/unit/transformers/test_id.py
@@ -167,6 +167,8 @@ def test___getstate__(self):
             'regex_format': '[A-Za-z]{5}',
             'random_states': mock_random_sates,
             'generation_order': 'alphanumeric',
+            '_unique_regex_values': None,
+            '_data_cardinality': None,
         }
 
     @patch('rdt.transformers.id.strings_from_regex')
@@ -255,6 +257,21 @@ def test___init__custom(self):
         assert instance.cardinality_rule == 'unique'
         assert instance.generation_order == 'scrambled'
 
+    def test___init__cardinality_rule_match(self):
+        """Test it when cardinality_rule is 'match'."""
+        # Run
+        instance = RegexGenerator(
+            regex_format='[0-9]',
+            cardinality_rule='match',
+        )
+
+        # Assert
+        assert instance.data_length is None
+        assert instance.regex_format == '[0-9]'
+        assert instance.cardinality_rule == 'match'
+        assert instance._data_cardinality is None
+        assert instance._unique_regex_values is None
+
     def test___init__bad_value_generation_order(self):
         """Test that an error is raised if a bad value is given for `generation_order`."""
         # Run and Assert
@@ -336,6 +353,141 @@ def test__fit(self):
         assert instance.data_length == 3
         assert instance.output_properties == {None: {'next_transformer': None}}
 
+    def test__fit_cardinality_rule_match(self):
+        """Test it when cardinality_rule is 'match'."""
+        # Setup
+        instance = RegexGenerator(cardinality_rule='match')
+        columns_data = pd.Series(['1', '2', '3', '2', '1'])
+
+        # Run
+        instance._fit(columns_data)
+
+        # Assert
+        assert instance.data_length == 5
+        assert instance._data_cardinality == 3
+        assert instance.output_properties == {None: {'next_transformer': None}}
+
+    def test__reverse_transform_cardinality_rule_match(self):
+        """Test it when cardinality_rule is 'match'."""
+        # Setup
+        instance = RegexGenerator(cardinality_rule='match')
+        columns_data = pd.DataFrame({'col': ['1', '2', '3', '2', '1']})
+
+        # Run
+        instance.fit(columns_data, 'col')
+        instance._reverse_transform(columns_data)
+
+        # Assert
+        assert instance._unique_regex_values == ['AAAAA', 'AAAAB', 'AAAAC']
+
+    def test__fit_cardinality_rule_match_with_regex_format(self):
+        """Test it when cardinality_rule is 'match'."""
+        # Setup
+        instance = RegexGenerator(cardinality_rule='match', regex_format='[1-5]{1}')
+        columns_data = pd.Series(['1', '2', '3', '2', '1'])
+
+        # Run
+        instance._fit(columns_data)
+
+        # Assert
+        assert instance.data_length == 5
+        assert instance._data_cardinality == 3
+        assert instance.output_properties == {None: {'next_transformer': None}}
+
+    def test__reverse_transform_cardinality_rule_match_with_regex_format(self):
+        """Test it when cardinality_rule is 'match'."""
+        # Setup
+        instance = RegexGenerator(cardinality_rule='match', regex_format='[1-5]{1}')
+        columns_data = pd.DataFrame({'col': ['1', '2', '3', '2', '1']})
+
+        # Run
+        instance.fit(columns_data, 'col')
+        instance._reverse_transform(columns_data)
+
+        # Assert
+        assert instance._unique_regex_values == ['1', '2', '3']
+
+    def test__fit_cardinality_rule_match_with_nans(self):
+        """Test it when cardinality_rule is 'match'."""
+        # Setup
+        instance = RegexGenerator(cardinality_rule='match', regex_format='[1-5]{1}')
+        columns_data = pd.Series(['1', '2', '3', '2', '1', np.nan, None, np.nan])
+
+        # Run
+        instance._fit(columns_data)
+
+        # Assert
+        assert instance.data_length == 8
+        assert instance._data_cardinality == 4
+        assert instance.output_properties == {None: {'next_transformer': None}}
+
+    def test__reverse_transform_cardinality_rule_match_with_nans(self):
+        """Test it when cardinality_rule is 'match'."""
+        # Setup
+        instance = RegexGenerator(cardinality_rule='match', regex_format='[1-5]{1}')
+        columns_data = pd.DataFrame({'col': ['1', '2', '3', '2', '1', np.nan, None, np.nan]})
+
+        # Run
+        instance.fit(columns_data, 'col')
+        instance._reverse_transform(columns_data)
+
+        # Assert
+        assert instance._unique_regex_values == ['1', '2', '3', '4']
+
+    def test__fit_cardinality_rule_match_with_nans_too_many_values(self):
+        """Test it when cardinality_rule is 'match'."""
+        # Setup
+        instance = RegexGenerator(cardinality_rule='match', regex_format='[1-3]{1}')
+        columns_data = pd.Series(['1', '2', '3', '2', '4'])
+
+        # Run
+        instance._fit(columns_data)
+
+        # Assert
+        assert instance.data_length == 5
+        assert instance._data_cardinality == 4
+        assert instance.output_properties == {None: {'next_transformer': None}}
+
+    def test__reverse_transform_cardinality_rule_match_with_nans_too_many_values(self):
+        """Test it when cardinality_rule is 'match'."""
+        # Setup
+        instance = RegexGenerator(cardinality_rule='match', regex_format='[1-3]{1}')
+        columns_data = pd.DataFrame({'col': ['1', '2', '3', '2', '4']})
+
+        # Run
+        instance.fit(columns_data, 'col')
+        instance._reverse_transform(columns_data)
+
+        # Assert
+        assert instance._unique_regex_values == ['1', '2', '3', '4']
+
+    def test__fit_cardinality_rule_match_with_too_many_values_str(self):
+        """Test it when cardinality_rule is 'match'."""
+        # Setup
+        instance = RegexGenerator(cardinality_rule='match', regex_format='[a-b]{1}')
+        columns_data = pd.Series(['a', 'b', 'c', 'b', 'd', 'f'])
+
+        # Run
+        instance._fit(columns_data)
+
+        # Assert
+        assert instance.data_length == 6
+        assert instance._data_cardinality == 5
+        assert instance.output_properties == {None: {'next_transformer': None}}
+
+    def test__reverse_transform_cardinality_rule_match_with_too_many_values_str(self):
+        """Test it when cardinality_rule is 'match'."""
+        # Setup
+        instance = RegexGenerator(cardinality_rule='match', regex_format='[a-b]{1}')
+        columns_data = pd.DataFrame({'col': ['a', 'b', 'c', 'b', 'd', 'f']})
+
+        # Run
+        instance.fit(columns_data, 'col')
+        instance._reverse_transform(columns_data)
+
+        # Assert
+        assert instance._unique_regex_values == ['a', 'b', 'a(0)', 'b(0)', 'a(1)']
+
     def test__transform(self):
         """Test the ``_transform`` method.
 
@@ -375,6 +527,7 @@ def test__reverse_transform_generation_order_scrambled(self, shuffle_mock):
         instance.generator_size = 5
         instance.generated = 0
         instance.generation_order = 'scrambled'
+        instance.columns = ['col']
 
         # Run
         result = instance._reverse_transform(columns_data)
@@ -406,6 +559,7 @@ def test__reverse_transform_generator_size_bigger_than_data_length(self):
         instance.generator = generator
         instance.generator_size = 5
         instance.generated = 0
+        instance.columns = ['col']
 
         # Run
         result = instance._reverse_transform(columns_data)
@@ -614,3 +768,137 @@ def test__reverse_transform_info_message(self, mock_logger):
         expected_args = (6, 'a', 5, 'a')
 
         mock_logger.info.assert_called_once_with(expected_format, *expected_args)
+
+    def test__reverse_transform_match_not_enough_values(self):
+        """Test the case when there are not enough values to match the cardinality rule."""
+        # Setup
+        data = pd.DataFrame({'col': ['A', 'B', 'C', 'D', 'E']})
+        instance = RegexGenerator(regex_format='[A-Z]', cardinality_rule='match')
+        instance.fit(data, 'col')
+
+        # Run and Assert
+        warn_msg = re.escape(
+            'Only 3 values can be generated. Cannot match the cardinality '
+            'of the data, it requires 5 values.'
+        )
+        with pytest.warns(UserWarning, match=warn_msg):
+            out = instance._reverse_transform(data[:3])
+
+        np.testing.assert_array_equal(out, np.array(['A', 'B', 'C']))
+
+    def test__reverse_transform_match_too_many_samples(self):
+        """Test it when the number of samples is bigger than the generator size."""
+        # Setup
+        data = pd.DataFrame({'col': ['A', 'B', 'C', 'D', 'E']})
+        instance = RegexGenerator(regex_format='[A-C]', cardinality_rule='match')
+        instance.fit(data, 'col')
+
+        # Run and Assert
+        warn_msg = re.escape(
+            "The regex for 'col' can only generate 3 unique values. Additional values may not "
+            'exactly follow the provided regex.'
+        )
+        with pytest.warns(UserWarning, match=warn_msg):
+            out = instance._reverse_transform(data)
+
+        np.testing.assert_array_equal(out, np.array(['A', 'B', 'C', 'A(0)', 'B(0)']))
+
+    def test__reverse_transform_only_one_warning(self):
+        """Test it when the num_samples < generator_size but data_cardinality > generator_size."""
+        # Setup
+        data = pd.DataFrame({'col': ['A', 'B', 'C', 'D', 'E']})
+        instance = RegexGenerator(regex_format='[A-C]', cardinality_rule='match')
+        instance.fit(data, 'col')
+
+        # Run and Assert
+        warn_msg = re.escape(
+            'Only 2 values can be generated. Cannot match the cardinality of the data, '
+            'it requires 5 values.'
+        )
+        with pytest.warns(UserWarning, match=warn_msg):
+            out = instance._reverse_transform(data[:2])
+
+        np.testing.assert_array_equal(out, np.array(['A', 'B']))
+        instance._unique_regex_values = ['A', 'B', 'C', 'A(0)', 'B(0)']
+
+    def test__reverse_transform_two_warnings(self):
+        """Test it when data_cardinality > num_samples > generator_size."""
+        # Setup
+        data = pd.DataFrame({'col': ['A', 'B', 'C', 'D', 'E']})
+        instance = RegexGenerator(regex_format='[A-C]', cardinality_rule='match')
+        instance.fit(data, 'col')
+
+        # Run and Assert
+        warn_msg_1 = re.escape(
+            'Only 4 values can be generated. Cannot match the cardinality of the data, '
+            'it requires 5 values.'
+        )
+        warn_msg_2 = re.escape(
+            "The regex for 'col' can only generate 3 unique values. Additional values may "
+            'not exactly follow the provided regex.'
+        )
+        with pytest.warns(UserWarning, match=warn_msg_1):
+            with pytest.warns(UserWarning, match=warn_msg_2):
+                out = instance._reverse_transform(data[:4])
+
+        np.testing.assert_array_equal(out, np.array(['A', 'B', 'C', 'A(0)']))
+
+    def test__reverse_transform_match_empty_data(self):
+        """Test it when the data is empty and the cardinality rule is 'match'."""
+        # Setup
+        data = pd.DataFrame({'col': ['A', 'B', 'C', 'D', 'E']})
+        instance = RegexGenerator(regex_format='[A-Z]', cardinality_rule='match')
+        instance.fit(data, 'col')
+
+        # Run
+        out = instance._reverse_transform(pd.Series())
+
+        # Assert
+        np.testing.assert_array_equal(out, np.array(['A', 'B', 'C', 'D', 'E']))
+
+    def test__reverse_transform_match_with_nans(self):
+        """Test it when the data has nans and the cardinality rule is 'match'."""
+        # Setup
+        data = pd.DataFrame({'col': ['A', np.nan, np.nan, 'D', np.nan]})
+        instance = RegexGenerator(regex_format='[A-Z]', cardinality_rule='match')
+        instance.fit(data, 'col')
+
+        # Run
+        out = instance._reverse_transform(data)
+
+        # Assert
+        np.testing.assert_array_equal(out, np.array(['A', 'B', 'C', 'A', 'B']))
+
+    def test__reverse_transform_match_too_many_values(self):
+        """Test it when the data has more values than the cardinality rule."""
+        # Setup
+        data = pd.DataFrame({'col': ['A', 'B', 'B', 'C']})
+        instance = RegexGenerator(regex_format='[A-Z]', cardinality_rule='match')
+        instance.fit(data, 'col')
+
+        # Run
+        out = instance._reverse_transform(pd.Series([1] * 10))
+
+        # Assert
+        np.testing.assert_array_equal(
+            out, np.array(['A', 'B', 'C', 'A', 'B', 'C', 'A', 'B', 'C', 'A'])
+        )
+
+    def test__reverse_transform_no_unique_regex_values_attribute(self):
+        """Test it without the _unique_regex_values attribute."""
+        # Setup
+        instance = RegexGenerator('[A-E]')
+        delattr(instance, '_unique_regex_values')
+        instance.data_length = 6
+        generator = AsciiGenerator(5)
+        instance.generator = generator
+        instance.generator_size = 5
+        instance.generated = 0
+        instance.columns = ['a']
+        columns_data = pd.Series()
+
+        # Run
+        out = instance._reverse_transform(columns_data)
+
+        # Assert
+        np.testing.assert_array_equal(out, np.array(['A', 'B', 'C', 'D', 'E', 'A']))


Reviews:
user: login: pvk-developer
body: Left some suggestions. I need another iteration to get the modularization review accurately, but this is the right direction 👍🏻 
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-04-02T11:37:34Z
user: login: fealho
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-02T12:53:40Z
user: login: fealho
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-02T13:24:11Z
user: login: fealho
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-02T13:32:36Z
user: login: pvk-developer
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-03T13:53:18Z
user: login: pvk-developer
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-03T17:31:48Z
user: login: amontanez24
body: The refactoring looks good! I left some minor comments. The main thing is I think we should just sample in reverse_transform only. This might also let you coalesce the code that generates regex matching values into one function (ie. all lines that say `next(self.generator)`)
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-07T16:18:43Z
user: login: fealho
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-07T16:28:00Z
user: login: fealho
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-07T16:34:05Z
user: login: fealho
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-07T16:37:15Z
user: login: amontanez24
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-07T16:41:23Z
user: login: fealho
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-07T17:54:21Z
user: login: amontanez24
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-07T18:04:21Z
user: login: amontanez24
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-07T19:37:22Z

Review comments:
diff_hunk: @@ -164,27 +203,35 @@ def _fit(self, data):
         self.reset_randomization()
         self.data_length = len(data)
 
+        if self.cardinality_rule == 'match':
+            is_nan = int(pd.isna(data).any())  # nans count as a unique value
+            self._data_cardinality = data.nunique() + is_nan
path: rdt/transformers/id.py
user: login: pvk-developer
body: `self._data_cardinality = len(data.unique())`
```python
In [4]: data
Out[4]: 
0    1.0
1    2.0
2    3.0
3    NaN
Name: a, dtype: float64

In [5]: data.nunique()
Out[5]: 3

In [6]: data.unique()
Out[6]: array([ 1.,  2.,  3., nan])

In [7]: len(data.unique())
Out[7]: 4
```
created_at: 2025-04-02T11:15:19Z
author_association: MEMBER
diff_hunk: @@ -203,6 +250,67 @@ def _warn_not_enough_unique_values(self, sample_size, unique_condition):
                 f'values (only {max(remaining, 0)} unique values left).'
             )
 
+        if match_cardinality:
+            if self._data_cardinality > sample_size:
+                warnings.warn(
+                    f'Only {sample_size} values can be generated. Cannot match the cardinality '
+                    f'of the data, it requires {self._data_cardinality} values.'
+                )
+            if sample_size > self.generator_size and self._data_cardinality > self.generator_size:
+                warnings.warn(warn_msg)
+
+    def _generate_as_many_as_possible(self, num_samples):
+        """Generate samples.
+
+        Generate values following the regex until either the sample size is reached or
+        the generator is exhausted.
+        """
+        generated_values = []
+        try:
+            while len(generated_values) < num_samples:
+                generated_values.append(next(self.generator))
+                self.generated += 1
+        except (RuntimeError, StopIteration):
+            pass
+
+        return generated_values
+
+    def _generate_num_samples(self, num_samples, template_samples):
+        """Generate num_samples values from template_samples."""
+        samples = []
+        while num_samples > 0:
+            samples.extend(template_samples[:num_samples])
+            num_samples -= len(template_samples)
+
+        return samples
+
+    def _generate_match_cardinality(self, num_samples):
+        """Generate values until the sample size is reached, while respecting the cardinality."""
+        template_samples = self._unique_regex_values[:num_samples]
+        samples = self._generate_num_samples(num_samples - len(template_samples), template_samples)
+
+        return template_samples + samples
path: rdt/transformers/id.py
user: login: pvk-developer
body: Here we could just relay on `np` to do this for us. Here are two approaches:
1. Use `np.random.choice`, this will just randomly generate `n` amount of samples, but this is scrambled, so if we don't want scrambled we can do number 2.
2. Use `np.tile`, the logic here would be something like this: `result = np.tile(values, (size // len(values)) + 1)[:size]`

```python
In [20]: values = ['a', 'b', 'c']

In [21]: result = np.tile(values, (100 // len(values)) + 1)[:100]

In [22]: result
Out[22]: 
array(['a', 'b', 'c', 'a', 'b', 'c', 'a', 'b', 'c', 'a', 'b', 'c', 'a',
       'b', 'c', 'a', 'b', 'c', 'a', 'b', 'c', 'a', 'b', 'c', 'a', 'b',
       'c', 'a', 'b', 'c', 'a', 'b', 'c', 'a', 'b', 'c', 'a', 'b', 'c',
       'a', 'b', 'c', 'a', 'b', 'c', 'a', 'b', 'c', 'a', 'b', 'c', 'a',
       'b', 'c', 'a', 'b', 'c', 'a', 'b', 'c', 'a', 'b', 'c', 'a', 'b',
       'c', 'a', 'b', 'c', 'a', 'b', 'c', 'a', 'b', 'c', 'a', 'b', 'c',
       'a', 'b', 'c', 'a', 'b', 'c', 'a', 'b', 'c', 'a', 'b', 'c', 'a',
       'b', 'c', 'a', 'b', 'c', 'a', 'b', 'c', 'a'], dtype='<U1')

In [23]: len(result)
Out[23]: 100
```
created_at: 2025-04-02T11:27:11Z
author_association: MEMBER
diff_hunk: @@ -164,27 +203,35 @@ def _fit(self, data):
         self.reset_randomization()
         self.data_length = len(data)
 
+        if self.cardinality_rule == 'match':
+            is_nan = int(pd.isna(data).any())  # nans count as a unique value
+            self._data_cardinality = data.nunique() + is_nan
path: rdt/transformers/id.py
user: login: fealho
body: It doesn't combine `None` with `nan`:

```python3
pd.Series([np.nan, None, '1']).unique()  # Output: array([nan, None, '1'], dtype=object)
```

There's a similar problem if you use `nunique(dropna=False)`, which is why I used this approach.
created_at: 2025-04-02T12:53:40Z
author_association: MEMBER
diff_hunk: @@ -203,6 +250,67 @@ def _warn_not_enough_unique_values(self, sample_size, unique_condition):
                 f'values (only {max(remaining, 0)} unique values left).'
             )
 
+        if match_cardinality:
+            if self._data_cardinality > sample_size:
+                warnings.warn(
+                    f'Only {sample_size} values can be generated. Cannot match the cardinality '
+                    f'of the data, it requires {self._data_cardinality} values.'
+                )
+            if sample_size > self.generator_size and self._data_cardinality > self.generator_size:
+                warnings.warn(warn_msg)
+
+    def _generate_as_many_as_possible(self, num_samples):
+        """Generate samples.
+
+        Generate values following the regex until either the sample size is reached or
+        the generator is exhausted.
+        """
+        generated_values = []
+        try:
+            while len(generated_values) < num_samples:
+                generated_values.append(next(self.generator))
+                self.generated += 1
+        except (RuntimeError, StopIteration):
+            pass
+
+        return generated_values
+
+    def _generate_num_samples(self, num_samples, template_samples):
+        """Generate num_samples values from template_samples."""
+        samples = []
+        while num_samples > 0:
+            samples.extend(template_samples[:num_samples])
+            num_samples -= len(template_samples)
+
+        return samples
+
+    def _generate_match_cardinality(self, num_samples):
+        """Generate values until the sample size is reached, while respecting the cardinality."""
+        template_samples = self._unique_regex_values[:num_samples]
+        samples = self._generate_num_samples(num_samples - len(template_samples), template_samples)
+
+        return template_samples + samples
path: rdt/transformers/id.py
user: login: fealho
body: Good suggestion, thank you.
created_at: 2025-04-02T13:24:11Z
author_association: MEMBER
diff_hunk: @@ -164,27 +203,35 @@ def _fit(self, data):
         self.reset_randomization()
         self.data_length = len(data)
 
+        if self.cardinality_rule == 'match':
+            is_nan = int(pd.isna(data).any())  # nans count as a unique value
+            self._data_cardinality = data.nunique() + is_nan
+            self._unique_regex_values = self._generate_unique_regex_values()
path: rdt/transformers/id.py
user: login: fealho
body: I believe this list will always have at least 1 value. It comes from the `strings_from_regex` method, which always produces a non-empty list, it either crashes for invalid regexes like `(?!)` or `[1-0]{1}`, or it produces an empty string generator.

Throughout this file we make use of this assumption that this list doesn't have length 0.
created_at: 2025-04-02T13:32:35Z
author_association: MEMBER
diff_hunk: @@ -164,27 +203,35 @@ def _fit(self, data):
         self.reset_randomization()
         self.data_length = len(data)
 
+        if self.cardinality_rule == 'match':
+            is_nan = int(pd.isna(data).any())  # nans count as a unique value
+            self._data_cardinality = data.nunique() + is_nan
path: rdt/transformers/id.py
user: login: pvk-developer
body: I see 🤔 , cool let's keep it that way then!
created_at: 2025-04-03T13:53:17Z
author_association: MEMBER
diff_hunk: @@ -164,27 +203,35 @@ def _fit(self, data):
         self.reset_randomization()
         self.data_length = len(data)
 
+        if self.cardinality_rule == 'match':
+            is_nan = int(pd.isna(data).any())  # nans count as a unique value
+            self._data_cardinality = data.nunique() + is_nan
path: rdt/transformers/id.py
user: login: amontanez24
body: minor: I think you can just do 
```python
self._data_cardinality = data.nunique(dropna=False)
```
created_at: 2025-04-07T16:05:29Z
author_association: CONTRIBUTOR
diff_hunk: @@ -203,6 +250,70 @@ def _warn_not_enough_unique_values(self, sample_size, unique_condition):
                 f'values (only {max(remaining, 0)} unique values left).'
             )
 
+        if match_cardinality:
+            if self._data_cardinality > sample_size:
+                warnings.warn(
+                    f'Only {sample_size} values can be generated. Cannot match the cardinality '
+                    f'of the data, it requires {self._data_cardinality} values.'
+                )
+            if sample_size > self.generator_size and self._data_cardinality > self.generator_size:
+                warnings.warn(warn_msg)
+
+    def _generate_as_many_as_possible(self, num_samples):
+        """Generate samples.
+
+        Generate values following the regex until either the sample size is reached or
path: rdt/transformers/id.py
user: login: amontanez24
body: ```suggestion
        Generate values following the regex until either the ``num_samples`` is reached or
```
created_at: 2025-04-07T16:08:58Z
author_association: CONTRIBUTOR
diff_hunk: @@ -203,6 +250,70 @@ def _warn_not_enough_unique_values(self, sample_size, unique_condition):
                 f'values (only {max(remaining, 0)} unique values left).'
             )
 
+        if match_cardinality:
+            if self._data_cardinality > sample_size:
+                warnings.warn(
+                    f'Only {sample_size} values can be generated. Cannot match the cardinality '
+                    f'of the data, it requires {self._data_cardinality} values.'
+                )
+            if sample_size > self.generator_size and self._data_cardinality > self.generator_size:
+                warnings.warn(warn_msg)
+
+    def _generate_as_many_as_possible(self, num_samples):
+        """Generate samples.
+
+        Generate values following the regex until either the sample size is reached or
+        the generator is exhausted.
+        """
+        generated_values = []
+        try:
+            while len(generated_values) < num_samples:
+                generated_values.append(next(self.generator))
+                self.generated += 1
+        except (RuntimeError, StopIteration):
+            pass
+
+        return generated_values
+
+    def _generate_num_samples(self, num_samples, template_samples):
path: rdt/transformers/id.py
user: login: amontanez24
body: can we call this _generate_from_samples or _generate_from_template?
created_at: 2025-04-07T16:09:55Z
author_association: CONTRIBUTOR
diff_hunk: @@ -164,27 +203,35 @@ def _fit(self, data):
         self.reset_randomization()
         self.data_length = len(data)
 
+        if self.cardinality_rule == 'match':
+            is_nan = int(pd.isna(data).any())  # nans count as a unique value
+            self._data_cardinality = data.nunique() + is_nan
+            self._unique_regex_values = self._generate_unique_regex_values()
path: rdt/transformers/id.py
user: login: amontanez24
body: why do this in the fit instead of the beginning of reverse_transform? We can just do it there and store the value as an attribute for future calls to reverse transform
created_at: 2025-04-07T16:15:14Z
author_association: CONTRIBUTOR
diff_hunk: @@ -164,27 +203,35 @@ def _fit(self, data):
         self.reset_randomization()
         self.data_length = len(data)
 
+        if self.cardinality_rule == 'match':
+            is_nan = int(pd.isna(data).any())  # nans count as a unique value
+            self._data_cardinality = data.nunique() + is_nan
path: rdt/transformers/id.py
user: login: fealho
body: @amontanez24 It unfortunately doesn't work, @pvk-developer mentioned a similar [idea](https://github.com/sdv-dev/RDT/pull/971#discussion_r2024614878).

```python3
>>> pd.Series(['1', np.nan, None]).nunique(dropna=False)
3
```
created_at: 2025-04-07T16:28:00Z
author_association: MEMBER
diff_hunk: @@ -203,6 +250,70 @@ def _warn_not_enough_unique_values(self, sample_size, unique_condition):
                 f'values (only {max(remaining, 0)} unique values left).'
             )
 
+        if match_cardinality:
+            if self._data_cardinality > sample_size:
+                warnings.warn(
+                    f'Only {sample_size} values can be generated. Cannot match the cardinality '
+                    f'of the data, it requires {self._data_cardinality} values.'
+                )
+            if sample_size > self.generator_size and self._data_cardinality > self.generator_size:
+                warnings.warn(warn_msg)
+
+    def _generate_as_many_as_possible(self, num_samples):
+        """Generate samples.
+
+        Generate values following the regex until either the sample size is reached or
+        the generator is exhausted.
+        """
+        generated_values = []
+        try:
+            while len(generated_values) < num_samples:
+                generated_values.append(next(self.generator))
+                self.generated += 1
+        except (RuntimeError, StopIteration):
+            pass
+
+        return generated_values
+
+    def _generate_num_samples(self, num_samples, template_samples):
path: rdt/transformers/id.py
user: login: fealho
body: @amontanez24 This gets renamed to `_sample_cyclic` in my other [PR](https://github.com/sdv-dev/RDT/pull/973/files#diff-2843a7c1ee8b6375ff9317f39aa6cc08ed411990f024bd691687d6417e54d54dR306).
created_at: 2025-04-07T16:33:43Z
author_association: MEMBER
diff_hunk: @@ -164,27 +203,35 @@ def _fit(self, data):
         self.reset_randomization()
         self.data_length = len(data)
 
+        if self.cardinality_rule == 'match':
+            is_nan = int(pd.isna(data).any())  # nans count as a unique value
+            self._data_cardinality = data.nunique() + is_nan
+            self._unique_regex_values = self._generate_unique_regex_values()
path: rdt/transformers/id.py
user: login: fealho
body: @amontanez24 That's what's specified in the issue:

* During fit: It should learn the number of unique values that were in the real data and save this info.
  * It can also go ahead and generate Regexes of that same size, to be used every time during reverse_transform.
created_at: 2025-04-07T16:37:14Z
author_association: MEMBER
diff_hunk: @@ -164,27 +203,35 @@ def _fit(self, data):
         self.reset_randomization()
         self.data_length = len(data)
 
+        if self.cardinality_rule == 'match':
+            is_nan = int(pd.isna(data).any())  # nans count as a unique value
+            self._data_cardinality = data.nunique() + is_nan
+            self._unique_regex_values = self._generate_unique_regex_values()
path: rdt/transformers/id.py
user: login: amontanez24
body: Is there any reason it would need to generate during fit? I think it makes more sense to just do the actual sampling during sampling.
created_at: 2025-04-07T16:41:23Z
author_association: CONTRIBUTOR
diff_hunk: @@ -164,27 +203,35 @@ def _fit(self, data):
         self.reset_randomization()
         self.data_length = len(data)
 
+        if self.cardinality_rule == 'match':
+            is_nan = int(pd.isna(data).any())  # nans count as a unique value
+            self._data_cardinality = data.nunique() + is_nan
+            self._unique_regex_values = self._generate_unique_regex_values()
path: rdt/transformers/id.py
user: login: fealho
body: Do you want to just move the `_generate_unique_regex_values` call to the first time `reverse_transform` is called? Or do you want to optimize this, e.g. if the `reverse_transform` data has fewer values than the fit data, only a subset of the regexes gets generated?
created_at: 2025-04-07T17:54:20Z
author_association: MEMBER
diff_hunk: @@ -164,27 +203,35 @@ def _fit(self, data):
         self.reset_randomization()
         self.data_length = len(data)
 
+        if self.cardinality_rule == 'match':
+            is_nan = int(pd.isna(data).any())  # nans count as a unique value
+            self._data_cardinality = data.nunique() + is_nan
+            self._unique_regex_values = self._generate_unique_regex_values()
path: rdt/transformers/id.py
user: login: amontanez24
body: I'm fine with either. Whichever works out to be cleaner and easier
created_at: 2025-04-07T18:04:20Z
author_association: CONTRIBUTOR

PR comments:
user: login: sdv-team
created_at: 2025-04-01T14:14:59Z
body: Task linked: [CU-86b47q01w RDT - For `RegexGenerator`, add add an option for `cardinality_rule='match'` to match the exact cardinality #950](https://app.clickup.com/t/86b47q01w)
author_association: CONTRIBUTOR
user: login: codecov[bot]
created_at: 2025-04-01T17:03:29Z
body: ## [Codecov](https://app.codecov.io/gh/sdv-dev/RDT/pull/971?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=sdv-dev) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 100.00%. Comparing base [(`130a5c0`)](https://app.codecov.io/gh/sdv-dev/RDT/commit/130a5c0b39051ccdd2baa041e5b1269f4bd8ecb2?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=sdv-dev) to head [(`dddd5b6`)](https://app.codecov.io/gh/sdv-dev/RDT/commit/dddd5b6545ed147fd06ac379039156dd73da58e9?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=sdv-dev).
> Report is 2 commits behind head on feature/allow-id-categorical-transformers.

<details><summary>Additional details and impacted files</summary>


```diff
@@                             Coverage Diff                             @@
##           feature/allow-id-categorical-transformers      #971   +/-   ##
===========================================================================
  Coverage                                     100.00%   100.00%           
===========================================================================
  Files                                             18        18           
  Lines                                           2357      2400   +43     
===========================================================================
+ Hits                                            2357      2400   +43     
```

| [Flag](https://app.codecov.io/gh/sdv-dev/RDT/pull/971/flags?src=pr&el=flags&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=sdv-dev) | Coverage Δ | |
|---|---|---|
| [integration](https://app.codecov.io/gh/sdv-dev/RDT/pull/971/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=sdv-dev) | `83.45% <97.43%> (+0.25%)` | :arrow_up: |
| [unit](https://app.codecov.io/gh/sdv-dev/RDT/pull/971/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=sdv-dev) | `100.00% <100.00%> (ø)` | |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=sdv-dev#carryforward-flags-in-the-pull-request-comment) to find out more.

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/sdv-dev/RDT/pull/971?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=sdv-dev).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=sdv-dev).

<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
</details>
author_association: NONE