Repository: sdv-dev/RDT (2024-02-07 ~ 2024-03-21)

problem_statement: Move the _learn_rounding_digits of the FloatFormatter into a helper
### Problem Description

Currently, the method to learn the rounding of a column, `_learn_rounding_digits`, is defined inside the `FloatFormatter`.
We would like to move it into a helper file so other transformers can use it. 

### Additional context
* The tests should be also moved
* The method is defined here:
https://github.com/sdv-dev/RDT/blob/c3c86acc50b3c9165f95bd45f698874f5491c9ea/rdt/transformers/numerical.py#L95

date: 2024-02-07
patch: diff --git a/rdt/transformers/numerical.py b/rdt/transformers/numerical.py
index 86bc437..94be5b7 100644
--- a/rdt/transformers/numerical.py
+++ b/rdt/transformers/numerical.py
@@ -1,7 +1,5 @@
 """Transformers for numerical data."""
 import copy
-import logging
-import sys
 import warnings
 
 import numpy as np
@@ -11,11 +9,9 @@ from sklearn.mixture import BayesianGaussianMixture
 
 from rdt.transformers.base import BaseTransformer
 from rdt.transformers.null import NullTransformer
-
-LOGGER = logging.getLogger(__name__)
+from rdt.transformers.utils import learn_rounding_digits
 
 EPSILON = np.finfo(np.float32).eps
