From 08bed6d8fde6d5348162ba8e29bf27d3e2927d37 Mon Sep 17 00:00:00 2001 From: amontanez24 Date: Mon, 22 Nov 2021 14:58:52 -0600 Subject: [PATCH 01/12] =?UTF-8?q?Bump=20version:=200.13.0=20=E2=86=92=200.?= =?UTF-8?q?13.1.dev0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- conda/meta.yaml | 2 +- sdv/__init__.py | 2 +- setup.cfg | 2 +- setup.py | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/conda/meta.yaml b/conda/meta.yaml index c341fac48..9dfb4fadb 100644 --- a/conda/meta.yaml +++ b/conda/meta.yaml @@ -1,5 +1,5 @@ {% set name = 'sdv' %} -{% set version = '0.13.0' %} +{% set version = '0.13.1.dev0' %} package: name: "{{ name|lower }}" diff --git a/sdv/__init__.py b/sdv/__init__.py index 05dcc9321..7511a40d6 100644 --- a/sdv/__init__.py +++ b/sdv/__init__.py @@ -6,7 +6,7 @@ __author__ = """MIT Data To AI Lab""" __email__ = 'dailabmit@gmail.com' -__version__ = '0.13.0' +__version__ = '0.13.1.dev0' from sdv import constraints, evaluation, metadata, relational, tabular from sdv.demo import get_available_demos, load_demo diff --git a/setup.cfg b/setup.cfg index 4cca4b6ab..5107478be 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.13.0 +current_version = 0.13.1.dev0 commit = True tag = True parse = (?P\d+)\.(?P\d+)\.(?P\d+)(\.(?P[a-z]+)(?P\d+))? diff --git a/setup.py b/setup.py index d716d1517..2d8938790 100644 --- a/setup.py +++ b/setup.py @@ -110,6 +110,6 @@ test_suite='tests', tests_require=tests_require, url='https://github.com/sdv-dev/SDV', - version='0.13.0', + version='0.13.1.dev0', zip_safe=False, ) From 1a6a703632957b9e39820d5a708de12641bd4ef0 Mon Sep 17 00:00:00 2001 From: Katharine Xiao <2405771+katxiao@users.noreply.github.com> Date: Tue, 23 Nov 2021 12:54:19 -0800 Subject: [PATCH 02/12] Add py39 to github workflows (#649) * Add py39 to github workflows * update setup --- .github/workflows/integration.yml | 2 +- .github/workflows/minimum.yml | 2 +- .github/workflows/readme.yml | 2 +- .github/workflows/tutorials.yml | 2 +- .github/workflows/unit.yml | 2 +- setup.py | 1 + 6 files changed, 6 insertions(+), 5 deletions(-) diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index 5339d5f9d..c3c2e8ac7 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -9,7 +9,7 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - python-version: [3.6, 3.7, 3.8] + python-version: [3.6, 3.7, 3.8, 3.9] os: [ubuntu-latest, macos-10.15, windows-latest] steps: - uses: actions/checkout@v1 diff --git a/.github/workflows/minimum.yml b/.github/workflows/minimum.yml index bd90c4422..cb2f3af55 100644 --- a/.github/workflows/minimum.yml +++ b/.github/workflows/minimum.yml @@ -9,7 +9,7 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - python-version: [3.6, 3.7, 3.8] + python-version: [3.6, 3.7, 3.8, 3.9] os: [ubuntu-latest, macos-10.15, windows-latest] steps: - uses: actions/checkout@v1 diff --git a/.github/workflows/readme.yml b/.github/workflows/readme.yml index bd4d6d868..2fe4b64c5 100644 --- a/.github/workflows/readme.yml +++ b/.github/workflows/readme.yml @@ -9,7 +9,7 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - python-version: [3.6, 3.7, 3.8] + python-version: [3.6, 3.7, 3.8, 3.9] os: [ubuntu-latest, macos-10.15] # skip windows bc rundoc fails steps: - uses: actions/checkout@v1 diff --git a/.github/workflows/tutorials.yml b/.github/workflows/tutorials.yml index 5b67c7eea..9de0e9c7e 100644 --- a/.github/workflows/tutorials.yml +++ b/.github/workflows/tutorials.yml @@ -9,7 +9,7 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - python-version: [3.6, 3.7, 3.8] + python-version: [3.6, 3.7, 3.8, 3.9] os: [ubuntu-latest, macos-latest, windows-latest] steps: - uses: actions/checkout@v1 diff --git a/.github/workflows/unit.yml b/.github/workflows/unit.yml index ef1c619c1..185587314 100644 --- a/.github/workflows/unit.yml +++ b/.github/workflows/unit.yml @@ -9,7 +9,7 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - python-version: [3.6, 3.7, 3.8] + python-version: [3.6, 3.7, 3.8, 3.9] os: [ubuntu-latest, macos-10.15, windows-latest] steps: - uses: actions/checkout@v1 diff --git a/setup.py b/setup.py index d18c39d8b..2bf361fe3 100644 --- a/setup.py +++ b/setup.py @@ -91,6 +91,7 @@ 'Programming Language :: Python :: 3.6', 'Programming Language :: Python :: 3.7', 'Programming Language :: Python :: 3.8', + 'Programming Language :: Python :: 3.9', ], description='Synthetic Data Generation for tabular, relational and time series data.', extras_require={ From 55d86804b6461240aa8d545b0a735f4bfa8e6c4b Mon Sep 17 00:00:00 2001 From: Katharine Xiao <2405771+katxiao@users.noreply.github.com> Date: Wed, 1 Dec 2021 12:38:55 -0800 Subject: [PATCH 03/12] Update docstrings for hma1 methods (#642) * Update docstrings for hma1 alg * address cr --- sdv/relational/hma.py | 138 +++++++++++++++++++++++++++++++++++++++--- 1 file changed, 130 insertions(+), 8 deletions(-) diff --git a/sdv/relational/hma.py b/sdv/relational/hma.py index 6111ef8ea..7b9c74a8a 100644 --- a/sdv/relational/hma.py +++ b/sdv/relational/hma.py @@ -12,7 +12,7 @@ class HMA1(BaseRelationalModel): - """Hierarchical Modeling Alrogirhtm One. + """Hierarchical Modeling Algorithm One. Args: metadata (dict, str or Metadata): @@ -57,17 +57,14 @@ def __init__(self, metadata, root_path=None, model=None, model_kwargs=None): def _get_extension(self, child_name, child_table, foreign_key): """Generate the extension columns for this child table. - Each element of the list is generated for one single children. - That dataframe should have as ``index.name`` the ``foreign_key`` name, and as index - it's values. - + The resulting dataframe will have an index that contains all the foreign key values. The values for a given index are generated by flattening a model fitted with - the related data to that index in the children table. + the child rows with that foreign key value. Args: child_name (str): Name of the child table. - child_table (set[str]): + child_table (pandas.DataFrame): Data for the child table. foreign_key (str): Name of the foreign key field. @@ -115,6 +112,17 @@ def _get_extension(self, child_name, child_table, foreign_key): return pd.DataFrame(extension_rows, index=index) def _load_table(self, tables, table_name): + """Load the specified table. + + Args: + tables (dict or None): + A dictionary mapping table name to table. + table_name (str): + The name of the desired table. + + Returns: + pandas.DataFrame + """ if tables: table = tables[table_name].copy() else: @@ -124,6 +132,23 @@ def _load_table(self, tables, table_name): return table def _extend_table(self, table, tables, table_name): + """Generate the extension columns for this table. + + For each of the table's foreign keys, generate the related extension columns, + and extend the provided table. + + Args: + table (pandas.DataFrame): + The table to extend. + tables (dict): + A dictionary mapping table_name to table data (pandas.DataFrame). + table_name (str): + The name of the table. + + Returns: + pandas.DataFrame: + The extended table. + """ LOGGER.info('Computing extensions for table %s', table_name) for child_name in self.metadata.get_children(table_name): if child_name not in self._models: @@ -142,6 +167,27 @@ def _extend_table(self, table, tables, table_name): return table def _prepare_for_modeling(self, table_data, table_name, primary_key): + """Prepare the given table for modeling. + + In preparation for modeling a given table, do the following: + - drop the primary key if exists + - drop any other columns of type 'id' + - add unknown fields to metadata as numerical fields, + and fill missing values in those fields + + Args: + table_data (pandas.DataFrame): + The data of the desired table. + table_name (str): + The name of the table. + primary_key (str): + The name of the primary key column. + + Returns: + (dict, dict): + A tuple containing the table metadata to use for modeling, and + the values of the id columns. + """ table_meta = self.metadata.get_table_meta(table_name) table_meta['name'] = table_name @@ -325,6 +371,20 @@ def _sample_rows(self, model, table_name, num_rows=None): return sampled def _sample_child_rows(self, table_name, parent_name, parent_row, sampled_data): + """Sample child rows that reference the given parent row. + + The sampled rows will be stored in ``sampled_data`` under the ``table_name`` key. + + Args: + table_name (str): + The name of the table to sample. + parent_name (str): + The name of the parent table. + parent_row (pandas.Series): + The parent row the child rows should reference. + sampled_data (dict): + A map of table name to sampled table data (pandas.DataFrame). + """ foreign_key = self.metadata.get_foreign_keys(parent_name, table_name)[0] parameters = self._extract_parameters(parent_row, table_name, foreign_key) @@ -345,6 +405,18 @@ def _sample_child_rows(self, table_name, parent_name, parent_row, sampled_data): [previous, table_rows]).reset_index(drop=True) def _sample_children(self, table_name, sampled_data, table_rows): + """Recursively sample the child tables of the given table. + + Sampled child data will be stored into `sampled_data`. + + Args: + table_name (str): + The name of the table whose children will be sampled. + sampled_data (dict): + A map of table name to the sampled table data (pandas.DataFrame). + table_rows (pandas.DataFrame): + The sampled rows of the given table. + """ for child_name in self.metadata.get_children(table_name): if child_name not in sampled_data: LOGGER.info('Sampling rows from child table %s', child_name) @@ -356,12 +428,26 @@ def _sample_children(self, table_name, sampled_data, table_rows): @staticmethod def _find_parent_id(likelihoods, num_rows): + """Find the parent id for one row based on the likelihoods of parent id values. + + If likelihoods are invalid, fall back to the num_rows. + + Args: + likelihoods (pandas.Series): + The likelihood of parent id values. + num_rows (pandas.Series): + The number of times each parent id value appears in the data. + + Returns: + int: + The parent id for this row, chosen based on likelihoods. + """ mean = likelihoods.mean() if (likelihoods == 0).all(): # All rows got 0 likelihood, fallback to num_rows likelihoods = num_rows elif pd.isnull(mean) or mean == 0: - # Some rows got singlar matrix error and the rest were 0 + # Some rows got singular matrix error and the rest were 0 # Fallback to num_rows on the singular matrix rows and # keep 0s on the rest. likelihoods = likelihoods.fillna(num_rows) @@ -382,6 +468,22 @@ def _find_parent_id(likelihoods, num_rows): return np.random.choice(likelihoods.index, p=weights) def _get_likelihoods(self, table_rows, parent_rows, table_name, foreign_key): + """Calculate the likelihood of each parent id value appearing in the data. + + Args: + table_rows (pandas.DataFrame): + The rows in the child table. + parent_rows (pandas.DataFrame): + The rows in the parent table. + table_name (str): + The name of the child table. + foreign_key (str): + The foreign key column in the child table. + + Returns: + pandas.DataFrame: + A DataFrame of the likelihood of each parent id. + """ likelihoods = dict() for parent_id, row in parent_rows.iterrows(): parameters = self._extract_parameters(row, table_name, foreign_key) @@ -396,6 +498,26 @@ def _get_likelihoods(self, table_rows, parent_rows, table_name, foreign_key): return pd.DataFrame(likelihoods, index=table_rows.index) def _find_parent_ids(self, table_name, parent_name, foreign_key, sampled_data): + """Find parent ids for the given table and foreign key. + + The parent ids are chosen randomly based on the likelihood of the available + parent ids in the parent table. If the parent table is not sampled, this method + will first sample rows for the parent table. + + Args: + table_name (str): + The name of the table to find parent ids for. + parent_name (str): + The name of the parent table. + foreign_key (str): + The name of the foreign key column in the child table. + sampled_data (dict): + Map of table name to sampled data (pandas.DataFrame). + + Returns: + pandas.Series: + The parent ids for the given table data. + """ table_rows = sampled_data[table_name] if parent_name in sampled_data: parent_rows = sampled_data[parent_name] From 3af5fe73a181714fa1a8691b2df2e17627fb5e49 Mon Sep 17 00:00:00 2001 From: Katharine Xiao <2405771+katxiao@users.noreply.github.com> Date: Wed, 8 Dec 2021 16:26:03 -0800 Subject: [PATCH 04/12] Update mac os version on tutorials workflow (#662) --- .github/workflows/tutorials.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tutorials.yml b/.github/workflows/tutorials.yml index 9de0e9c7e..d6957d77d 100644 --- a/.github/workflows/tutorials.yml +++ b/.github/workflows/tutorials.yml @@ -10,7 +10,7 @@ jobs: strategy: matrix: python-version: [3.6, 3.7, 3.8, 3.9] - os: [ubuntu-latest, macos-latest, windows-latest] + os: [ubuntu-latest, macos-10.15, windows-latest] steps: - uses: actions/checkout@v1 - name: Set up Python ${{ matrix.python-version }} From 9032c4472563a8d6129c1e72258191031a489d47 Mon Sep 17 00:00:00 2001 From: Katharine Xiao <2405771+katxiao@users.noreply.github.com> Date: Fri, 10 Dec 2021 11:34:35 -0800 Subject: [PATCH 05/12] Display all attempted metrics (#652) * Add option to display errored metrics * Update sdmetrics version --- conda/meta.yaml | 4 ++-- sdv/evaluation.py | 1 - setup.py | 2 +- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/conda/meta.yaml b/conda/meta.yaml index 82112b3eb..bf4b4dfcd 100644 --- a/conda/meta.yaml +++ b/conda/meta.yaml @@ -28,7 +28,7 @@ requirements: - ctgan >=0.5.0,<0.6 - deepecho >=0.3.0.post1,<0.4 - rdt >=0.6.1,<0.7 - - sdmetrics >=0.4.0,<0.5 + - sdmetrics >=0.4.1,<0.5 run: - graphviz - python >=3.6,<3.10 @@ -41,7 +41,7 @@ requirements: - ctgan >=0.5.0,<0.6 - deepecho >=0.3.0.post1,<0.4 - rdt >=0.6.1,<0.7 - - sdmetrics >=0.4.0,<0.5 + - sdmetrics >=0.4.1,<0.5 about: home: "https://sdv.dev" diff --git a/sdv/evaluation.py b/sdv/evaluation.py index 44a18bb6e..1304fb5b9 100644 --- a/sdv/evaluation.py +++ b/sdv/evaluation.py @@ -133,7 +133,6 @@ def evaluate(synthetic_data, real_data=None, metadata=None, root_path=None, synthetic_data = synthetic_data[table] scores = sdmetrics.compute_metrics(metrics, real_data, synthetic_data, metadata=metadata) - scores.dropna(inplace=True) if aggregate: return scores.normalized_score.mean() diff --git a/setup.py b/setup.py index 2bf361fe3..c54be1c3a 100644 --- a/setup.py +++ b/setup.py @@ -22,7 +22,7 @@ 'ctgan>=0.5.0,<0.6', 'deepecho>=0.3.0.post1,<0.4', 'rdt>=0.6.1,<0.7', - 'sdmetrics>=0.4.0,<0.5', + 'sdmetrics>=0.4.1,<0.5', ] pomegranate_requires = [ From 643de0a874b0d870a5bdf76ac79c6b55a2800074 Mon Sep 17 00:00:00 2001 From: Katharine Xiao <2405771+katxiao@users.noreply.github.com> Date: Fri, 10 Dec 2021 18:03:04 -0800 Subject: [PATCH 06/12] Add constraints arg to metadata add table (#660) * Add constraints arg to metadata add table * add unit test * add documentation --- docs/user_guides/relational/constraints.rst | 82 +++++++++++++++++++ docs/user_guides/relational/index.rst | 1 + .../single_table/custom_constraints.rst | 2 +- sdv/metadata/dataset.py | 18 +++- tests/unit/metadata/test_dataset.py | 66 +++++++++++++++ 5 files changed, 166 insertions(+), 3 deletions(-) create mode 100644 docs/user_guides/relational/constraints.rst diff --git a/docs/user_guides/relational/constraints.rst b/docs/user_guides/relational/constraints.rst new file mode 100644 index 000000000..7fd1a797f --- /dev/null +++ b/docs/user_guides/relational/constraints.rst @@ -0,0 +1,82 @@ +.. _relational_constraints: + +Constraints +=========== + +SDV supports adding constraints within a single table. See :ref:`single_table_constraints` +for more information about the available single table constraints. + +In order to use single-table constraints within a relational model, you can pass +in a list of applicable constraints when adding a table to your relational ``Metadata``. +(See :ref:`relational_metadata` for more information on constructing a ``Metadata`` object.) + +In this example, we wish to add a ``UniqueCombinations`` constraint to our ``sessions`` table, +which is a child table of ``users``. First, we will create a ``Metadata`` object and add the +``users`` table. + +.. ipython:: python + :okwarning: + + from sdv import load_demo, Metadata + + tables = load_demo() + + metadata = Metadata() + + metadata.add_table( + name='users', + data=tables['users'], + primary_key='user_id' + ) + +The metadata now contains the ``users`` table. + +.. ipython:: python + :okwarning: + + metadata + +Now, we want to add a child table ``sessions`` which contains a single table constraint. +In the ``sessions`` table, we wish to only have combinations of ``(device, os)`` that +appear in the original data. + +.. ipython:: python + :okwarning: + + from sdv.constraints import UniqueCombinations + + constraint = UniqueCombinations(columns=['device', 'os']) + + metadata.add_table( + name='sessions', + data=tables['sessions'], + primary_key='session_id', + parent='users', + foreign_key='user_id', + constraints=[constraint], + ) + +If we get the table metadata for ``sessions``, we can see that the constraint has been added. + +.. ipython:: python + :okwarning: + + metadata.get_table_meta('sessions') + +We can now use this metadata to fit a relational model and synthesize data. + +.. ipython:: python + :okwarning: + + from sdv.relational import HMA1 + + model = HMA1(metadata) + model.fit(tables) + new_data = model.sample() + +In the sampled data, we should see that our constraint is being satisfied. + +.. ipython:: python + :okwarning: + + new_data diff --git a/docs/user_guides/relational/index.rst b/docs/user_guides/relational/index.rst index b34183efa..b262039ca 100644 --- a/docs/user_guides/relational/index.rst +++ b/docs/user_guides/relational/index.rst @@ -10,3 +10,4 @@ Relational Data data_description models + constraints diff --git a/docs/user_guides/single_table/custom_constraints.rst b/docs/user_guides/single_table/custom_constraints.rst index de422512f..821dbfb83 100644 --- a/docs/user_guides/single_table/custom_constraints.rst +++ b/docs/user_guides/single_table/custom_constraints.rst @@ -23,7 +23,7 @@ Let's look at a demo dataset: employees = load_tabular_demo() employees -The dataset defined in :ref:`_single_table_constraints` contains basic details about employees. +The dataset defined in :ref:`handling_constraints` contains basic details about employees. We will use this dataset to demonstrate how you can create your own constraint. diff --git a/sdv/metadata/dataset.py b/sdv/metadata/dataset.py index 2e078bef3..bd77b95a5 100644 --- a/sdv/metadata/dataset.py +++ b/sdv/metadata/dataset.py @@ -10,6 +10,7 @@ import pandas as pd from rdt import HyperTransformer, transformers +from sdv.constraints import Constraint from sdv.metadata import visualization from sdv.metadata.errors import MetadataError @@ -871,7 +872,7 @@ def _get_field_details(self, data, fields): return fields_metadata def add_table(self, name, data=None, fields=None, fields_metadata=None, - primary_key=None, parent=None, foreign_key=None): + primary_key=None, parent=None, foreign_key=None, constraints=None): """Add a new table to this metadata. ``fields`` list can be a mixture of field names, which will be build automatically @@ -902,7 +903,10 @@ def add_table(self, name, data=None, fields=None, fields_metadata=None, parent (str): Table name to refere a foreign key field. Defaults to ``None``. foreign_key (str): - Foreing key field name to ``parent`` table primary key. Defaults to ``None``. + Foreign key field name to ``parent`` table primary key. Defaults to ``None``. + constraints (list[Constraint, dict]): + List of Constraint objects or dicts representing the constraints for the + given table. Raises: ValueError: @@ -938,6 +942,16 @@ def add_table(self, name, data=None, fields=None, fields_metadata=None, self._metadata['tables'][name] = table_metadata + if constraints: + meta_constraints = [] + for constraint in constraints: + if isinstance(constraint, Constraint): + meta_constraints.append(constraint.to_dict()) + else: + meta_constraints.append(constraint) + + table_metadata['constraints'] = meta_constraints + try: if primary_key: self.set_primary_key(name, primary_key) diff --git a/tests/unit/metadata/test_dataset.py b/tests/unit/metadata/test_dataset.py index 5da5625f9..a1f8a88a9 100644 --- a/tests/unit/metadata/test_dataset.py +++ b/tests/unit/metadata/test_dataset.py @@ -879,6 +879,72 @@ def test_add_table_with_data_str(self, mock_read_csv): metadata.set_primary_key.call_count == 0 metadata.add_relationship.call_count == 0 + def test_add_table_with_constraints(self): + """Test the ``Metadata.add_table`` method with constraints. + + Expect that when constraints are provided, the metadata for the + specified table is created with the given constraints. + + Input: + - Metadata object + - Table name of the desired table to add + - Metadata for the table's fields + - Constraints for the given table + Side Effects: + - An entry is added to the metadata for the provided table, which contains + the given fields and constrants. + """ + # Setup + metadata = Mock(spec_set=Metadata) + metadata.get_tables.return_value = ['a_table', 'b_table'] + metadata._metadata = {'tables': dict()} + + # Run + fields_metadata = { + 'a_field': {'type': 'numerical', 'subtype': 'integer'}, + 'b_field': {'type': 'numerical', 'subtype': 'integer'} + } + constraints = [ + { + 'constraint': 'sdv.constraints.tabular.GreaterThan', + 'columns': [ + 'a_field', + 'b_field', + ], + 'handling_strategy': 'transform', + } + ] + + Metadata.add_table( + metadata, + 'x_table', + fields_metadata=fields_metadata, + constraints=constraints, + ) + + # Asserts + expected_table_meta = { + 'fields': { + 'a_field': {'type': 'numerical', 'subtype': 'integer'}, + 'b_field': {'type': 'numerical', 'subtype': 'integer'}, + }, + 'constraints': [ + { + 'constraint': 'sdv.constraints.tabular.GreaterThan', + 'columns': [ + 'a_field', + 'b_field', + ], + 'handling_strategy': 'transform', + }, + ] + } + + assert metadata._metadata['tables']['x_table'] == expected_table_meta + + metadata.set_primary_key.call_count == 0 + metadata.add_relationship.call_count == 0 + def test_add_relationship_table_no_exist(self): """Add relationship table no exist""" # Setup From 1281246131462d998d5eafe448d26f7423242c5f Mon Sep 17 00:00:00 2001 From: Plamen Valentinov Kolev <41479552+pvk-developer@users.noreply.github.com> Date: Wed, 15 Dec 2021 20:05:17 +0100 Subject: [PATCH 07/12] Update README.md (#669) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 534f1715b..a131cd9ed 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ [![Binder](https://mybinder.org/badge_logo.svg)](https://mybinder.org/v2/gh/sdv-dev/SDV/master?filepath=tutorials) [![Slack](https://img.shields.io/badge/Slack%20Workspace-Join%20now!-36C5F0?logo=slack)](https://join.slack.com/t/sdv-space/shared_invite/zt-gdsfcb5w-0QQpFMVoyB2Yd6SRiMplcw) - + * Website: https://sdv.dev * Documentation: https://sdv.dev/SDV From f87f503cb66cd49d2bb5ddec2ba09c9ccad3ed47 Mon Sep 17 00:00:00 2001 From: Felipe Alex Hofmann Date: Thu, 16 Dec 2021 10:00:48 -0800 Subject: [PATCH 08/12] Fix categorical column after sequence_index column issue (#357) * Fixes the issue * Add test * Fix lint * Addresses feedback/adds new test case * Fix lint/remove get_fields() from loop * Changes `fields` to `fields_metadata` * Add more validation to the test cases --- tests/integration/timeseries/test_par.py | 41 ++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/tests/integration/timeseries/test_par.py b/tests/integration/timeseries/test_par.py index 8a89eb0b5..af22602c4 100644 --- a/tests/integration/timeseries/test_par.py +++ b/tests/integration/timeseries/test_par.py @@ -1,3 +1,5 @@ +import datetime + import pandas as pd from deepecho import load_demo @@ -47,3 +49,42 @@ def test_par(): assert sampled.shape == data.shape assert (sampled.dtypes == data.dtypes).all() assert (sampled.notnull().sum(axis=1) != 0).all() + + +def test_column_after_date_simple(): + """Test that adding a column after the `sequence_index` column works.""" + date = datetime.datetime.strptime('2020-01-01', '%Y-%m-%d') + data = pd.DataFrame({ + 'col': ['a', 'a'], + 'date': [date, date], + 'col2': ['hello', 'world'], + }) + + model = PAR(entity_columns=['col'], sequence_index='date', epochs=1) + model.fit(data) + sampled = model.sample() + + assert sampled.shape == data.shape + assert (sampled.dtypes == data.dtypes).all() + assert (sampled.notnull().sum(axis=1) != 0).all() + + +def test_column_after_date_complex(): + """Test that adding multiple columns after the `sequence_index` column works.""" + date = datetime.datetime.strptime('2020-01-01', '%Y-%m-%d') + data = pd.DataFrame({ + 'column1': [1.0, 2.0, 1.5, 1.3], + 'date': [date, date, date, date], + 'column2': ['b', 'a', 'a', 'c'], + 'entity': ['person1', 'person1', 'person2', 'person2'], + 'context': ['a', 'a', 'b', 'b'] + }) + + model = PAR(entity_columns=['entity'], context_columns=['context'], sequence_index='date', + epochs=1) + model.fit(data) + sampled = model.sample() + + assert sampled.shape == data.shape + assert (sampled.dtypes == data.dtypes).all() + assert (sampled.notnull().sum(axis=1) != 0).all() From 91f2357639df39780f61aab56b496313342feabe Mon Sep 17 00:00:00 2001 From: Katharine Xiao <2405771+katxiao@users.noreply.github.com> Date: Fri, 17 Dec 2021 12:58:16 -0800 Subject: [PATCH 09/12] Update metadata doc (#671) --- docs/developer_guides/sdv/metadata.rst | 6 +++--- sdv/relational/hma.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/developer_guides/sdv/metadata.rst b/docs/developer_guides/sdv/metadata.rst index ad509a155..5ef322573 100644 --- a/docs/developer_guides/sdv/metadata.rst +++ b/docs/developer_guides/sdv/metadata.rst @@ -130,7 +130,7 @@ the following keys. "fields": { "social_security_number": { "type": "categorical", - "pii": True, + "pii": true, "pii_category": "ssn" }, ... @@ -180,7 +180,7 @@ A list of all possible localizations can be found on the `Faker documentation si "fields": { "address": { "type": "categorical", - "pii": True, + "pii": true, "pii_category": "address" "pii_locales": ["sv_SE", "en_US"] }, @@ -215,7 +215,7 @@ If a field is specified as a ``primary_key`` of the table, then the field must b ... } -If the subtype of the primary key is integer, an optional regular expression can be passed to +If the subtype of the primary key is string, an optional regular expression can be passed to generate keys that match it: .. code-block:: python diff --git a/sdv/relational/hma.py b/sdv/relational/hma.py index 7b9c74a8a..d308e2834 100644 --- a/sdv/relational/hma.py +++ b/sdv/relational/hma.py @@ -123,7 +123,7 @@ def _load_table(self, tables, table_name): Returns: pandas.DataFrame """ - if tables: + if tables and table_name in tables: table = tables[table_name].copy() else: table = self.metadata.load_table(table_name) From 73da0e1c61a27f33cfb01ef10b565d254c9d6931 Mon Sep 17 00:00:00 2001 From: Carles Sala Date: Wed, 22 Dec 2021 15:45:35 +0100 Subject: [PATCH 10/12] Update README and Logos --- .gitignore | 1 - README.md | 73 ++++++++++++++++++++++------- docs/images/CTGAN-DataCebo.png | Bin 0 -> 52078 bytes docs/images/Copulas-DataCebo.png | Bin 0 -> 50900 bytes docs/images/DataCebo-Blue.png | Bin 0 -> 60600 bytes docs/images/DataCebo.png | Bin 0 -> 54065 bytes docs/images/DeepEcho-DataCebo.png | Bin 0 -> 46250 bytes docs/images/RDT-DataCebo.png | Bin 0 -> 25573 bytes docs/images/SDGym-DataCebo.png | Bin 0 -> 27285 bytes docs/images/SDMetrics-DataCebo.png | Bin 0 -> 35670 bytes docs/images/SDV-DataCebo.png | Bin 0 -> 22394 bytes 11 files changed, 57 insertions(+), 17 deletions(-) create mode 100644 docs/images/CTGAN-DataCebo.png create mode 100644 docs/images/Copulas-DataCebo.png create mode 100644 docs/images/DataCebo-Blue.png create mode 100644 docs/images/DataCebo.png create mode 100644 docs/images/DeepEcho-DataCebo.png create mode 100644 docs/images/RDT-DataCebo.png create mode 100644 docs/images/SDGym-DataCebo.png create mode 100644 docs/images/SDMetrics-DataCebo.png create mode 100644 docs/images/SDV-DataCebo.png diff --git a/.gitignore b/.gitignore index 624f18db7..1bf92b0b5 100644 --- a/.gitignore +++ b/.gitignore @@ -109,7 +109,6 @@ ENV/ sdv/data/ docs/**/*.pkl docs/**/*metadata.json -docs/images docs/savefig tutorials/**/*.pkl tutorials/**/*metadata.json diff --git a/README.md b/README.md index a131cd9ed..c87d64bb9 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,7 @@ -

- - DAI-Lab - - An Open Source Project from the Data to AI Lab, at MIT +

+
+

+ This repository is part of The Synthetic Data Vault Project, a project from DataCebo.

[![Development Status](https://img.shields.io/badge/Development%20Status-2%20--%20Pre--Alpha-yellow)](https://pypi.org/search/?c=Development+Status+%3A%3A+2+-+Pre-Alpha) @@ -13,17 +12,16 @@ [![Binder](https://mybinder.org/badge_logo.svg)](https://mybinder.org/v2/gh/sdv-dev/SDV/master?filepath=tutorials) [![Slack](https://img.shields.io/badge/Slack%20Workspace-Join%20now!-36C5F0?logo=slack)](https://join.slack.com/t/sdv-space/shared_invite/zt-gdsfcb5w-0QQpFMVoyB2Yd6SRiMplcw) - +
+
+

+ +

+
-* Website: https://sdv.dev -* Documentation: https://sdv.dev/SDV - * [User Guides](https://sdv.dev/SDV/user_guides/index.html) - * [Developer Guides](https://sdv.dev/SDV/developer_guides/index.html) -* Github: https://github.com/sdv-dev/SDV -* License: [MIT](https://github.com/sdv-dev/SDV/blob/master/LICENSE) -* Development Status: [Pre-Alpha](https://pypi.org/search/?c=Development+Status+%3A%3A+2+-+Pre-Alpha) +
-## Overview +# Overview The **Synthetic Data Vault (SDV)** is a **Synthetic Data Generation** ecosystem of libraries that allows users to easily learn [single-table]( @@ -41,7 +39,27 @@ Underneath the hood it uses several probabilistic graphical modeling and deep le techniques. To enable a variety of data storage structures, we employ unique hierarchical generative modeling and recursive sampling techniques. -### Current functionality and features: +| Important Links | | +| -------------------------- | -------------------------------------------------------------- | +| :computer: **[Website]** | Check out the SDV Website for more information about the project. | +| :orange_book: **[SDV Blog]** | Regular publshing of useful content about Synthetic Data Generation. | +| :book: **[Documentation]** | Quickstarts, User and Development Guides, and API Reference. | +| :octocat: **[Repository]** | The link to the Github Repository of this library. | +| :scroll: **[License]** | The entire ecosystem is published under the MIT License. | +| :keyboard: **[Development Status]** | This software is in its Pre-Alpha stage. | +| ![](slack.png) **[Community]** | Join our Slack Workspace for announcements and discussions. | +| ![](mybinder.png) **[Tutorials]** | Run the SDV Tutorials in a Binder environment. | + +[Website]: https://sdv.dev +[SDV Blog]: https://sdv.dev/blog +[Documentation]: https://sdv.dev/SDV +[Repository]: https://github.com/sdv-dev/SDV +[License]: https://github.com/sdv-dev/SDV/blob/master/LICENSE +[Development Status]: https://pypi.org/search/?c=Development+Status+%3A%3A+2+-+Pre-Alpha +[Community]: https://join.slack.com/t/sdv-space/shared_invite/zt-gdsfcb5w-0QQpFMVoyB2Yd6SRiMplcw +[Tutorials]: https://mybinder.org/v2/gh/sdv-dev/SDV/master?filepath=tutorials + +## Current functionality and features: * Synthetic data generators for [single tables]( https://sdv.dev/SDV/user_guides/single_table/index.html) with the following @@ -89,7 +107,7 @@ pip install sdv **Using `conda`:** ```bash -conda install -c sdv-dev -c pytorch -c conda-forge sdv +conda install -c pytorch -c conda-forge sdv ``` For more installation options please visit the [SDV installation Guide]( @@ -254,3 +272,26 @@ Neha Patki, Roy Wedge, Kalyan Veeramachaneni. [The Synthetic Data Vault](https:/ month={Oct} } ``` + +--- + + +
+ +
+
+
+ +The [DataCebo team](https://datacebo.com) is the proud developer of [The Synthetic Data Vault Project]( +https://sdv.dev), the largest open source ecosystem for synthetic data generation & evaluation. +The ecosystem is home to multiple libraries that support synthetic data, including: + +* 🔄 Data discovery & transformation. Reverse the transforms to reproduce realistic data. +* 🧠 Multiple machine learning models -- ranging from Copulas to Deep Learning -- to create tabular, + multi table and time series data. +* 📊 Measuring quality and privacy of synthetic data, and comparing different synthetic data + generation models. + +[Get started using the SDV package](https://sdv.dev/SDV/getting_started/install.html) -- a fully +integrated solution and your one-stop shop for synthetic data.Or, use the standalone libraries +for specific needs. diff --git a/docs/images/CTGAN-DataCebo.png b/docs/images/CTGAN-DataCebo.png new file mode 100644 index 0000000000000000000000000000000000000000..b913cfe9bcb7a0630d1a4952dfa672c53fe1ddaa GIT binary patch literal 52078 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