From df149740ee72322e9dc10efe700997c7db92b846 Mon Sep 17 00:00:00 2001 From: Frances Hartwell Date: Tue, 21 Jul 2026 10:52:20 -0400 Subject: [PATCH 1/4] Have HMA error if >5 tables or depth >2 --- sdv/_utils.py | 11 +++++++ sdv/multi_table/hma.py | 75 +++++++++++++++++++++--------------------- 2 files changed, 49 insertions(+), 37 deletions(-) diff --git a/sdv/_utils.py b/sdv/_utils.py index f6eb49b93..fa111dd1b 100644 --- a/sdv/_utils.py +++ b/sdv/_utils.py @@ -417,6 +417,17 @@ def _get_root_tables(relationships): return parent_tables - child_tables +def _get_max_child_depth(child_map, table): + """Return the max child depth for the given table.""" + max_depth = 1 + for child in child_map[table]: + child_depth = 1 + _get_max_child_depth(child_map, child) + if child_depth > max_depth: + max_depth = child_depth + + return max_depth + + def generate_synthesizer_id(synthesizer): """Generate a unique identifier for the synthesizer instance. diff --git a/sdv/multi_table/hma.py b/sdv/multi_table/hma.py index c224890c0..eb1fcb695 100644 --- a/sdv/multi_table/hma.py +++ b/sdv/multi_table/hma.py @@ -10,7 +10,7 @@ from rdt.transformers import FloatFormatter from tqdm import tqdm -from sdv._utils import _get_root_tables +from sdv._utils import _get_max_child_depth, _get_root_tables from sdv.errors import SynthesizerInputError from sdv.multi_table.base import BaseMultiTableSynthesizer from sdv.sampling import BaseHierarchicalSampler @@ -184,6 +184,29 @@ def _estimate_num_columns(cls, metadata, distributions=None): table_name: sum(columns_list) for table_name, columns_list in columns_per_table.items() } + @staticmethod + def _get_max_schema_depth(metadata): + """Calculate the maximum depth of the schema. + + This method traverses all relationships and returns the length of the longest relationship + chain between tables. + + Args: + metadata (sdv.Metadata): + Metadata representing the data tables this synthesizer will be used for. + + Returns: + int: + The maximum depth of the schema. + """ + max_depth = 1 + child_map = metadata._get_child_map() + for root_table in _get_root_tables(metadata.relationships): + root_depth = _get_max_child_depth(child_map, root_table) + max_depth = root_depth if root_depth > max_depth else max_depth + + return max_depth + def __init__(self, metadata, locales=['en_US'], verbose=True): BaseMultiTableSynthesizer.__init__(self, metadata, locales=locales) self._table_sizes = {} @@ -203,7 +226,7 @@ def __init__(self, metadata, locales=['en_US'], verbose=True): child_tables.add(relationship['child_table_name']) for child_table_name in child_tables: self.set_table_parameters(child_table_name, {'default_distribution': 'norm'}) - self._print_estimate_warning() + self._validate_schema_complexity() def set_table_parameters(self, table_name, table_parameters): """Update the table's synthesizer instantiation parameters. @@ -260,42 +283,20 @@ def _get_distributions(self): return distributions - def _print_estimate_warning(self): - total_est_cols = 0 - metadata_columns = self._get_num_data_columns(self.metadata) - print_table = [] - distributions = self._get_distributions() - estimated_columns = self._estimate_num_columns(self.metadata, distributions) - for table, est_cols in estimated_columns.items(): - entry = [] - entry.append(table) - entry.append(sum(metadata_columns[table])) - total_est_cols += est_cols - entry.append(min(est_cols, PERFORMANCE_ALERT_DISPLAY_CAP)) - print_table.append(entry) - - if total_est_cols > MAX_NUMBER_OF_COLUMNS: - display_total = ( - f'{PERFORMANCE_ALERT_DISPLAY_CAP}+' - if total_est_cols > PERFORMANCE_ALERT_DISPLAY_CAP - else f'{total_est_cols}' - ) - self._print( - 'PerformanceAlert: Using the HMASynthesizer on this metadata ' - 'schema is not recommended. To model this data, HMA will ' - f'generate a large number of columns. ({display_total} columns)\n\n' - ) - self._print( - pd.DataFrame( - print_table, columns=['Table Name', '# Columns in Metadata', 'Est # Columns'] - ).to_string(index=False) - + '\n' - ) - self._print( - 'We recommend simplifying your metadata schema using ' - "'sdv.utils.poc.simplify_schema'.\nIf this is not possible, please visit " - 'datacebo.com and reach out to us for enterprise solutions.\n' + def _validate_schema_complexity(self): + num_tables = len(self.metadata.tables) + schema_depth = self._get_max_schema_depth(self.metadata) + + if num_tables > 5 or schema_depth > 2: + error_msg = ( + 'HMASynthesizer is not designed to handle a schema with more than 5 tables or ' + 'relationship depth greater than 2.\n' + 'Please use SDV Enterprise to model this schema.\n\n' + 'SDV Enterprise provides access to synthesizers that can easily scale with the ' + 'amount of data and complexity of your schema.\n\n' + 'For more information, visit datacebo.com' ) + raise SynthesizerInputError(error_msg) def preprocess(self, data): """Transform the raw data to numerical space. From d684a62677ed7ac9283d0793d7efd540b20e7a74 Mon Sep 17 00:00:00 2001 From: Frances Hartwell Date: Tue, 21 Jul 2026 10:52:25 -0400 Subject: [PATCH 2/4] Fix tests --- tests/integration/multi_table/conftest.py | 6 - tests/integration/multi_table/test_hma.py | 367 +++------------------- tests/integration/utils/test_poc.py | 18 +- tests/unit/multi_table/test_base.py | 9 +- tests/unit/multi_table/test_hma.py | 114 +++---- tests/utils.py | 47 +++ 6 files changed, 148 insertions(+), 413 deletions(-) diff --git a/tests/integration/multi_table/conftest.py b/tests/integration/multi_table/conftest.py index 45e883ec3..b4618bca1 100644 --- a/tests/integration/multi_table/conftest.py +++ b/tests/integration/multi_table/conftest.py @@ -43,12 +43,6 @@ def data_metadata_1_to_1(fake_hotels): } metadata_dict['relationships'] = [ - { - 'parent_table_name': 'hotels', - 'parent_primary_key': 'hotel_id', - 'child_table_name': 'guests', - 'child_foreign_key': 'hotel_id', - }, { 'parent_table_name': 'guests', 'parent_primary_key': 'guest_email', diff --git a/tests/integration/multi_table/test_hma.py b/tests/integration/multi_table/test_hma.py index b22ca9352..ff86e76c4 100644 --- a/tests/integration/multi_table/test_hma.py +++ b/tests/integration/multi_table/test_hma.py @@ -27,7 +27,7 @@ from sdv.multi_table import HMASynthesizer from sdv.utils import load_constraints from tests.integration.single_table.custom_constraints import MyConstraint -from tests.utils import catch_sdv_logs +from tests.utils import catch_sdv_logs, get_multi_table_metadata class TestHMASynthesizer: @@ -655,162 +655,22 @@ def test_progress_bar_print(self, capsys): match = re.search(constraint, captured.out + captured.err) assert match is not None - def test_warning_message_too_many_cols(self, capsys): - """Test that a warning appears if there are more than 1000 expected columns""" + def test_error_complex_schema(self): + """Test that an error occurs if the schema is too complex.""" # Setup - parent_columns = {'parent_id': {'sdtype': 'id'}, 'parent_data': {'sdtype': 'categorical'}} - child_columns = {'child_id': {'sdtype': 'id'}, 'parent_id': {'sdtype': 'id'}} - for i in range(999): - child_columns[f'col_{i}'] = {'sdtype': 'categorical'} - - large_metadata = Metadata.load_from_dict({ - 'tables': { - 'parent': {'columns': parent_columns, 'primary_key': 'parent_id'}, - 'child': {'columns': child_columns, 'primary_key': 'child_id'}, - }, - 'relationships': [ - { - 'parent_table_name': 'parent', - 'parent_primary_key': 'parent_id', - 'child_table_name': 'child', - 'child_foreign_key': 'parent_id', - } - ], - }) - - key_phrases = [ - r'PerformanceAlert:', - r'large number of columns.', - r'please visit datacebo.com and reach out to us for enterprise solutions.', - ] - - # Run - HMASynthesizer(large_metadata) - - captured = capsys.readouterr() - - # Assert - for constraint in key_phrases: - match = re.search(constraint, captured.out + captured.err) - assert match is not None - - # Setup small metadata that shouldn't trigger warning - small_metadata = Metadata.load_from_dict({ - 'tables': { - 'parent': { - 'columns': { - 'parent_id': {'sdtype': 'id'}, - 'parent_data': {'sdtype': 'categorical'}, - }, - 'primary_key': 'parent_id', - }, - 'child': { - 'columns': { - 'child_id': {'sdtype': 'id'}, - 'parent_id': {'sdtype': 'id'}, - 'child_data': {'sdtype': 'categorical'}, - }, - 'primary_key': 'child_id', - }, - }, - 'relationships': [ - { - 'parent_table_name': 'parent', - 'parent_primary_key': 'parent_id', - 'child_table_name': 'child', - 'child_foreign_key': 'parent_id', - } - ], - }) + metadata = get_multi_table_metadata() # Run - HMASynthesizer(small_metadata) - - captured = capsys.readouterr() - - # Assert that small amount of columns don't trigger the message - for constraint in key_phrases: - match = re.search(constraint, captured.out + captured.err) - assert match is None - - def test_hma_three_linear_nodes(self): - """Test it works on a simple 'grandparent-parent-child' dataset.""" - # Setup - grandparent = pd.DataFrame( - data={'grandparent_ID': [0, 1, 2, 3, 4], 'data': ['0', '1', '2', '3', '4']} - ) - parent = pd.DataFrame( - data={ - 'parent_ID': ['a', 'b', 'c', 'd', 'e'], - 'grandparent_ID': [0, 0, 1, 1, 3], - 'data': [True, False, False, False, True], - } + expected_msg = re.escape( + 'HMASynthesizer is not designed to handle a schema with more than 5 tables or ' + 'relationship depth greater than 2.\n' + 'Please use SDV Enterprise to model this schema.\n\n' + 'SDV Enterprise provides access to synthesizers that can easily scale with the ' + 'amount of data and complexity of your schema.\n\n' + 'For more information, visit datacebo.com' ) - child = pd.DataFrame( - data={ - 'child_ID': ['00', '01', '02', '03', '04'], - 'parent_ID': ['b', 'b', 'a', 'e', 'e'], - 'data': ['Yes', 'Yes', 'Maybe', 'No', 'No'], - } - ) - data = {'grandparent': grandparent, 'parent': parent, 'child': child} - metadata = Metadata.load_from_dict({ - 'tables': { - 'grandparent': { - 'primary_key': 'grandparent_ID', - 'columns': { - 'grandparent_ID': {'sdtype': 'id'}, - 'data': {'sdtype': 'categorical'}, - }, - }, - 'parent': { - 'primary_key': 'parent_ID', - 'columns': { - 'parent_ID': {'sdtype': 'id'}, - 'grandparent_ID': {'sdtype': 'id'}, - 'data': {'sdtype': 'categorical'}, - }, - }, - 'child': { - 'primary_key': 'child_ID', - 'columns': { - 'child_ID': {'sdtype': 'id'}, - 'parent_ID': {'sdtype': 'id'}, - 'data': {'sdtype': 'categorical'}, - }, - }, - }, - 'relationships': [ - { - 'parent_table_name': 'grandparent', - 'parent_primary_key': 'grandparent_ID', - 'child_table_name': 'parent', - 'child_foreign_key': 'grandparent_ID', - }, - { - 'parent_table_name': 'parent', - 'parent_primary_key': 'parent_ID', - 'child_table_name': 'child', - 'child_foreign_key': 'parent_ID', - }, - ], - }) - synthesizer = HMASynthesizer(metadata) - - # Run - synthesizer.fit(data) - samples = synthesizer.sample(scale=1) - - # Assert tables are the same - assert set(samples) == set(data) - - # Assert columns are the same - for table_name, table in samples.items(): - assert set(table.columns) == set(data[table_name].columns) - - # Assert data values all exist in the original tables - for table_name, table in samples.items(): - assert table['data'].isin(data[table_name]['data']).all() + with pytest.raises(SynthesizerInputError, match=expected_msg): + HMASynthesizer(metadata) def test_hma_one_parent_two_children(self): """Test it works on a simple 'child-parent-child' dataset.""" @@ -958,132 +818,6 @@ def test_hma_two_parents_one_child(self): for table_name, table in samples.items(): assert table['data'].isin(data[table_name]['data']).all() - def test_hma_two_lineages_one_grandchild(self): - """Test it works on a dataset where one grandchild comes from two lineages. - - Dataset has the shape: - r1 r2 - \\ // - c1 c2 - \\// - gc - """ - # Setup - root1 = pd.DataFrame( - data={'id': [0, 1, 2, 3, 4], 'data': [True, False, False, False, True]} - ) - root2 = pd.DataFrame( - data={'id': [0, 1, 2, 3, 4], 'data': [True, False, False, False, True]} - ) - child1 = pd.DataFrame( - data={ - 'child_ID': ['a', 'b', 'c', 'd', 'e'], - 'root1_ID': [0, 1, 2, 3, 3], - 'data': [True, False, False, False, True], - } - ) - child2 = pd.DataFrame( - data={ - 'child_ID': ['a', 'b', 'c', 'd', 'e'], - 'root2_ID': [0, 1, 2, 3, 4], - 'data': [True, False, False, False, True], - } - ) - grandchild = pd.DataFrame( - data={ - 'grandchild_ID': ['a', 'b', 'c', 'd', 'e'], - 'child1_ID': ['a', 'b', 'c', 'd', 'e'], - 'child2_ID': ['a', 'b', 'c', 'd', 'e'], - 'data': [True, False, False, False, True], - } - ) - data = { - 'root1': root1, - 'root2': root2, - 'child1': child1, - 'child2': child2, - 'grandchild': grandchild, - } - metadata = Metadata.load_from_dict({ - 'tables': { - 'root1': { - 'primary_key': 'id', - 'columns': {'id': {'sdtype': 'id'}, 'data': {'sdtype': 'categorical'}}, - }, - 'root2': { - 'primary_key': 'id', - 'columns': {'id': {'sdtype': 'id'}, 'data': {'sdtype': 'categorical'}}, - }, - 'child1': { - 'primary_key': 'child_ID', - 'columns': { - 'child_ID': {'sdtype': 'id'}, - 'root1_ID': {'sdtype': 'id'}, - 'data': {'sdtype': 'categorical'}, - }, - }, - 'child2': { - 'primary_key': 'child_ID', - 'columns': { - 'child_ID': {'sdtype': 'id'}, - 'root2_ID': {'sdtype': 'id'}, - 'data': {'sdtype': 'categorical'}, - }, - }, - 'grandchild': { - 'primary_key': 'grandchild_ID', - 'columns': { - 'grandchild_ID': {'sdtype': 'id'}, - 'child1_ID': {'sdtype': 'id'}, - 'child2_ID': {'sdtype': 'id'}, - 'data': {'sdtype': 'categorical'}, - }, - }, - }, - 'relationships': [ - { - 'parent_table_name': 'root1', - 'parent_primary_key': 'id', - 'child_table_name': 'child1', - 'child_foreign_key': 'root1_ID', - }, - { - 'parent_table_name': 'root2', - 'parent_primary_key': 'id', - 'child_table_name': 'child2', - 'child_foreign_key': 'root2_ID', - }, - { - 'parent_table_name': 'child1', - 'parent_primary_key': 'child_ID', - 'child_table_name': 'grandchild', - 'child_foreign_key': 'child1_ID', - }, - { - 'parent_table_name': 'child2', - 'parent_primary_key': 'child_ID', - 'child_table_name': 'grandchild', - 'child_foreign_key': 'child2_ID', - }, - ], - }) - synthesizer = HMASynthesizer(metadata) - - # Run - synthesizer.fit(data) - samples = synthesizer.sample(scale=1) - - # Assert tables are the same - assert set(samples) == set(data) - - # Assert columns are the same - for table_name, table in samples.items(): - assert set(table.columns) == set(data[table_name].columns) - - # Assert data values all exist in the original tables - for table_name, table in samples.items(): - assert table['data'].isin(data[table_name]['data']).all() - def test_hma_numerical_distributions(self): """Test it runs when 'numerical_distributions' is set (GH#1605).""" # Setup @@ -1684,6 +1418,7 @@ def test_large_integer_ids_overflow_three_tables(self): 'col_0': [1, 2, 2], }) table_2 = pd.DataFrame({ + 'col_0': [1, 2, 3], 'col_A': [1, 2, 3], 'col_B': ['d', 'e', 'f'], 'col_C': ['g', 'h', 'i'], @@ -1707,6 +1442,7 @@ def test_large_integer_ids_overflow_three_tables(self): }, 'table_2': { 'columns': { + 'col_0': {'sdtype': 'id', 'regex_format': '[1-9]{20}'}, 'col_A': {'sdtype': 'id', 'regex_format': '[1-9]{20}'}, 'col_B': {'sdtype': 'categorical'}, 'col_C': {'sdtype': 'categorical'}, @@ -1722,7 +1458,7 @@ def test_large_integer_ids_overflow_three_tables(self): }, { 'parent_table_name': 'table_0', - 'child_table_name': 'table_1', + 'child_table_name': 'table_2', 'parent_primary_key': 'col_0', 'child_foreign_key': 'col_0', }, @@ -1764,16 +1500,19 @@ def test_large_integer_ids_overflow_three_tables(self): # Check that a warning is raised assert len(captured_warnings) == 2 - assert str(captured_warnings[0].message) == ( - "The real data in 'table_0' and column 'col_0' was stored as 'int64' but the " - 'synthetic data overflowed when casting back to this type. If this is a problem, ' - 'please check your input data and metadata settings.' - ) - assert str(captured_warnings[1].message) == ( - "The real data in 'table_1' and column 'col_1' was stored as 'int64' but the " - 'synthetic data overflowed when casting back to this type. If this is a problem, ' - 'please check your input data and metadata settings.' - ) + captured_msgs = {str(captured_warnings[0].message), str(str(captured_warnings[1].message))} + assert captured_msgs == { + ( + "The real data in 'table_1' and column 'col_1' was stored as 'int64' but the " + 'synthetic data overflowed when casting back to this type. If this is a problem, ' + 'please check your input data and metadata settings.' + ), + ( + "The real data in 'table_0' and column 'col_0' was stored as 'int64' but the " + 'synthetic data overflowed when casting back to this type. If this is a problem, ' + 'please check your input data and metadata settings.' + ), + } def test_ids_that_dont_fit_in_int64(self): """Test it when both real and synthetic data don't fit in int64.""" @@ -2040,14 +1779,18 @@ def test_hma_0_1_grandparent(): metadata = Metadata().load_from_dict(metadata_dict) metadata.validate() metadata.validate_data(data) - synthesizer = HMASynthesizer(metadata=metadata, verbose=False) - synthesizer.fit(data) - synthetic_data = synthesizer.sample() - child_df = synthetic_data['child'] - data_col_max = child_df['data'].max() - data_col_min = child_df['data'].min() - assert child_df[child_df['data'] == data_col_max].shape[0] == 3 - assert child_df[child_df['data'] == data_col_min].shape[0] == 3 + + # Run and Assert + expected_msg = re.escape( + 'HMASynthesizer is not designed to handle a schema with more than 5 tables or ' + 'relationship depth greater than 2.\n' + 'Please use SDV Enterprise to model this schema.\n\n' + 'SDV Enterprise provides access to synthesizers that can easily scale with the ' + 'amount of data and complexity of your schema.\n\n' + 'For more information, visit datacebo.com' + ) + with pytest.raises(SynthesizerInputError, match=expected_msg): + HMASynthesizer(metadata=metadata, verbose=False) def test_parent_default_distribution_non_beta(): @@ -2059,7 +1802,6 @@ def test_parent_default_distribution_non_beta(): 'grandparent_id': range(10), 'categories': list(np.random.choice(['T', 'F'], size=10)), }), - 'grandparent': pd.DataFrame({'id': range(10)}), } metadata = Metadata.load_from_dict({ 'tables': { @@ -2075,7 +1817,6 @@ def test_parent_default_distribution_non_beta(): 'categories': {'sdtype': 'categorical'}, }, }, - 'grandparent': {'primary_key': 'id', 'columns': {'id': {'sdtype': 'id'}}}, }, 'relationships': [ { @@ -2084,12 +1825,6 @@ def test_parent_default_distribution_non_beta(): 'parent_primary_key': 'id', 'child_foreign_key': 'parent_id', }, - { - 'parent_table_name': 'grandparent', - 'child_table_name': 'parent', - 'parent_primary_key': 'id', - 'child_foreign_key': 'grandparent_id', - }, ], 'METADATA_SPEC_VERSION': 'V1', }) @@ -2738,6 +2473,7 @@ def test__estimate_num_columns_to_be_modeled_various_sdtypes(): }, ], }) + HMASynthesizer._validate_schema_complexity = Mock() synthesizer = HMASynthesizer(metadata) synthesizer._finalize = Mock(return_value=data) distributions = synthesizer._get_distributions() @@ -3018,25 +2754,6 @@ def test_1_to_1_or_0_not_superset(self, data_metadata_1_to_1_or_0): with pytest.raises(InvalidDataError, match=match_): synthesizer.fit(data) - def test_1_to_1_to_1_subset_to_subset(self, data_metadata_1_to_1_to_1_subset_to_subset): - """Test PK to PK to PK, with the 2nd and 3rd table having a subset.""" - # Setup - data, metadata = data_metadata_1_to_1_to_1_subset_to_subset - - # Run - synthesizer = HMASynthesizer(metadata=metadata, verbose=False) - synthesizer.fit(data) - synthetic_data = synthesizer.sample(scale=1.0) - - # Assert - assert set(synthetic_data['guests']['guest_email']).issuperset( - set(synthetic_data['guests_pii']['guest_email']) - ) - assert set(synthetic_data['guests_pii']['guest_email']).issuperset( - set(synthetic_data['rooms']['guest_email']) - ) - synthesizer.validate(synthetic_data) - def test_1_to_1_to_1_arrow(self, data_metadata_1_to_1_subset_arrow): """Test PK to PK to PK in an arrow relationship.""" # Setup diff --git a/tests/integration/utils/test_poc.py b/tests/integration/utils/test_poc.py index 9b63ee9ca..cf90cabd0 100644 --- a/tests/integration/utils/test_poc.py +++ b/tests/integration/utils/test_poc.py @@ -8,7 +8,7 @@ from sdv.datasets.demo import download_demo from sdv.metadata.metadata import Metadata -from sdv.multi_table.hma import MAX_NUMBER_OF_COLUMNS, HMASynthesizer +from sdv.multi_table.hma import MAX_NUMBER_OF_COLUMNS from sdv.multi_table.utils import _get_total_estimated_columns from sdv.utils.poc import get_random_subset, simplify_schema @@ -142,27 +142,12 @@ def test_simplify_schema(capsys): # Setup data, metadata = download_demo('multi_table', 'AustralianFootball') num_estimated_column_before_simplification = _get_total_estimated_columns(metadata) - HMASynthesizer(metadata) - captured_before_simplification = capsys.readouterr() # Run data_simplify, metadata_simplify = simplify_schema(data, metadata) captured_after_simplification = capsys.readouterr() # Assert - expected_message_before = re.compile( - r'PerformanceAlert: Using the HMASynthesizer on this metadata schema is not recommended\.' - r' To model this data, HMA will generate a large number of columns\. \(135934 columns\)\s+' - r'Table Name\s*#\s*Columns in Metadata\s*Est # Columns\s*' - r'match_stats\s*24\s*24\s*' - r'matches\s*39\s*364\s*' - r'players\s*5\s*330\s*' - r'teams\s*1\s*135216\s*' - r'We recommend simplifying your metadata schema using ' - r"'sdv.utils.poc.simplify_schema'\.\s*" - r'If this is not possible, please visit ' - r'datacebo.com and reach out to us for enterprise solutions\.' - ) expected_message_after = re.compile( r'Success! The schema has been simplified\.\s+' r'Table Name\s*#\s*Columns \(Before\)\s*#\s*Columns \(After\)\s*' @@ -171,7 +156,6 @@ def test_simplify_schema(capsys): r'players\s*6\s*0\s*' r'teams\s*2\s*2' ) - assert expected_message_before.match(captured_before_simplification.out.strip()) assert expected_message_after.match(captured_after_simplification.out.strip()) metadata_simplify.validate() metadata_simplify.validate_data(data_simplify) diff --git a/tests/unit/multi_table/test_base.py b/tests/unit/multi_table/test_base.py index e2f8e68ff..9818d5f62 100644 --- a/tests/unit/multi_table/test_base.py +++ b/tests/unit/multi_table/test_base.py @@ -29,7 +29,12 @@ from sdv.multi_table.hma import HMASynthesizer from sdv.single_table.copulas import GaussianCopulaSynthesizer from sdv.single_table.ctgan import CTGANSynthesizer -from tests.utils import catch_sdv_logs, get_multi_table_data, get_multi_table_metadata +from tests.utils import ( + catch_sdv_logs, + get_multi_table_data, + get_multi_table_metadata, + get_simplified_multi_table_metadata, +) class TestBaseMultiTableSynthesizer: @@ -768,7 +773,7 @@ def test_auto_assign_transformers_missing_table(self): def test_auto_assign_transformers_missing_column(self): """Test errors when there is a missing column within a table""" # Setup - metadata = get_multi_table_metadata() + metadata = get_simplified_multi_table_metadata() synthesizer = HMASynthesizer(metadata) table1 = pd.DataFrame({'col1': [1, 2]}) table2 = pd.DataFrame({'col2': [1, 2]}) diff --git a/tests/unit/multi_table/test_hma.py b/tests/unit/multi_table/test_hma.py index f8ae9b48a..43ab8af97 100644 --- a/tests/unit/multi_table/test_hma.py +++ b/tests/unit/multi_table/test_hma.py @@ -11,14 +11,18 @@ HMASynthesizer, ) from sdv.single_table.copulas import GaussianCopulaSynthesizer -from tests.utils import get_multi_table_data, get_multi_table_metadata +from tests.utils import ( + get_multi_table_data, + get_multi_table_metadata, + get_simplified_multi_table_metadata, +) class TestHMASynthesizer: def test___init__(self): """Test the default initialization of the ``HMASynthesizer``.""" # Run - metadata = get_multi_table_metadata() + metadata = get_simplified_multi_table_metadata() metadata.validate = Mock() instance = HMASynthesizer(metadata) @@ -28,7 +32,7 @@ def test___init__(self): assert isinstance(instance._table_synthesizers['oseba'], GaussianCopulaSynthesizer) assert isinstance(instance._table_synthesizers['upravna_enota'], GaussianCopulaSynthesizer) assert instance._table_parameters == { - 'nesreca': {'default_distribution': 'norm'}, + 'nesreca': {'default_distribution': 'beta'}, 'oseba': {'default_distribution': 'norm'}, 'upravna_enota': {'default_distribution': 'beta'}, } @@ -41,7 +45,7 @@ def test_set_table_parameters_errors_gaussian_kde(self): numerical_distribution_parameters = { 'numerical_distributions': {'id_nesreca': 'gaussian_kde'} } - metadata = get_multi_table_metadata() + metadata = get_simplified_multi_table_metadata() instance = HMASynthesizer(metadata) # Run and Assert @@ -63,7 +67,7 @@ def test__get_extension(self): and parameters from a trained ``copulas.univariate`` model. """ # Setup - metadata = get_multi_table_metadata() + metadata = get_simplified_multi_table_metadata() child_table = pd.DataFrame({'id_nesreca': [0, 1, 2, 3], 'upravna_enota': [0, 1, 2, 3]}) instance = HMASynthesizer(metadata) @@ -72,6 +76,8 @@ def test__get_extension(self): # Assert expected = pd.DataFrame({ + '__nesreca__upravna_enota__univariates__id_nesreca__a': [1.0] * 4, + '__nesreca__upravna_enota__univariates__id_nesreca__b': [1.0] * 4, '__nesreca__upravna_enota__univariates__id_nesreca__loc': [0.0, 1.0, 2.0, 3.0], '__nesreca__upravna_enota__univariates__id_nesreca__scale': [np.nan] * 4, '__nesreca__upravna_enota__num_rows': [1.0, 1.0, 1.0, 1.0], @@ -82,7 +88,7 @@ def test__get_extension(self): def test__get_distributions(self): """Test the ``_get_distributions`` method.""" # Setup - metadata = get_multi_table_metadata() + metadata = get_simplified_multi_table_metadata() instance = HMASynthesizer(metadata) instance.get_table_parameters = Mock() instance.get_table_parameters.side_effect = [ @@ -98,62 +104,41 @@ def test__get_distributions(self): expected = {'nesreca': 'gamma', 'oseba': None, 'upravna_enota': None} assert result == expected - @patch('sdv.multi_table.hma.HMASynthesizer._estimate_num_columns') - @patch('sdv.multi_table.hma.HMASynthesizer._get_distributions') - def test__print_estimate_warning(self, get_distributions_mock, estimate_mock, capsys): - """Test that a warning appears if there are more than 1000 expected columns""" + def test__complex_schema_too_many_tables_error(self): + """Test that an error occurs if there are more than 5 tables.""" # Setup - metadata = get_multi_table_metadata() - estimate_mock.side_effect = [{'nesreca': 2000}, {'nesreca': 10}] - - key_phrases = [ - r'PerformanceAlert:', - r'large number of columns.', - r'please visit datacebo.com and reach out to us for enterprise solutions.', - ] - - # Run - HMASynthesizer(metadata) - captured = capsys.readouterr() + metadata = Metadata.load_from_dict({ + 'tables': {f'table{i}': {'columns': {'col': {'sdtype': 'id'}}} for i in range(6)} + }) + expected_error = re.escape( + 'HMASynthesizer is not designed to handle a schema with more than 5 tables or ' + 'relationship depth greater than 2.\n' + 'Please use SDV Enterprise to model this schema.\n\n' + 'SDV Enterprise provides access to synthesizers that can easily scale with the ' + 'amount of data and complexity of your schema.\n\n' + 'For more information, visit datacebo.com' + ) - # Assert - get_distributions_mock.assert_called_once() - for constraint in key_phrases: - match = re.search(constraint, captured.out + captured.err) - assert match is not None + # Run and Assert + with pytest.raises(SynthesizerInputError, match=expected_error): + HMASynthesizer(metadata) - # Run - HMASynthesizer(metadata) - captured = capsys.readouterr() - - # Assert that small amount of columns don't trigger the message - for constraint in key_phrases: - match = re.search(constraint, captured.out + captured.err) - assert match is None - - @patch('sdv.multi_table.hma.HMASynthesizer._estimate_num_columns') - @patch('sdv.multi_table.hma.HMASynthesizer._get_distributions') - def test__print_estimate_warning_many_cols(self, get_distributions_mock, estimate_mock, capsys): - """Test that a warning appears if there are more than 1_000_000 expected columns""" + def test__complex_schema_depth_error(self): + """Test that an error occurs if the depth is greater than 2.""" # Setup metadata = get_multi_table_metadata() - estimate_mock.side_effect = [{'nesreca': 1_000_010}, {'nesreca': 10}] - - # Run - HMASynthesizer(metadata) - captured = capsys.readouterr() - - # Assert - expected_output = ( - 'PerformanceAlert: Using the HMASynthesizer on this metadata schema is not recommended.' - ' To model this data, HMA will generate a large number of columns. (1000000+ columns)\n' - '\n\nTable Name # Columns in Metadata Est # Columns\n' - ' nesreca 1 1000000\n\n' - "We recommend simplifying your metadata schema using 'sdv.utils.poc.simplify_schema'." - '\nIf this is not possible, please visit datacebo.com and reach out to us for ' - 'enterprise solutions.\n\n' + expected_error = re.escape( + 'HMASynthesizer is not designed to handle a schema with more than 5 tables or ' + 'relationship depth greater than 2.\n' + 'Please use SDV Enterprise to model this schema.\n\n' + 'SDV Enterprise provides access to synthesizers that can easily scale with the ' + 'amount of data and complexity of your schema.\n\n' + 'For more information, visit datacebo.com' ) - assert captured.out == expected_output + + # Run and Assert + with pytest.raises(SynthesizerInputError, match=expected_error): + HMASynthesizer(metadata) def test__get_extension_foreign_key_only(self): """Test the ``_get_extension`` method. @@ -223,7 +208,7 @@ def test__augment_table(self): This also updates ``self._augmented_tables`` and ``self._max_child_rows``. """ # Setup - metadata = get_multi_table_metadata() + metadata = get_simplified_multi_table_metadata() instance = HMASynthesizer(metadata) metadata.add_column('value', 'nesreca', sdtype='numerical') metadata.add_column('oseba_value', 'oseba', sdtype='numerical') @@ -257,7 +242,7 @@ def test__augment_table(self): assert instance._augmented_tables == ['oseba', 'nesreca'] assert instance._max_child_rows['__oseba__id_nesreca__num_rows'] == 1 mock_get_pbar_args.assert_called_once_with( - desc="(1/3) Tables 'nesreca' and 'oseba' ('id_nesreca')" + desc="(1/2) Tables 'nesreca' and 'oseba' ('id_nesreca')" ) def test__pop_foreign_keys(self): @@ -337,7 +322,7 @@ def test__model_tables(self): instance._get_pbar_args.return_value = {'desc': 'Modeling Tables'} instance._default_parameters = {} - metadata = get_multi_table_metadata() + metadata = get_simplified_multi_table_metadata() instance.metadata = metadata instance._table_sizes = {'upravna_enota': 3} instance._table_synthesizers = { @@ -381,7 +366,7 @@ def test__model_tables(self): def test__augment_tables(self): """Test that ``_fit`` calls ``_model_tables`` only if the table has no parents.""" # Setup - metadata = get_multi_table_metadata() + metadata = get_simplified_multi_table_metadata() instance = HMASynthesizer(metadata) instance._augment_table = Mock() data = get_multi_table_data() @@ -806,7 +791,7 @@ def test_get_learned_distributions(self): distribution and its parameters. """ # Setup - metadata = get_multi_table_metadata() + metadata = get_simplified_multi_table_metadata() instance = HMASynthesizer(metadata) data = { 'nesreca': pd.DataFrame({ @@ -840,7 +825,7 @@ def test_get_learned_distributions(self): def test_get_learned_distributions_raises_an_error(self): """Test that ``get_learned_distributions`` raises an error.""" # Setup - metadata = get_multi_table_metadata() + metadata = get_simplified_multi_table_metadata() metadata.add_column('value', 'nesreca', sdtype='numerical') metadata.add_column('value', 'oseba', sdtype='numerical') metadata.add_column('a_value', 'upravna_enota', sdtype='numerical') @@ -856,7 +841,7 @@ def test_get_learned_distributions_raises_an_error(self): def test_get_parameters(self): """Test that the synthesizer's parameters are being returned.""" # Setup - metadata = get_multi_table_metadata() + metadata = get_simplified_multi_table_metadata() instance = HMASynthesizer(metadata, locales='en_CA') # Run @@ -1129,6 +1114,7 @@ def test__estimate_num_columns_to_be_modeled_different_distributions(self): }, ], }) + HMASynthesizer._validate_schema_complexity = Mock() synthesizer = HMASynthesizer(metadata) synthesizer.set_table_parameters( table_name='child_norm', table_parameters={'default_distribution': 'norm'} @@ -1272,6 +1258,7 @@ def test__estimate_num_columns_to_be_modeled(self): }, ], }) + HMASynthesizer._validate_schema_complexity = Mock() synthesizer = HMASynthesizer(metadata) synthesizer._finalize = Mock(return_value=data) distributions = synthesizer._get_distributions() @@ -1385,6 +1372,7 @@ def test__estimate_num_columns_to_be_modeled_various_sdtypes(self): }, ], }) + HMASynthesizer._validate_schema_complexity = Mock() synthesizer = HMASynthesizer(metadata) synthesizer._finalize = Mock(return_value=data) distributions = synthesizer._get_distributions() diff --git a/tests/utils.py b/tests/utils.py index 84a2898d5..507515a2c 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -103,6 +103,53 @@ def get_multi_table_metadata(): return Metadata.load_from_dict(dict_metadata) +def get_simplified_multi_table_metadata(): + """Return a simplified ``MultiTableMetadata`` object to be used with HMA tests.""" + dict_metadata = { + 'tables': { + 'nesreca': { + 'primary_key': 'id_nesreca', + 'columns': { + 'upravna_enota': {'sdtype': 'id'}, + 'id_nesreca': {'sdtype': 'id'}, + 'nesreca_val': {'sdtype': 'numerical'}, + }, + }, + 'oseba': { + 'columns': { + 'upravna_enota': {'sdtype': 'id'}, + 'id_nesreca': {'sdtype': 'id'}, + 'oseba_val': {'sdtype': 'numerical'}, + } + }, + 'upravna_enota': { + 'primary_key': 'id_upravna_enota', + 'columns': { + 'id_upravna_enota': {'sdtype': 'id'}, + 'upravna_val': {'sdtype': 'numerical'}, + }, + }, + }, + 'relationships': [ + { + 'parent_table_name': 'upravna_enota', + 'parent_primary_key': 'id_upravna_enota', + 'child_table_name': 'oseba', + 'child_foreign_key': 'upravna_enota', + }, + { + 'parent_table_name': 'nesreca', + 'parent_primary_key': 'id_nesreca', + 'child_table_name': 'oseba', + 'child_foreign_key': 'id_nesreca', + }, + ], + 'METADATA_SPEC_VERSION': 'MULTI_TABLE_V1', + } + + return Metadata.load_from_dict(dict_metadata) + + def get_multi_table_data(): """Return a dictionary containing some data for multi table.""" data = { From 3bb01a3576ac0acce6db7b56244512145bd4269b Mon Sep 17 00:00:00 2001 From: Frances Hartwell Date: Wed, 22 Jul 2026 15:29:48 -0400 Subject: [PATCH 3/4] Move _get_max_schema_depth to MultiTableMetadata and update tests for clarity --- sdv/metadata/multi_table.py | 20 +++++++++ sdv/multi_table/hma.py | 27 +----------- tests/integration/multi_table/test_hma.py | 26 +++++++++++- tests/unit/metadata/test_multi_table.py | 50 +++++++++++++++++++++++ 4 files changed, 96 insertions(+), 27 deletions(-) diff --git a/sdv/metadata/multi_table.py b/sdv/metadata/multi_table.py index 4a6d6f103..4e10964b5 100644 --- a/sdv/metadata/multi_table.py +++ b/sdv/metadata/multi_table.py @@ -14,6 +14,8 @@ from sdv._utils import ( _cast_to_iterable, _format_invalid_values_string, + _get_max_child_depth, + _get_root_tables, _get_unreferenced_keys, _load_data_from_csv, ) @@ -289,6 +291,24 @@ def _get_all_keys(self, table_name): foreign_keys = self._get_all_foreign_keys(table_name) return set(foreign_keys).union(self.tables[table_name]._get_primary_and_alternate_keys()) + def _get_max_schema_depth(self): + """Calculate the maximum depth of this schema. + + This method traverses all relationships and returns the length of the longest relationship + chain between tables. + + Returns: + int: + The maximum depth of the schema. + """ + max_depth = 1 + child_map = self._get_child_map() + for root_table in _get_root_tables(self.relationships): + root_depth = _get_max_child_depth(child_map, root_table) + max_depth = root_depth if root_depth > max_depth else max_depth + + return max_depth + def add_relationship( self, parent_table_name, child_table_name, parent_primary_key, child_foreign_key ): diff --git a/sdv/multi_table/hma.py b/sdv/multi_table/hma.py index eb1fcb695..e15d8bca0 100644 --- a/sdv/multi_table/hma.py +++ b/sdv/multi_table/hma.py @@ -10,7 +10,7 @@ from rdt.transformers import FloatFormatter from tqdm import tqdm -from sdv._utils import _get_max_child_depth, _get_root_tables +from sdv._utils import _get_root_tables from sdv.errors import SynthesizerInputError from sdv.multi_table.base import BaseMultiTableSynthesizer from sdv.sampling import BaseHierarchicalSampler @@ -184,29 +184,6 @@ def _estimate_num_columns(cls, metadata, distributions=None): table_name: sum(columns_list) for table_name, columns_list in columns_per_table.items() } - @staticmethod - def _get_max_schema_depth(metadata): - """Calculate the maximum depth of the schema. - - This method traverses all relationships and returns the length of the longest relationship - chain between tables. - - Args: - metadata (sdv.Metadata): - Metadata representing the data tables this synthesizer will be used for. - - Returns: - int: - The maximum depth of the schema. - """ - max_depth = 1 - child_map = metadata._get_child_map() - for root_table in _get_root_tables(metadata.relationships): - root_depth = _get_max_child_depth(child_map, root_table) - max_depth = root_depth if root_depth > max_depth else max_depth - - return max_depth - def __init__(self, metadata, locales=['en_US'], verbose=True): BaseMultiTableSynthesizer.__init__(self, metadata, locales=locales) self._table_sizes = {} @@ -285,7 +262,7 @@ def _get_distributions(self): def _validate_schema_complexity(self): num_tables = len(self.metadata.tables) - schema_depth = self._get_max_schema_depth(self.metadata) + schema_depth = self.metadata._get_max_schema_depth() if num_tables > 5 or schema_depth > 2: error_msg = ( diff --git a/tests/integration/multi_table/test_hma.py b/tests/integration/multi_table/test_hma.py index ff86e76c4..220cb5ed6 100644 --- a/tests/integration/multi_table/test_hma.py +++ b/tests/integration/multi_table/test_hma.py @@ -655,8 +655,8 @@ def test_progress_bar_print(self, capsys): match = re.search(constraint, captured.out + captured.err) assert match is not None - def test_error_complex_schema(self): - """Test that an error occurs if the schema is too complex.""" + def test_error_complex_schema_depth(self): + """Test that an error occurs if the schema is too deep.""" # Setup metadata = get_multi_table_metadata() @@ -672,6 +672,28 @@ def test_error_complex_schema(self): with pytest.raises(SynthesizerInputError, match=expected_msg): HMASynthesizer(metadata) + def test_error_complex_schema_num_tables(self): + """Test that an error occurs if the schema has too many tables.""" + # Setup + metadata = Metadata.load_from_dict({ + 'tables': { + f'table_{i}': {'columns': {'col': {'sdtype': 'id'}}} + for i in range(6) + } + }) + + # Run + expected_msg = re.escape( + 'HMASynthesizer is not designed to handle a schema with more than 5 tables or ' + 'relationship depth greater than 2.\n' + 'Please use SDV Enterprise to model this schema.\n\n' + 'SDV Enterprise provides access to synthesizers that can easily scale with the ' + 'amount of data and complexity of your schema.\n\n' + 'For more information, visit datacebo.com' + ) + with pytest.raises(SynthesizerInputError, match=expected_msg): + HMASynthesizer(metadata) + def test_hma_one_parent_two_children(self): """Test it works on a simple 'child-parent-child' dataset.""" # Setup diff --git a/tests/unit/metadata/test_multi_table.py b/tests/unit/metadata/test_multi_table.py index 3bb8f32e0..212ac6016 100644 --- a/tests/unit/metadata/test_multi_table.py +++ b/tests/unit/metadata/test_multi_table.py @@ -655,6 +655,56 @@ def test__get_all_keys(self): # Assert assert set(result) == {'user_id', 'session_id', 'transaction_id'} + def test__get_max_schema_depth(self): + """Test returning the maximum depth of the schema.""" + # Setup + metadata = Metadata() + metadata.relationships = [ + { + 'parent_table_name': 'root', + 'child_table_name': 'child', + 'parent_primary_key': 'id', + 'child_foreign_key': 'id', + }, + { + 'parent_table_name': 'root', + 'child_table_name': 'child', + 'parent_primary_key': 'id', + 'child_foreign_key': 'id', + }, + { + 'parent_table_name': 'root', + 'child_table_name': 'grandchild', + 'parent_primary_key': 'id', + 'child_foreign_key': 'id', + }, + { + 'parent_table_name': 'child', + 'child_table_name': 'grandchild', + 'parent_primary_key': 'id', + 'child_foreign_key': 'id', + }, + ] + + # Run + max_schema_depth = metadata._get_max_schema_depth() + + # Assert + max_schema_depth == 3 + + def test__get_max_schema_depth_no_children(self): + """Test returning the maximum depth of the schema when no tables have children.""" + # Setup + instance = Mock() + instance._get_child_map.return_value = {} + instance.relationships = [] + + # Run + max_schema_depth = MultiTableMetadata._get_max_schema_depth(instance) + + # Assert + max_schema_depth == 1 + def test_add_relationship(self): """Test the ``add_relationship`` method of ``MultiTableMetadata``. From a90b8a588182b37b8232c9f02eeb48ad7e013611 Mon Sep 17 00:00:00 2001 From: Frances Hartwell Date: Thu, 23 Jul 2026 11:09:51 -0400 Subject: [PATCH 4/4] Fix lint --- tests/integration/multi_table/test_hma.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/tests/integration/multi_table/test_hma.py b/tests/integration/multi_table/test_hma.py index 220cb5ed6..af5b5989e 100644 --- a/tests/integration/multi_table/test_hma.py +++ b/tests/integration/multi_table/test_hma.py @@ -676,10 +676,7 @@ def test_error_complex_schema_num_tables(self): """Test that an error occurs if the schema has too many tables.""" # Setup metadata = Metadata.load_from_dict({ - 'tables': { - f'table_{i}': {'columns': {'col': {'sdtype': 'id'}}} - for i in range(6) - } + 'tables': {f'table_{i}': {'columns': {'col': {'sdtype': 'id'}}} for i in range(6)} }) # Run