-MAX_DECIMALS = sys.float_info.dig - 1
 INTEGER_BOUNDS = {
     'Int8': (-2**7, 2**7 - 1),
     'Int16': (-2**15, 2**15 - 1),
@@ -91,31 +87,6 @@ class FloatFormatter(BaseTransformer):
         self.enforce_min_max_values = enforce_min_max_values
         self.computer_representation = computer_representation
 
-    @staticmethod
-    def _learn_rounding_digits(data):
-        # check if data has any decimals
-        name = data.name
-        data = np.array(data)
-        roundable_data = data[~(np.isinf(data) | pd.isna(data))]
-
-        # Doesn't contain numbers
-        if len(roundable_data) == 0:
-            return None
-
-        # Doesn't contain decimal digits
-        if ((roundable_data % 1) == 0).all():
-            return 0
-
-        # Try to round to fewer digits
-        if (roundable_data == roundable_data.round(MAX_DECIMALS)).all():
-            for decimal in range(MAX_DECIMALS + 1):
-                if (roundable_data == roundable_data.round(decimal)).all():
-                    return decimal
-
-        # Can't round, not equal after MAX_DECIMALS digits of precision
-        LOGGER.info("No rounding scheme detected for column '%s'. Data will not be rounded.", name)
-        return None
-
     def _raise_out_of_bounds_error(self, value, name, bound_type, min_bound, max_bound):
         raise ValueError(
             f"The {bound_type} value in column '{name}' is {value}."
@@ -158,7 +129,7 @@ class FloatFormatter(BaseTransformer):
             self._max_value = data.max()
 
         if self.learn_rounding_scheme:
-            self._rounding_digits = self._learn_rounding_digits(data)
+            self._rounding_digits = learn_rounding_digits(data)
 
         self.null_transformer = NullTransformer(
             self.missing_value_replacement,
diff --git a/rdt/transformers/utils.py b/rdt/transformers/utils.py
index 4e78685..10a6036 100644
--- a/rdt/transformers/utils.py
+++ b/rdt/transformers/utils.py
@@ -1,7 +1,9 @@
 """Tools to generate strings from regular expressions."""
 
+import logging
 import re
 import string
+import sys
 import warnings
 
 import numpy as np
@@ -9,6 +11,10 @@ import pandas as pd
 
 import sre_parse  # isort:skip
 
+LOGGER = logging.getLogger(__name__)
+
+MAX_DECIMALS = sys.float_info.dig - 1
+
 
 def _literal(character, max_repeat):
     del max_repeat
@@ -234,3 +240,38 @@ def try_convert_to_dtype(data, dtype):
             raise error
 
     return data
+
+
+def learn_rounding_digits(data):
+    """Learn the number of digits to round data to.
+
+    Args:
+        data (pd.Series):
+            Data to learn the number of digits to round to.
+
+    Returns:
+        int or None:
+            Number of digits to round to.
+    """
+    # check if data has any decimals
+    name = data.name
+    data = np.array(data)
+    roundable_data = data[~(np.isinf(data) | pd.isna(data))]
+
+    # Doesn't contain numbers
+    if len(roundable_data) == 0:
+        return None
+
+    # Doesn't contain decimal digits
+    if ((roundable_data % 1) == 0).all():
+        return 0
+
+    # Try to round to fewer digits
+    if (roundable_data == roundable_data.round(MAX_DECIMALS)).all():
+        for decimal in range(MAX_DECIMALS + 1):
+            if (roundable_data == roundable_data.round(decimal)).all():
+                return decimal
+
+    # Can't round, not equal after MAX_DECIMALS digits of precision
+    LOGGER.info("No rounding scheme detected for column '%s'. Data will not be rounded.", name)
+    return None

test_patch: diff --git a/tests/unit/transformers/test_numerical.py b/tests/unit/transformers/test_numerical.py
index c023edc..0b0f4a4 100644
--- a/tests/unit/transformers/test_numerical.py
+++ b/tests/unit/transformers/test_numerical.py
@@ -24,86 +24,6 @@ class TestFloatFormatter(TestCase):
         assert nt.missing_value_replacement == 'mode'
         assert nt.missing_value_generation == 'random'
 
-    @patch('rdt.transformers.numerical.LOGGER')
-    def test__learn_rounding_digits_more_than_15_decimals(self, logger_mock):
-        """Test the _learn_rounding_digits method with more than 15 decimals.
-
-        If the data has more than 15 decimals, return None and raise warning.
-        """
-        # Setup
-        data = pd.Series(np.random.random(size=10).round(20), name='col')
-
-        # Run
-        output = FloatFormatter._learn_rounding_digits(data)
-
-        # Assert
-        logger_msg = "No rounding scheme detected for column '%s'. Data will not be rounded."
-        logger_mock.info.assert_called_once_with(logger_msg, 'col')
-        assert output is None
-
-    def test__learn_rounding_digits_less_than_15_decimals(self):
-        """Test the _learn_rounding_digits method with less than 15 decimals.
-
-        If the data has less than 15 decimals, the maximum number of decimals
-        should be returned.
-
-        Input:
-        - An array that contains floats with a maximum of 3 decimals and a
-          NaN.
-        Output:
-        - 3
-        """
-        data = pd.Series(np.array([10, 0., 0.1, 0.12, 0.123, np.nan]))
-
-        output = FloatFormatter._learn_rounding_digits(data)
-
-        assert output == 3
-
-    def test__learn_rounding_digits_negative_decimals_float(self):
-        """Test the _learn_rounding_digits method with floats multiples of powers of 10.
-
-        If the data has all multiples of 10 the output should be 0.
-
-        Input:
-        - An array that contains floats that are multiples of powers of 10, 100 and 1000 and a NaN.
-        """
-        data = pd.Series(np.array([1230., 12300., 123000., np.nan]))
-
-        output = FloatFormatter._learn_rounding_digits(data)
-
-        assert output == 0
-
-    def test__learn_rounding_digits_negative_decimals_integer(self):
-        """Test the _learn_rounding_digits method with integers multiples of powers of 10.
-
-        If the data has all multiples of 10 the output should be 0.
-
-        Input:
-        - An array that contains integers that are multiples of powers of 10, 100 and 1000
-          and a NaN.
-        """
-        data = pd.Series(np.array([1230, 12300, 123000, np.nan]))
-
-        output = FloatFormatter._learn_rounding_digits(data)
-
-        assert output == 0
-
-    def test__learn_rounding_digits_all_missing_value_replacements(self):
-        """Test the _learn_rounding_digits method with data that is all NaNs.
-
-        If the data is all NaNs, expect that the output is None.
-
-        Input:
-        - An array of NaN.
-        Output:
-        - None
-        """
-        data = pd.Series(np.array([np.nan, np.nan, np.nan, np.nan]))
-
-        output = FloatFormatter._learn_rounding_digits(data)
-
-        assert output is None
-
     def test__validate_values_within_bounds(self):
         """Test the ``_validate_values_within_bounds`` method.
 
diff --git a/tests/unit/transformers/test_utils.py b/tests/unit/transformers/test_utils.py
index d902023..6bc27bd 100644
--- a/tests/unit/transformers/test_utils.py
+++ b/tests/unit/transformers/test_utils.py
@@ -1,13 +1,14 @@
 import sre_parse
 from sre_constants import MAXREPEAT
+from unittest.mock import patch
 
 import numpy as np
 import pandas as pd
 import pytest
 
 from rdt.transformers.utils import (
-    _any, _max_repeat, check_nan_in_transform, flatten_column_list, strings_from_regex,
-    try_convert_to_dtype)
+    _any, _max_repeat, check_nan_in_transform, flatten_column_list, learn_rounding_digits,
+    strings_from_regex, try_convert_to_dtype)
 
 
 def test_strings_from_regex_literal():
@@ -127,3 +128,88 @@ def test_try_convert_to_dtype():
     expected_data_convertibe = pd.Series(['1.0', '2.0', 'nan', '4.0', '5.0'])
     pd.testing.assert_series_equal(output_int_with_nan, expected_data_with_nan)
     pd.testing.assert_series_equal(output_convertibe, expected_data_convertibe)
+
+
+@patch('rdt.transformers.utils.LOGGER')
+def test_learn_rounding_digits_more_than_15_decimals(logger_mock):
+    """Test the learn_rounding_digits method with more than 15 decimals.
+
+    If the data has more than 15 decimals, return None and raise warning.
+    """
+    # Setup
+    data = pd.Series(np.random.random(size=10).round(20), name='col')
+
+    # Run
+    output = learn_rounding_digits(data)
+
+    # Assert
+    logger_msg = "No rounding scheme detected for column '%s'. Data will not be rounded."
+    logger_mock.info.assert_called_once_with(logger_msg, 'col')
+    assert output is None
+
+
+def test_learn_rounding_digits_less_than_15_decimals():
+    """Test the learn_rounding_digits method with less than 15 decimals.
+
+    If the data has less than 15 decimals, the maximum number of decimals
+    should be returned.
+
+    Input:
+    - An array that contains floats with a maximum of 3 decimals and a
+        NaN.
+    Output:
+    - 3
+    """
+    data = pd.Series(np.array([10, 0., 0.1, 0.12, 0.123, np.nan]))
+
+    output = learn_rounding_digits(data)
+
+    assert output == 3
+
+
+def test_learn_rounding_digits_negative_decimals_float():
+    """Test the learn_rounding_digits method with floats multiples of powers of 10.
+
+    If the data has all multiples of 10 the output should be 0.
+
+    Input:
+    - An array that contains floats that are multiples of powers of 10, 100 and 1000 and a NaN.
+    """
+    data = pd.Series(np.array([1230., 12300., 123000., np.nan]))
+
+    output = learn_rounding_digits(data)
+
+    assert output == 0
+
+
+def test_learn_rounding_digits_negative_decimals_integer():
+    """Test the learn_rounding_digits method with integers multiples of powers of 10.
+
+    If the data has all multiples of 10 the output should be 0.
+
+    Input:
+    - An array that contains integers that are multiples of powers of 10, 100 and 1000
+        and a NaN.
+    """
+    data = pd.Series(np.array([1230, 12300, 123000, np.nan]))
+
+    output = learn_rounding_digits(data)
+
+    assert output == 0
+
+
+def test_learn_rounding_digits_all_missing_value_replacements():
+    """Test the learn_rounding_digits method with data that is all NaNs.
+
+    If the data is all NaNs, expect that the output is None.
+
+    Input:
+    - An array of NaN.
+    Output:
+    - None
+    """
+    data = pd.Series(np.array([np.nan, np.nan, np.nan, np.nan]))
+
+    output = learn_rounding_digits(data)
+
+    assert output is None


----

problem_statement: Allow `AnonymizedFaker` to learn cardinality from the real data
### Problem Description
I have a column with sensitive information such as SSNs or phone numbers. In some cases, I may want to anonymize this data but I want the _cardinality_ of the column to remain the same. That is, if my original data had 20 unique SSNs (that were repeated), then I want the anonymized data to also contain max 20 randomly-generated SSNs that repeat.

### Expected Behavior
Deprecate the `enforce_uniqueness` parameter and instead replace it with a new parameter called `cardinality_rule`. This parameter can take on 3 different values:

- (default) `None`: Do not consider cardinality. This will just allow Faker to create random values, without any regard to cardinality (same as today's `enforce_uniqueness=False`)
- `unique`: Enforce that every created value is unique (the same as today's `enforce_uniqueness=True`)
- `match`: Match the cardinality of the data seen during `fit`. Eg. if the data contains 20 unique values, then enforce that max 20 unique values are created during reverse transform.

### Additional context
For backwards compatibility, we can still accept the `enforce_uniqueness` and provide a deprecation notice when it is used.

```python
transformer = AnonymizedFaker(enforce_uniqueness=True)
FutureWarning: The 'enforce_uniqueness' parameter is no longer supported. Please use the 'cardinality_rule' parameter instead.
```
date: 2024-02-13
patch: diff --git a/rdt/transformers/pii/anonymizer.py b/rdt/transformers/pii/anonymizer.py
index 00090fa..ca7c7f5 100644
--- a/rdt/transformers/pii/anonymizer.py
+++ b/rdt/transformers/pii/anonymizer.py
@@ -10,6 +10,7 @@ from operator import attrgetter
 
 import faker
 import numpy as np
+import pandas as pd
 
 from rdt.errors import TransformerInputError, TransformerProcessingError
 from rdt.transformers.base import BaseTransformer
@@ -33,9 +34,15 @@ class AnonymizedFaker(BaseTransformer):
             Keyword args to pass into the ``function_name`` when being called.
         locales (list):
             List of localized providers to use instead of the global provider.
+        cardinality_rule (str):
+            If ``'unique'`` enforce that every created value is unique.
+            If ``'match'`` match the cardinality of the data seen during fit.
+            If ``None`` do not consider cardinality.
+            Defaults to ``None``.
         enforce_uniqueness (bool):
-            Whether or not to ensure that the new anonymized data is all unique. If it isn't
-            possible to create the requested number of rows, then an error will be raised.
+            **DEPRECATED** Whether or not to ensure that the new anonymized data is all unique.
+            If it isn't possible to create the requested number of rows, then an error will be
+            raised.
             Defaults to ``False``.
         missing_value_generation (str or None):
             The way missing values are being handled. There are two strategies:
@@ -47,6 +54,8 @@ class AnonymizedFaker(BaseTransformer):
 
     """
 
+    # pylint: disable=too-many-instance-attributes
+
     IS_GENERATOR = True
     INPUT_SDTYPE = 'pii'
 
@@ -98,10 +107,21 @@ class AnonymizedFaker(BaseTransformer):
             )
 
     def __init__(self, provider_name=None, function_name=None, function_kwargs=None,
-                 locales=None, enforce_uniqueness=False, missing_value_generation='random'):
+                 locales=None, cardinality_rule=None, enforce_uniqueness=False,
+                 missing_value_generation='random'):
         super().__init__()
+        self._data_cardinality = None
         self.data_length = None
-        self.enforce_uniqueness = enforce_uniqueness
+        self.cardinality_rule = cardinality_rule.lower() if cardinality_rule else None
+        if enforce_uniqueness:
+            warnings.warn(
+                "The 'enforce_uniqueness' parameter is no longer supported. "
+                "Please use the 'cardinality_rule' parameter instead.",
+                FutureWarning
+            )
+            if not self.cardinality_rule:
+                self.cardinality_rule = 'unique'
+
         self.provider_name = provider_name if provider_name else 'BaseProvider'
         if self.provider_name != 'BaseProvider' and function_name is None:
             raise TransformerInputError(
@@ -156,7 +176,11 @@ class AnonymizedFaker(BaseTransformer):
 
     def _function(self):
         """Return the result of calling the ``faker`` function."""
-        faker_attr = self.faker.unique if self.enforce_uniqueness else self.faker
+        if self.cardinality_rule in {'unique', 'match'}:
+            faker_attr = self.faker.unique
+        else:
+            faker_attr = self.faker
+
         result = getattr(faker_attr, self.function_name)(**self.function_kwargs)
 
         if isinstance(result, Iterable) and not isinstance(result, str):
@@ -185,10 +209,42 @@ class AnonymizedFaker(BaseTransformer):
         if self.missing_value_generation == 'random':
             self._nan_frequency = data.isna().sum() / len(data)
 
+        if self.cardinality_rule == 'match':
+            # remove nans from data
+            self._data_cardinality = len(data.dropna().unique())
+
     def _transform(self, _data):
         """Drop the input column by returning ``None``."""
         return None
 
+    def _get_unique_categories(self, samples):
+        return np.array([self._function() for _ in range(samples)], dtype=object)
+
+    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
+
+        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])
+
+        np.random.shuffle(reverse_transformed)
+
+        return reverse_transformed
+
     def _reverse_transform(self, data):
         """Generate new anonymized data using a ``faker.provider.function``.
 
@@ -205,10 +261,14 @@ class AnonymizedFaker(BaseTransformer):
             sample_size = self.data_length
 
         try:
-            reverse_transformed = np.array([
-                self._function()
-                for _ in range(sample_size)
-            ], dtype=object)
+            if 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 '
@@ -216,7 +276,7 @@ class AnonymizedFaker(BaseTransformer):
                 f"('{self.get_input_column()}')."
             ) from exception
 
-        if self.missing_value_generation == 'random':
+        if self.missing_value_generation == 'random' and not pd.isna(reverse_transformed).any():
             num_nans = int(self._nan_frequency * sample_size)
             nan_indices = np.random.choice(sample_size, num_nans, replace=False)
             reverse_transformed[nan_indices] = np.nan
@@ -235,6 +295,7 @@ class AnonymizedFaker(BaseTransformer):
         args = inspect.getfullargspec(self.__init__)
         keys = args.args[1:]
         defaults = dict(zip(keys, args.defaults))
+        keys.remove('enforce_uniqueness')
         instanced = {key: getattr(self, key) for key in keys}
 
         defaults['function_name'] = None
@@ -283,7 +344,7 @@ class PseudoAnonymizedFaker(AnonymizedFaker):
             function_name=function_name,
             function_kwargs=function_kwargs,
             locales=locales,
-            enforce_uniqueness=True
+            cardinality_rule='unique'
         )
         self._mapping_dict = {}
         self._reverse_mapping_dict = {}

test_patch: diff --git a/tests/integration/transformers/pii/test_anonymizer.py b/tests/integration/transformers/pii/test_anonymizer.py
index bd99de0..a930f62 100644
--- a/tests/integration/transformers/pii/test_anonymizer.py
+++ b/tests/integration/transformers/pii/test_anonymizer.py
@@ -129,7 +129,7 @@ class TestAnonymizedFaker:
         assert len(reverse_transform['cc']) == 5
         assert reverse_transform['cc'].isna().sum() == 1
 
-    def test_enforce_uniqueness(self):
+    def test_cardinality_rule(self):
         """Test that ``AnonymizedFaker`` works with uniqueness.
 
         Also ensure that when we call ``reset_randomization`` the generator will be able to
