Repository: sdv-dev/RDT

Issue:
The `AnonymizedFaker` does not match cardinality across multiple applications of `reverse_transform`
### Environment Details
* RDT version: 1.14.0
* Tested with SDV version: 1.18.0

### Error Description
The [`AnonymizedFaker` transformer](https://docs.sdv.dev/rdt/transformers-glossary/id/anonymizedfaker#parameters) has a parameter called `cardinality_rule` that's meant to control the # of unique values generated (from reverse_transform). When I apply this transformer with `cardinality_rule='match'`, I expect that the transformer should (a) learn the # of unique values in the my original data, and (b) only ever generate the same # of unique values during reverse transform.

The key part to (b) is that the **# of generated unique values should be global across all/any applications of reverse transform**. So if I reverse transform data twice and concatenate the results, it shouldn't be an issue.

In practice, this is an issue. The cardinality is match locally during each application of `reverse_transform`, but it is not match globally across multiple applications.

### Steps to reproduce
You can replicate this via an SDV synthesizer. Apply the `AnonymizedFaker` transformer with this cardinality rule. 

```python
import pandas as pd
from sdv.metadata import Metadata
from rdt.transformers.pii import AnonymizedFaker

# the 'name' column has 3 unique values
data = pd.DataFrame(data={'name': ['Amy']*10 + ['Bob']*20 + ['Carla']*50})

metadata = Metadata.load_from_dict({
    'tables': {
        'table': {
            'columns': {
                'name': { 'sdtype': 'id' }}}}})

synthesizer = GaussianCopulaSynthesizer(metadata)
synthesizer.auto_assign_transformers(data)
synthesizer.update_transformers({
    'name': AnonymizedFaker(
        provider_name='person',
        function_name='name', 
        cardinality_rule='match')
})
synthesizer.fit(data)

# sample synthetic data multiple times
# this essentially calls the transformer's reverse_transform multiple times
synthetic_data1 = synthesizer.sample(80)
synthetic_data2 = synthesizer.sample(80)

# stitch the synthetic data togther
all_synthetic_data = pd.concat([synthetic_data, synthetic_data2])
all_synthetic_data['name'].value_counts()
```

You can observe that each sample of synthetic data (`synthetic_data1` and `synthetic_data2`) has the correct cardinality (3 unique values). But when combined, there are actually 6 unique values. 

<img width="752" alt="Image" src="https://github.com/user-attachments/assets/102e7264-df12-44a9-85eb-8f93335b3f90" />

This should not be the case. No matter how many times I sample data (reverse transform the transformer), there should only ever be 3 unique values created.

### Additional Context
A possible fix: Randomly generate new values in `fit`, after learning the # of unique values. That way, every time we `reverse_transform`, we can pick from that same list.

Discussion:

Solution patch:
diff --git a/rdt/transformers/pii/anonymizer.py b/rdt/transformers/pii/anonymizer.py
index 9094217..68de067 100644
--- a/rdt/transformers/pii/anonymizer.py
+++ b/rdt/transformers/pii/anonymizer.py
@@ -155,6 +155,7 @@ class AnonymizedFaker(BaseTransformer):
 
         self.missing_value_generation = missing_value_generation
         self._nan_frequency = 0.0
+        self._unique_categories = None
 
     @classmethod
     def get_supported_sdtypes(cls):
@@ -192,11 +193,11 @@ class AnonymizedFaker(BaseTransformer):
                 faker_attr = self.faker.unique
             else:
                 faker_attr = self.faker
+
         except AttributeError:
             faker_attr = self.faker.unique if self.enforce_uniqueness else self.faker
 
         result = getattr(faker_attr, self.function_name)(**self.function_kwargs)
-
         if isinstance(result, Iterable) and not isinstance(result, str):
             result = ', '.join(map(str, result))
 
@@ -236,39 +237,47 @@ class AnonymizedFaker(BaseTransformer):
 
     def _reverse_transform_cardinality_rule_match(self, sample_size):
         """Reverse transform the data when the cardinality rule is 'match'."""
-        reverse_transformed = np.array([], dtype=object)
-        if self.missing_value_generation == 'random':
-            num_nans = int(self._nan_frequency * sample_size)
-            reverse_transformed = np.concatenate([
-                reverse_transformed,
-                np.full(num_nans, np.nan),
-            ])
-        else:
-            num_nans = 0
+        num_nans = self._calculate_num_nans(sample_size)
+        reverse_transformed = self._generate_nans(num_nans)
 
         if sample_size <= num_nans:
             return reverse_transformed
 
-        if sample_size < num_nans + self._data_cardinality:
-            unique_categories = self._get_unique_categories(sample_size - num_nans)
-            reverse_transformed = np.concatenate([
-                reverse_transformed,
-                unique_categories,
-            ])
-        else:
-            unique_categories = self._get_unique_categories(self._data_cardinality)
-            num_copies = sample_size - self._data_cardinality - num_nans
-            copies = np.random.choice(unique_categories, num_copies)
-            reverse_transformed = np.concatenate([
-                reverse_transformed,
-                unique_categories,
-                copies,
-            ])
+        remaining_samples = sample_size - num_nans
+        sampled_values = self._generate_cardinality_match_values(remaining_samples)
 
+        reverse_transformed = np.concatenate([reverse_transformed, sampled_values])
         np.random.shuffle(reverse_transformed)
 
         return reverse_transformed
 
+    def _calculate_num_nans(self, sample_size):
+        """Calculate the number of NaN values to generate."""
+        if self.missing_value_generation == 'random':
+            return int(self._nan_frequency * sample_size)
+
+        return 0
+
+    def _generate_nans(self, num_nans):
+        """Generate an array of NaN values."""
+        return np.full(num_nans, np.nan, dtype=object)
+
+    def _generate_cardinality_match_values(self, remaining_samples):
+        """Generate sampled values while ensuring each unique category appears at least once."""
+        # Backwards compatibility requires us to generate the values at this point
+        if self._unique_categories is None:
+            self._unique_categories = self._get_unique_categories(self._data_cardinality)
+
+        unique_categories = np.array(self._unique_categories)
+        if remaining_samples <= len(unique_categories):
+            return np.random.choice(unique_categories, remaining_samples, replace=False)
+
+        # Ensure all unique categories appear at least once
+        extra_samples_needed = remaining_samples - len(unique_categories)
+        extra_samples = np.random.choice(unique_categories, extra_samples_needed, replace=True)
+
+        return np.concatenate((unique_categories, extra_samples))
+
     def _reverse_transform(self, data):
         """Generate new anonymized data using a ``faker.provider.function``.
 
@@ -328,6 +337,7 @@ class AnonymizedFaker(BaseTransformer):
                 raise TransformerInputError(
                     'Cardinality "match" rule must specify a cardinality value.'
                 )
+
         self._data_cardinality = cardinality
         self._nan_frequency = nan_frequency
 


Test patch:
diff --git a/tests/integration/transformers/pii/test_anonymizer.py b/tests/integration/transformers/pii/test_anonymizer.py
index d99dc90..6a61dff 100644
--- a/tests/integration/transformers/pii/test_anonymizer.py
+++ b/tests/integration/transformers/pii/test_anonymizer.py
@@ -297,6 +297,30 @@ class TestAnonymizedFaker:
         # Assert
         assert missing_values / output.size == freq
 
+    def test_anonymized_faker_produces_only_n_values_for_each_reverse_transform_cardinality_match(
+        self,
+    ):
+        """Test `AnonymizedFaker` when `cardinality_rule` is set to `match`.
+
+        Ensure that the AnonymizedFaker transformer with `cardinality_rule='match'`
+        maintains the correct number of unique values across multiple `reverse_transform` calls.
+        """
+        # Setup
+        data = pd.DataFrame(data={'name': ['Amy'] * 10 + ['Bob'] * 20 + ['Carla'] * 50})
+        transformer = AnonymizedFaker(
+            provider_name='person', function_name='name', cardinality_rule='match'
+        )
+
+        # Run
+        transformed_data = transformer.fit_transform(data, 'name')
+        first_reverse_transformed = transformer.reverse_transform(transformed_data)
+
+        transformed_again = transformer.transform(first_reverse_transformed)
+        second_reverse_transformed = transformer.reverse_transform(transformed_again)
+
+        # Assert
+        assert set(first_reverse_transformed['name']) == set(second_reverse_transformed['name'])
+
 
 class TestPsuedoAnonymizedFaker:
     def test_default_settings(self):
diff --git a/tests/unit/transformers/pii/test_anonymizer.py b/tests/unit/transformers/pii/test_anonymizer.py
index 05d3a14..c9435d9 100644
--- a/tests/unit/transformers/pii/test_anonymizer.py
+++ b/tests/unit/transformers/pii/test_anonymizer.py
@@ -603,6 +603,7 @@ class TestAnonymizedFaker:
         instance = AnonymizedFaker(missing_value_generation=None)
         instance._data_cardinality = 2
         instance._nan_frequency = 0
+        instance._unique_categories = ['a', 'b', 'c']
         function = Mock()
         function.side_effect = ['a', 'b', 'c']
 
@@ -612,25 +613,22 @@ class TestAnonymizedFaker:
         result = instance._reverse_transform_cardinality_rule_match(3)
 
         # Assert
-        assert function.call_args_list == [call(), call()]
-        assert set(result).issubset({'a', 'b'})
+        assert set(result) == set(['a', 'b', 'c'])
 
     def test__reverse_transform_cardinality_rule_match_not_enough_unique(self):
         """Test it when there are not enough unique values."""
         # Setup
         instance = AnonymizedFaker()
-        instance._data_cardinality = 4
+        instance._data_cardinality = 3
         instance._nan_frequency = 0
         function = Mock()
         function.side_effect = ['a', 'b', 'c', 'd']
-
         instance._function = function
 
         # Run
-        result = instance._reverse_transform_cardinality_rule_match(3)
+        result = instance._reverse_transform_cardinality_rule_match(6)
 
         # Assert
-        assert function.call_args_list == [call(), call(), call()]
         assert set(result) == {'a', 'b', 'c'}
 
     def test__reverse_transform_cardinality_rule_missing_attribute(self):