Repository: sdv-dev/RDT

Issue:
AnonymizedFaker should have a fallback in the case that it's unable to generate enough unique values
### Problem Description
I'm filing this issue based on a discussion in [SDV #2427](https://github.com/sdv-dev/SDV/issues/2427): Faker functions generally have a max number of unique values that they are able to generate. In the case where you request a larger # of unique values, the transformer will crash.

```python
import pandas as pd
from rdt import HyperTransformer
from rdt.transformers.pii import AnonymizedFaker

ht = HyperTransformer()
ht.set_config({
    'sdtypes': {
        'name': 'pii'
    },
    'transformers': {
        'name': AnonymizedFaker(provider_name='person', function_name='first_name',
                                cardinality_rule='unique')
    }
})

test_data = pd.DataFrame(data={
    'name': ['Alice', 'Bob', 'Carol']
})

ht.fit(test_data)
ht.create_anonymized_columns(num_rows=1000, column_names=['name'])
```
```
TransformerProcessingError: The Faker function you specified is not able to generate 1000 unique values. Please use a different Faker function for column ('name').
```

### Expected behavior
The [RegexGenerator RDT](https://docs.sdv.dev/rdt/transformers-glossary/id/regexgenerator) runs into a very similar problem where uniqueness cannot be enforced if the Regex format string doesn't allow for a large enough # of values. In this case, we do not crash; we _continue to generate new strings_ while warning the user that they will not follow the format. 

We can take a similar approach for the AnonymizedFaker:
```python
ht.create_anonymized_columns(num_rows=1000, column_names=['name'])
```
```
UserWarning: Unable to generate enough unique values for column 'name' in a human-readable format. Additional values may be created randomly.
```

Approach: After exhausting all possibilities in the original function, swap over to using the [bothify function](https://faker.readthedocs.io/en/master/providers/baseprovider.html#faker.providers.BaseProvider.bothify) set to `'??????'` (with uniqueness on). This allows for 19 billion more unique possibilities (more than enough).

Alternative considered: Similar to `RegexGenerater`, we can repeat the values of the original function, but with numerical value after them to indicate that they are copies. For example, `Alice(1)`, `Bob(1)`, etc. This has the benefit that we can create an infinite amount of unique strings by just incrementing the value. The con is that it may lead to people assuming that `Alice` is actually related to `Alice(1)`, which is not true.

Hints from issue comments:

Solution patch:
diff --git a/rdt/transformers/pii/anonymizer.py b/rdt/transformers/pii/anonymizer.py
index 264c36b..fbd93ec 100644
--- a/rdt/transformers/pii/anonymizer.py
+++ b/rdt/transformers/pii/anonymizer.py
@@ -199,6 +199,10 @@ class AnonymizedFaker(BaseTransformer):
 
         return result
 
+    def _fallback_function(self):
+        """Return the result of calling a fallback ``faker`` function."""
+        return self.faker.unique.bothify(text='??????')
+
     def _set_faker_seed(self, data):
         hash_value = self.get_input_column()
         for value in data.head(5):
@@ -274,6 +278,23 @@ class AnonymizedFaker(BaseTransformer):
 
         return np.concatenate((unique_categories, extra_samples))
 
+    def _reverse_transform_with_fallback(self, sample_size):
+        try:
+            reverse_transformed = []
+            for _ in range(sample_size):
+                reverse_transformed.append(self._function())
+
+        except faker.exceptions.UniquenessException:
+            warnings.warn(
+                f"Unable to generate enough unique values for column '{self.get_input_column()}' "
+                'in a human-readable format. Additional values may be created randomly.'
+            )
+            remaining_samples = sample_size - len(reverse_transformed)
+            for _ in range(remaining_samples):
+                reverse_transformed.append(self._fallback_function())
+
+        return np.array(reverse_transformed, dtype=object)
+
     def _reverse_transform(self, data):
         """Generate new anonymized data using a ``faker.provider.function``.
 
@@ -289,21 +310,10 @@ class AnonymizedFaker(BaseTransformer):
         else:
             sample_size = self.data_length
 
-        try:
-            if hasattr(self, 'cardinality_rule') and self.cardinality_rule == 'match':
-                reverse_transformed = self._reverse_transform_cardinality_rule_match(sample_size)
-            else:
-                reverse_transformed = np.array(
-                    [self._function() for _ in range(sample_size)],
-                    dtype=object,
-                )
-
-        except faker.exceptions.UniquenessException as exception:
-            raise TransformerProcessingError(
-                f'The Faker function you specified is not able to generate {sample_size} unique '
-                'values. Please use a different Faker function for column '
-                f"('{self.get_input_column()}')."
-            ) from exception
+        if hasattr(self, 'cardinality_rule') and self.cardinality_rule == 'match':
+            reverse_transformed = self._reverse_transform_cardinality_rule_match(sample_size)
+        else:
+            reverse_transformed = self._reverse_transform_with_fallback(sample_size)
 
         if self.missing_value_generation == 'random' and not pd.isna(reverse_transformed).any():
             num_nans = int(self._nan_frequency * sample_size)
@@ -435,15 +445,8 @@ class PseudoAnonymizedFaker(AnonymizedFaker):
         self._set_faker_seed(columns_data)
         unique_values = columns_data[columns_data.notna()].unique()
         unique_data_length = len(unique_values)
-        try:
-            generated_values = [self._function() for _ in range(unique_data_length)]
-        except faker.exceptions.UniquenessException as exception:
-            raise TransformerProcessingError(
-                'The Faker function you specified is not able to generate '
-                f'{unique_data_length} unique values. Please use a different '
-                'Faker function for this column.'
-            ) from exception
 
+        generated_values = self._reverse_transform_with_fallback(unique_data_length)
         generated_values = list(set(generated_values))
         self._mapping_dict = dict(zip(unique_values, generated_values))
         self._reverse_mapping_dict = dict(zip(generated_values, unique_values))


Test patch:
diff --git a/tests/integration/transformers/pii/test_anonymizer.py b/tests/integration/transformers/pii/test_anonymizer.py
index 6a61dff..7a100f9 100644
--- a/tests/integration/transformers/pii/test_anonymizer.py
+++ b/tests/integration/transformers/pii/test_anonymizer.py
@@ -4,7 +4,6 @@ import numpy as np
 import pandas as pd
 import pytest
 
-from rdt.errors import TransformerProcessingError
 from rdt.transformers.pii import AnonymizedFaker, PseudoAnonymizedFaker
 
 
@@ -158,12 +157,11 @@ class TestAnonymizedFaker:
         # Assert
         assert len(reverse_transform['job'].unique()) == 500
 
-        error_msg = re.escape(
-            'The Faker function you specified is not able to generate 500 unique '
-            'values. Please use a different Faker function for column '
-            "('job')."
+        warning_msg = re.escape(
+            "Unable to generate enough unique values for column 'job' in "
+            'a human-readable format. Additional values may be created randomly.'
         )
-        with pytest.raises(TransformerProcessingError, match=error_msg):
+        with pytest.warns(UserWarning, match=warning_msg):
             instance.reverse_transform(transformed)
 
         instance.reset_randomization()
@@ -265,12 +263,11 @@ class TestAnonymizedFaker:
         # Assert
         assert len(reverse_transform['job'].unique()) == 500
 
-        error_msg = re.escape(
-            'The Faker function you specified is not able to generate 500 unique '
-            'values. Please use a different Faker function for column '
-            "('job')."
+        warning_msg = re.escape(
+            "Unable to generate enough unique values for column 'job' in "
+            'a human-readable format. Additional values may be created randomly.'
         )
-        with pytest.raises(TransformerProcessingError, match=error_msg):
+        with pytest.warns(UserWarning, match=warning_msg):
             instance.reverse_transform(transformed)
 
         instance.reset_randomization()
diff --git a/tests/unit/transformers/pii/test_anonymizer.py b/tests/unit/transformers/pii/test_anonymizer.py
index 0472b9a..c9dde11 100644
--- a/tests/unit/transformers/pii/test_anonymizer.py
+++ b/tests/unit/transformers/pii/test_anonymizer.py
@@ -5,6 +5,7 @@ import re
 import tempfile
 from unittest.mock import Mock, call, patch
 
+import faker
 import numpy as np
 import pandas as pd
 import pytest
@@ -629,7 +630,7 @@ class TestAnonymizedFaker:
         assert set(result) == {'a', 'b', 'c'}
 
     def test__reverse_transform_cardinality_rule_missing_attribute(self):
-        """Test it when the ``cardinality_rule`` attribute is missing."""
+        """Test that when the ``cardinality_rule`` attribute is missing."""
         # Setup
         instance = Mock()
         delattr(instance, 'cardinality_rule')
@@ -639,8 +640,32 @@ class TestAnonymizedFaker:
         # Run
         AnonymizedFaker._reverse_transform(instance, None)
 
+        # Assert
+        instance._reverse_transform_with_fallback.assert_called_once_with(3)
+
+    def test__reverse_transform_with_fallback_handles_uniqueness_exception(self):
+        """Test that when we reverse transform with fallback we handle uniqueness."""
+        # Setup
+        instance = AnonymizedFaker()
+        instance.get_input_column = Mock(return_value='test_column')
+        instance._function = Mock(
+            side_effect=['val1', 'val2', faker.exceptions.UniquenessException()]
+        )
+        instance._fallback_function = Mock(side_effect=['fallback1', 'fallback2', 'fallback3'])
+        sample_size = 5
+
+        # Run and Assert
+        warning_msg = re.escape(
+            "Unable to generate enough unique values for column 'test_column' in "
+            'a human-readable format. Additional values may be created randomly.'
+        )
+        with pytest.warns(UserWarning, match=warning_msg):
+            result = instance._reverse_transform_with_fallback(sample_size)
+
         # Assert
         assert instance._function.call_count == 3
+        assert instance._fallback_function.call_count == 3
+        assert result.tolist() == ['val1', 'val2', 'fallback1', 'fallback2', 'fallback3']
 
     def test__reverse_transform_with_nans(self):
         """Test that ``_reverse_transform`` generates NaNs."""
@@ -684,10 +709,10 @@ class TestAnonymizedFaker:
 
         # Run / Assert
         error_msg = re.escape(
-            'The Faker function you specified is not able to generate 4 unique '
-            "values. Please use a different Faker function for column ('a')."
+            "Unable to generate enough unique values for column 'a' in "
+            'a human-readable format. Additional values may be created randomly.'
         )
-        with pytest.raises(TransformerProcessingError, match=error_msg):
+        with pytest.warns(UserWarning, match=error_msg):
             instance._reverse_transform(data)
 
     def test__reverse_transform_size_is_length_of_data(self):
@@ -948,7 +973,7 @@ class TestPseudoAnonymizedFaker:
         """Test the ``_fit`` method.
 
         Test that when calling the ``_fit`` method and the ``instance._function`` is not
-        generating enough unique values raises an error.
+        generating enough unique values uses the fallback function.
 
         Setup:
             -Instance of ``PseudoAnonymizedFaker``.
@@ -968,12 +993,11 @@ class TestPseudoAnonymizedFaker:
         data = pd.Series(['a', 'b', 'c', 'd'])
 
         # Run / Assert
-        error_msg = (
-            'The Faker function you specified is not able to generate '
-            '4 unique values. Please use a different '
-            'Faker function for this column.'
+        warning_msg = re.escape(
+            "Unable to generate enough unique values for column 'col' in "
+            'a human-readable format. Additional values may be created randomly.'
         )
-        with pytest.raises(TransformerProcessingError, match=error_msg):
+        with pytest.warns(UserWarning, match=warning_msg):
             instance._fit(data)
 
     def test__transform(self):