@@ -139,7 +139,7 @@ class TestAnonymizedFaker:
             'job': np.arange(500)
         })
 
-        instance = AnonymizedFaker('job', 'job', enforce_uniqueness=True)
+        instance = AnonymizedFaker('job', 'job', cardinality_rule='unique')
         transformed = instance.fit_transform(data, 'job')
         reverse_transform = instance.reverse_transform(transformed)
 
@@ -157,6 +157,97 @@ class TestAnonymizedFaker:
         instance.reset_randomization()
         instance.reverse_transform(transformed)
 
+    def test_cardinality_rule_match(self):
+        """Test it works with the cardinality rule 'match'."""
+        # Setup
+        data = pd.DataFrame({
+            'col': [1, 2, 3, 1, 2]
+        })
+        instance = AnonymizedFaker(cardinality_rule='match')
+
+        # Run
+        transformed = instance.fit_transform(data, 'col')
+        reverse_transform = instance.reverse_transform(transformed)
+
+        # Assert
+        assert len(reverse_transform['col'].unique()) == 3
+
+    def test_cardinality_rule_match_nans(self):
+        """Test it works with the cardinality rule 'match' with nans."""
+        # Setup
+        data = pd.DataFrame({
+            'col': [1, 2, 3, 1, 2, None, np.nan, np.nan, 2]
+        })
+        instance = AnonymizedFaker(cardinality_rule='match')
+
+        # Run
+        transformed = instance.fit_transform(data, 'col')
+        reverse_transform = instance.reverse_transform(transformed)
+
+        # Assert
+        assert len(reverse_transform['col'].unique()) == 4
+        assert reverse_transform['col'].isna().sum() == 3
+
+    def test_cardinality_rule_match_not_enough_unique_values(self):
+        """Test it works with the cardinality rule 'match' and too few values to transform."""
+        # Setup
+        data_fit = pd.DataFrame({
+            'col': [1, 2, 3, 1, 2, None, np.nan, np.nan, 2]
+        })
+        data_transform = pd.DataFrame({
+            'col': [1, 1, 1]
+        })
+        instance = AnonymizedFaker(cardinality_rule='match')
+
+        # Run
+        transformed = instance.fit(data_fit, 'col')
+        transformed = instance.transform(data_transform)
+        reverse_transform = instance.reverse_transform(transformed)
+
+        # Assert
+        assert len(reverse_transform['col'].unique()) == 3
+        assert reverse_transform['col'].isna().sum() == 1
+
+    def test_cardinality_rule_match_too_many_unique(self):
+        """Test it works with the cardinality rule 'match' and more unique values than samples."""
+        # Setup
+        data_fit = pd.DataFrame({
+            'col': [1, 2, 3, 4, 5, 6]
+        })
+        data_transform = pd.DataFrame({
+            'col': [1, 1, np.nan, 3, 1]
+        })
+        instance = AnonymizedFaker(cardinality_rule='match')
+
+        # Run
+        transformed = instance.fit(data_fit, 'col')
+        transformed = instance.transform(data_transform)
+        reverse_transform = instance.reverse_transform(transformed)
+
+        # Assert
+        assert len(reverse_transform['col'].unique()) == 5
+        assert reverse_transform['col'].isna().sum() == 0
+
+    def test_cardinality_rule_match_too_many_nans(self):
+        """Test it works with the cardinality rule 'match' and more nans than possible to fit."""
+        # Setup
+        data_fit = pd.DataFrame({
+            'col': [1, 2, 3, np.nan, np.nan, np.nan]
+        })
+        data_transform = pd.DataFrame({
+            'col': [1, 1, 1, 1]
+        })
+        instance = AnonymizedFaker(cardinality_rule='match')
+
+        # Run
+        transformed = instance.fit(data_fit, 'col')
+        transformed = instance.transform(data_transform)
+        reverse_transform = instance.reverse_transform(transformed)
+
+        # Assert
+        assert len(reverse_transform['col'].unique()) == 3
+        assert reverse_transform['col'].isna().sum() == 2
+
 
 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 6ba1239..aece067 100644
--- a/tests/unit/transformers/pii/test_anonymizer.py
+++ b/tests/unit/transformers/pii/test_anonymizer.py
@@ -78,7 +78,7 @@ class TestAnonymizedFaker:
         with pytest.raises(TransformerProcessingError, match=expected_message):
             AnonymizedFaker.check_provider_function('TestProvider', 'TestFunction')
 
-    def test__function_enforce_uniqueness_false(self):
+    def test__function_cardinality_rule_none(self):
         """Test that ``_function`` does not use ``faker.unique``.
 
         The method ``_function`` should return a call from the
@@ -86,7 +86,6 @@ class TestAnonymizedFaker:
 
         Mock:
             - Instance of 'AnonymizedFaker'.
-            - Instance ``enforce_uniqueness`` set to `False`
             - Faker instance.
             - A function for the faker instance.
 
@@ -99,11 +98,11 @@ class TestAnonymizedFaker:
         """
         # setup
         instance = Mock()
-        instance.enforce_uniqueness = False
         function = Mock()
         unique_function = Mock()
         function.return_value = 1
 
+        instance.cardinality_rule = None
         instance.faker.unique.number = unique_function
         instance.faker.number = function
         instance.function_name = 'number'
@@ -117,7 +116,7 @@ class TestAnonymizedFaker:
         function.assert_called_once_with(type='int')
         assert result == 1
 
-    def test__function_enforce_uniqueness_true(self):
+    def test__function_cardinality_rule_unique(self):
         """Test that ``_function`` uses the ``faker.unique``.
 
         The method ``_function`` should return a call from the
@@ -125,7 +124,6 @@ class TestAnonymizedFaker:
 
         Mock:
             - Instance of 'AnonymizedFaker'.
-            - Instance ``enforce_uniqueness`` set to ``True``
             - Faker instance.
             - A function for the faker instance.
 
@@ -138,11 +136,33 @@ class TestAnonymizedFaker:
         """
         # setup
         instance = Mock()
-        instance.enforce_uniqueness = True
         function = Mock()
         unique_function = Mock()
         unique_function.return_value = 1
 
+        instance.cardinality_rule = 'unique'
+        instance.faker.unique.number = unique_function
+        instance.faker.number = function
+        instance.function_name = 'number'
+        instance.function_kwargs = {'type': 'int'}
+
+        # Run
+        result = AnonymizedFaker._function(instance)
+
+        # Assert
+        function.assert_not_called()
+        unique_function.assert_called_once_with(type='int')
+        assert result == 1
+
+    def test__function_cardinality_rule_match(self):
+        """Test it when 'cardinality_rule' is 'match'."""
+        # setup
+        instance = Mock()
+        function = Mock()
+        unique_function = Mock()
+        unique_function.return_value = 1
+
+        instance.cardinality_rule = 'match'
         instance.faker.unique.number = unique_function
         instance.faker.number = function
         instance.function_name = 'number'
@@ -160,7 +180,7 @@ class TestAnonymizedFaker:
         """Test that ``_function`` returns the values of the iterable."""
         # setup
         instance = Mock()
-        instance.enforce_uniqueness = False
+        instance.cardinality_rule = None
         function = Mock()
         function.return_value = ('value_1', 'value_2')
 
@@ -291,7 +311,7 @@ class TestAnonymizedFaker:
         assert instance.function_kwargs == {}
         assert instance.locales is None
         mock_faker.Faker.assert_called_once_with(None)
-        assert instance.enforce_uniqueness is False
+        assert instance.cardinality_rule is None
         assert instance.missing_value_generation == 'random'
 
     def test___init__error_missing_value_generation(self):
@@ -312,7 +332,8 @@ class TestAnonymizedFaker:
 
     @patch('rdt.transformers.pii.anonymizer.faker')
     @patch('rdt.transformers.pii.anonymizer.AnonymizedFaker.check_provider_function')
-    def test___init__custom(self, mock_check_provider_function, mock_faker):
+    @patch('rdt.transformers.pii.anonymizer.warnings')
+    def test___init__custom(self, mock_warnings, mock_check_provider_function, mock_faker):
         """Test the instantiation of the transformer with custom parameters.
 
         Test that the transformer can be instantiated with a custom provider and function, and
@@ -352,7 +373,14 @@ class TestAnonymizedFaker:
         assert instance.function_kwargs == {'type': 'visa'}
         assert instance.locales == ['en_US', 'fr_FR']
         mock_faker.Faker.assert_called_once_with(['en_US', 'fr_FR'])
-        assert instance.enforce_uniqueness
+        assert instance.cardinality_rule == 'unique'
+        mock_warnings.warn.assert_has_calls([
+            call(
+                "The 'enforce_uniqueness' parameter is no longer supported. "
+                "Please use the 'cardinality_rule' parameter instead.",
+                FutureWarning
+            )
+        ])
 
     def test___init__no_function_name(self):
         """Test the instantiation of the transformer with custom parameters.
@@ -437,6 +465,7 @@ class TestAnonymizedFaker:
         """
         # Setup
         transformer = AnonymizedFaker()
+        transformer.cardinality_rule = 'match'
         columns_data = pd.Series(['1', '2', '3', None, np.nan])
         transformer.columns = ['col']
 
@@ -447,6 +476,7 @@ class TestAnonymizedFaker:
         assert transformer.data_length == 5
         assert transformer.output_properties == {None: {'next_transformer': None}}
         assert transformer._nan_frequency == 0.4
+        assert transformer._data_cardinality == 3
 
     def test__transform(self):
         """Test the ``_transform`` method.
@@ -503,6 +533,67 @@ class TestAnonymizedFaker:
         assert function.call_args_list == [call(), call(), call()]
         np.testing.assert_array_equal(result, np.array(['a', 'b', 'c']))
 
+    def test__reverse_transform_match_cardinality(self):
+        """Test it calls the appropriate method."""
+        # Setup
+        instance = Mock()
+        instance.data_length = 3
+        instance.cardinality_rule = 'match'
+
+        # Run
+        AnonymizedFaker._reverse_transform(instance, None)
+
+        # Assert
+        instance._reverse_transform_cardinality_rule_match.assert_called_once_with(3)
+
+    def test__reverse_transform_cardinality_rule_match_only_nans(self):
+        """Test it with only nans."""
+        # Setup
+        instance = AnonymizedFaker()
+        instance._nan_frequency = 1
+
+        # Run
+        result = instance._reverse_transform_cardinality_rule_match(3)
+
+        # Assert
+        assert pd.isna(result).all()
+
+    def test__reverse_transform_cardinality_rule_match_no_missing_value(self):
+        """Test it with default values."""
+        # Setup
+        instance = AnonymizedFaker(missing_value_generation=None)
+        instance._data_cardinality = 2
+        instance._nan_frequency = 0
+        function = Mock()
+        function.side_effect = ['a', 'b', 'c']
+
+        instance._function = function
+
+        # Run
+        result = instance._reverse_transform_cardinality_rule_match(3)
+
+        # Assert
+        assert function.call_args_list == [call(), call()]
+        assert set(result).issubset({'a', 'b'})
+
+    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._nan_frequency = 0
+        function = Mock()
+        function.side_effect = ['a', 'b', 'c', 'd']
+
+        instance._function = function
+
+        # Run
+        result = instance._reverse_transform_cardinality_rule_match(3)
+
+        # Assert
+        assert function.call_args_list == [call(), call(), call()]
+        assert set(result) == {'a', 'b', 'c'}
+
     def test__reverse_transform_with_nans(self):
         """Test that ``_reverse_transform`` generates NaNs."""
         # Setup
@@ -539,7 +630,7 @@ class TestAnonymizedFaker:
             - Raises an error.
         """
         # Setup
-        instance = AnonymizedFaker('misc', 'boolean', enforce_uniqueness=True)
+        instance = AnonymizedFaker('misc', 'boolean', cardinality_rule='unique')
         data = pd.Series(['a', 'b', 'c', 'd'])
         instance.columns = ['a']
 


----

problem_statement: Providing locales to AnonymizedFaker with a function that uses the BaseProvider crashes
### Environment Details

Please indicate the following details about the environment in which you found the bug:

* RDT version: 1.9.2, 1.10.0, 1.10.1 and probably many version before these
* Python version: Any
* Operating System: Any

### Error Description

If the `AnonymizedFaker` is given a function with no provider (eg. bothify or lexify), and it is also given locales, then it crashes during validation with the following error:
```
ModuleNotFoundError                       Traceback (most recent call last)
[<ipython-input-3-620f06e16fc2>](https://localhost:8080/#) in <cell line: 2>()
      1 from rdt.transformers.pii import AnonymizedFaker
----> 2 ht.update_transformers({'review': AnonymizedFaker(provider_name=None, function_name='bothify', locales=['en_US'])})

2 frames
[/usr/lib/python3.10/importlib/util.py](https://localhost:8080/#) in find_spec(name, package)
     92         parent_name = fullname.rpartition('.')[0]
     93         if parent_name:
---> 94             parent = __import__(parent_name, fromlist=['__path__'])
     95             try:
     96                 parent_path = parent.__path__

ModuleNotFoundError: No module named 'faker.providers.BaseProvider'
```

### Steps to reproduce

```python
import pandas as pd
from rdt import HyperTransformer

ht = HyperTransformer()
data = pd.DataFrame({
    'reviewer_name': ['Bill', 'Joe', 'Sarah'],
    'rating': [1, 2, 3],
    'review': ['This was terrible, 'Bad service', 'Meh']
})
ht.detect_initial_config(data)
from rdt.transformers.pii import AnonymizedFaker
ht.update_transformers({'review': AnonymizedFaker(provider_name=None, function_name='bothify', locales=['en_US'])})
```

date: 2024-03-21
patch: diff --git a/rdt/transformers/pii/anonymizer.py b/rdt/transformers/pii/anonymizer.py
index 0e2eced..177bd71 100644
--- a/rdt/transformers/pii/anonymizer.py
+++ b/rdt/transformers/pii/anonymizer.py
@@ -138,7 +138,7 @@ class AnonymizedFaker(BaseTransformer):
         self._faker_random_seed = None
         self.locales = locales
         self.faker = faker.Faker(self.locales)
-        if self.locales:
+        if self.provider_name != 'BaseProvider' and self.locales:
             self._check_locales()
 
         if missing_value_generation not in ['random', None]:

test_patch: diff --git a/tests/integration/transformers/pii/test_anonymizer.py b/tests/integration/transformers/pii/test_anonymizer.py
index c9b5107..66244f6 100644
--- a/tests/integration/transformers/pii/test_anonymizer.py
+++ b/tests/integration/transformers/pii/test_anonymizer.py
@@ -27,6 +27,24 @@ class TestAnonymizedFaker:
         pd.testing.assert_frame_equal(transformed, expected_transformed)
         assert len(reverse_transform['username']) == 5
 
+    def test_default_settings_with_locales(self):
+        """End to end test with the default settings and locales of the ``AnonymizedFaker``."""
+        data = pd.DataFrame({
+            'id': [1, 2, 3, 4, 5],
+            'username': ['a', 'b', 'c', 'd', 'e']
+        })
+
+        instance = AnonymizedFaker(locales=['en_US', 'en_CA', 'es_ES'])
+        transformed = instance.fit_transform(data, 'username')
+
+        reverse_transform = instance.reverse_transform(transformed)
+        expected_transformed = pd.DataFrame({
+            'id': [1, 2, 3, 4, 5]
+        })
+
+        pd.testing.assert_frame_equal(transformed, expected_transformed)
+        assert len(reverse_transform['username']) == 5
+
     def test_get_supported_sdtypes(self):
         """Test that the correct supported sdtypes are returned."""
         # Run