From 20856a093a339be1c47da53ac888389fb4e9d6bf Mon Sep 17 00:00:00 2001 From: Kevin Alex Zhang Date: Thu, 4 Jun 2020 01:30:30 -0400 Subject: [PATCH 01/42] Added S3 download --- benchmark/README.md | 1 + benchmark/benchmark.py | 0 datatracer/data.py | 27 +++++++++++++++++++++++++++ setup.py | 3 ++- 4 files changed, 30 insertions(+), 1 deletion(-) create mode 100644 benchmark/README.md create mode 100644 benchmark/benchmark.py diff --git a/benchmark/README.md b/benchmark/README.md new file mode 100644 index 0000000..181eaeb --- /dev/null +++ b/benchmark/README.md @@ -0,0 +1 @@ +# Benchmarking DataTracer diff --git a/benchmark/benchmark.py b/benchmark/benchmark.py new file mode 100644 index 0000000..e69de29 diff --git a/datatracer/data.py b/datatracer/data.py index 400acfe..35507e6 100644 --- a/datatracer/data.py +++ b/datatracer/data.py @@ -6,7 +6,12 @@ """ import os import shutil +from io import BytesIO +from urllib.parse import urljoin +from urllib.request import urlopen +from zipfile import ZipFile +import boto3 import pandas as pd from metad import MetaData @@ -91,3 +96,25 @@ def get_demo_data(path='datatracer_demo', force=False): demo_path = os.path.join(os.path.dirname(__file__), 'datasets') shutil.copytree(demo_path, path) + + +def download(): + BUCKET_NAME = 'tracer-data' # Bucket where the datasets are stored + DATA_URL = 'http://{}.s3.amazonaws.com/'.format(BUCKET_NAME) + + homedir = os.path.expanduser("~/") + if homedir == "~/": + raise ValueError("Could not find a default download directory") + data_dir = os.path.join(homedir, "tracer_data") + + client = boto3.client('s3') + for dataset in client.list_objects(Bucket=BUCKET_NAME)['Contents']: + dataset_name = dataset['Key'].replace(".zip", "") + dataset_path = os.path.join(data_dir, dataset_name) + if os.path.exists(dataset_path): + print("Skipping %s" % dataset_name) + else: + print("Downloading %s" % dataset_name) + with urlopen(urljoin(DATA_URL, dataset['Key'])) as fp: + with ZipFile(BytesIO(fp.read())) as zipfile: + zipfile.extractall(dataset_path) diff --git a/setup.py b/setup.py index 9159b71..44f699c 100644 --- a/setup.py +++ b/setup.py @@ -12,6 +12,7 @@ history = history_file.read() install_requires = [ + 'boto3>=1.13,<2', 'pandas>=0.23.4,<0.25', 'scikit-learn>=0.20.0,<0.21', 'numpy<1.17,>=1.15.2', @@ -100,7 +101,7 @@ keywords='datatracer data-tracer Data Tracer', name='datatracer', packages=find_packages(include=['datatracer', 'datatracer.*']), - python_requires='>=3.5,<3.8', + python_requires='>=3.5,<=3.8', setup_requires=setup_requires, test_suite='tests', tests_require=tests_require, From 699adaed0148290d9471dc7e0e76c84493fc7e38 Mon Sep 17 00:00:00 2001 From: Kevin Alex Zhang Date: Sat, 6 Jun 2020 15:58:31 -0400 Subject: [PATCH 02/42] Added primary/foreign evaluation --- benchmark/benchmark.py | 95 ++++++++++++++++++++++++++++++++++++++++++ setup.py | 2 +- 2 files changed, 96 insertions(+), 1 deletion(-) diff --git a/benchmark/benchmark.py b/benchmark/benchmark.py index e69de29..3ad17c2 100644 --- a/benchmark/benchmark.py +++ b/benchmark/benchmark.py @@ -0,0 +1,95 @@ +from datatracer import load_datasets, DataTracer + +def evaluate_primary_key(solver, metadata, tables): + """Evaluate a primary key solver. + + This computes the accuracy of a primary key solver on the given dataset. It + skips tables without primary keys as well as tables which have composite + primary keys. + + Args: + solver (DataTracer): + A DataTracer instance which implements primary key detection. + metadata (MetaData): + A MetaData instance which describes the tables. + tables (dict): + A dictionary mapping table names to dataframes + + Returns: + dict: + A dictionary mapping metric names to values. + """ + y_true = {} + for table in metadata.get_tables(): + if "primary_key" not in table: + continue # Skip tables without primary keys + if not isinstance(table["primary_key"], str): + continue # Skip tables with composite primary keys + y_true[table["name"]] = table["primary_key"] + + correct, total = 0, 0 + y_pred = solver.solve(tables) + for table_name, primary_key in y_true.items(): + if y_pred.get(table_name) == primary_key: + correct += 1 + total += 1 + accuracy = correct / total + + return { + "accuracy": accuracy + } + +def evaluate_foreign_key(solver, metadata, tables): + """Evaluate a foreign key solver. + + This computes the precision, recall, and f1 score of a foreign key solver + on the given dataset. It skips composite foreign primary keys. + + Args: + solver (DataTracer): + A DataTracer instance which implements foreign key detection. + metadata (MetaData): + A MetaData instance which describes the tables. + tables (dict): + A dictionary mapping table names to dataframes + + Returns: + dict: + A dictionary mapping metric names to values. + """ + y_true = set() + for fk in metadata.get_foreign_keys(): + if not isinstance(fk["field"], str): + continue # Skip composite foreign keys + y_true.add((fk["table"], fk["field"], fk["ref_table"], fk["ref_field"])) + + y_pred = set() + best_precision, best_recall, best_f1 = float("-inf"), float("-inf"), float("-inf") + for fk in solver.solve(tables): + y_pred.add((fk["table"], fk["field"], fk["ref_table"], fk["ref_field"])) + + precision = len(y_true.intersection(y_pred)) / len(y_pred) + recall = len(y_true.intersection(y_pred)) / len(y_true) + f1 = 2.0 * precision * recall / (precision + recall) + + if f1 > best_f1: + best_precision = precision + best_recall = recall + best_f1 = f1 + + return { + "precision": best_precision, + "recall": best_recall, + "f1": best_f1 + } + +if __name__ == "__main__": + datasets = load_datasets("../datatracer/datasets") + + solver = DataTracer.load('datatracer.primary_key.basic') + for dataset_name, (metadata, tables) in datasets.items(): + print(dataset_name, evaluate_primary_key(solver, metadata, tables)) + + solver = DataTracer.load('datatracer.foreign_key.standard') + for dataset_name, (metadata, tables) in datasets.items(): + print(dataset_name, evaluate_foreign_key(solver, metadata, tables)) diff --git a/setup.py b/setup.py index 44f699c..3b7dd33 100644 --- a/setup.py +++ b/setup.py @@ -21,7 +21,7 @@ 'falcon>=2.0.0,<3', 'hug>=2.6.1,<3', 'pyyaml>=5.3.1,<6', - 'tqdm>=4.46.1,<5', + 'tqdm>=4,<5', ] setup_requires = [ From 422fad84e5e63bf381e0d7cdc9a8d3a644f833ca Mon Sep 17 00:00:00 2001 From: Kevin Alex Zhang Date: Mon, 8 Jun 2020 15:01:04 -0400 Subject: [PATCH 03/42] Added inference time --- benchmark/README.md | 59 ++++++++++++++++++++++++++++++++++++++++++ benchmark/benchmark.py | 31 ++++++++++++---------- 2 files changed, 76 insertions(+), 14 deletions(-) diff --git a/benchmark/README.md b/benchmark/README.md index 181eaeb..4150079 100644 --- a/benchmark/README.md +++ b/benchmark/README.md @@ -1 +1,60 @@ # Benchmarking DataTracer +This directory contains code for benchmarking the performance of `DataTracer` +on user-supplied datasets. The datasets for benchmarking can be found in the +`s3://tracer-data` bucket. + +## Primary Key +Primary key detection is evaluated by: + + - Accuracy. The percent of tables where the primary key was correctly identified. + - Inference time. The amount of time to infer the primary key for all tables. + +We will use leave-one-out validation and report the test performance on each dataset +in the S3 bucket. + +## Foreign Key +Foreign key detection is evaluated by: + + - F1. + - Recall. + - Precision. + - Inference time. + +Note that this assumes that the foreign key primitive returns a set of foreign keys; +in other words, for models that return a score for each candidate foreign key, this +assumes that thresholding is done. + +We will use leave-one-out validation and report the test performance on each dataset +in the S3 bucket. + +## Foreign Key (No Thesholding) +Foreign key detection (no thresholding) is evaluated by: + + - Best F1. + - Best Recall. + - Best Precision. + - Inference time. + +Note that this assumes that the foreign key primitive returns a ranking of foreign +keys. For each threshold - i.e. take the top N predicted foreign keys - the F1 score +is computed and only the threshold that produces the best F1 score is selected. It +returns the corresponding F1/Recall/Precision values. + +We will use leave-one-out validation and report the test performance on each dataset +in the S3 bucket. + +## Column Map +Column map detection is evaluated by: + + - F1. + - Recall. + - Precision. + - Inference time. + +Note that this assumes that the column map primitive returns a set of columns that it +thinks contributed to the target derived column. Since each dataset can have multiple +derived columns, this will report a F1/recall/precision/time tuple for each derived +column in the dataset. + +We will use leave-one-out validation and report the test performance on each dataset +in the S3 bucket. diff --git a/benchmark/benchmark.py b/benchmark/benchmark.py index 3ad17c2..d9d3107 100644 --- a/benchmark/benchmark.py +++ b/benchmark/benchmark.py @@ -1,3 +1,4 @@ +from time import time from datatracer import load_datasets, DataTracer def evaluate_primary_key(solver, metadata, tables): @@ -28,7 +29,9 @@ def evaluate_primary_key(solver, metadata, tables): y_true[table["name"]] = table["primary_key"] correct, total = 0, 0 + start = time() y_pred = solver.solve(tables) + end = time() for table_name, primary_key in y_true.items(): if y_pred.get(table_name) == primary_key: correct += 1 @@ -36,7 +39,8 @@ def evaluate_primary_key(solver, metadata, tables): accuracy = correct / total return { - "accuracy": accuracy + "accuracy": accuracy, + "inference_time": end - start } def evaluate_foreign_key(solver, metadata, tables): @@ -63,24 +67,23 @@ def evaluate_foreign_key(solver, metadata, tables): continue # Skip composite foreign keys y_true.add((fk["table"], fk["field"], fk["ref_table"], fk["ref_field"])) + start = time() + fk_pred = solver.solve(tables) + end = time() + y_pred = set() - best_precision, best_recall, best_f1 = float("-inf"), float("-inf"), float("-inf") - for fk in solver.solve(tables): + for fk in fk_pred: y_pred.add((fk["table"], fk["field"], fk["ref_table"], fk["ref_field"])) - precision = len(y_true.intersection(y_pred)) / len(y_pred) - recall = len(y_true.intersection(y_pred)) / len(y_true) - f1 = 2.0 * precision * recall / (precision + recall) - - if f1 > best_f1: - best_precision = precision - best_recall = recall - best_f1 = f1 + precision = len(y_true.intersection(y_pred)) / len(y_pred) + recall = len(y_true.intersection(y_pred)) / len(y_true) + f1 = 2.0 * precision * recall / (precision + recall) return { - "precision": best_precision, - "recall": best_recall, - "f1": best_f1 + "precision": precision, + "recall": recall, + "f1": f1, + "inference_time": end - start } if __name__ == "__main__": From 4a7476218cba3ff27c7db7393328d4954c0529ee Mon Sep 17 00:00:00 2001 From: Kevin Alex Zhang Date: Tue, 9 Jun 2020 12:57:10 -0400 Subject: [PATCH 04/42] Added dask-based benchmarks --- benchmark/README.md | 23 +--- benchmark/benchmark.py | 298 +++++++++++++++++++++++++++++++++-------- datatracer/data.py | 27 ---- setup.py | 8 +- 4 files changed, 252 insertions(+), 104 deletions(-) diff --git a/benchmark/README.md b/benchmark/README.md index 4150079..e51398a 100644 --- a/benchmark/README.md +++ b/benchmark/README.md @@ -3,6 +3,13 @@ This directory contains code for benchmarking the performance of `DataTracer` on user-supplied datasets. The datasets for benchmarking can be found in the `s3://tracer-data` bucket. +Each benchmark - `primary`, `foreign`, and `column` - can be executed by +running the following command + +> datatracer-benchmark --output /path/to/results.csv + +which will (optionally) generate a CSV file with the benchmark results. + ## Primary Key Primary key detection is evaluated by: @@ -27,22 +34,6 @@ assumes that thresholding is done. We will use leave-one-out validation and report the test performance on each dataset in the S3 bucket. -## Foreign Key (No Thesholding) -Foreign key detection (no thresholding) is evaluated by: - - - Best F1. - - Best Recall. - - Best Precision. - - Inference time. - -Note that this assumes that the foreign key primitive returns a ranking of foreign -keys. For each threshold - i.e. take the top N predicted foreign keys - the F1 score -is computed and only the threshold that produces the best F1 score is selected. It -returns the corresponding F1/Recall/Precision values. - -We will use leave-one-out validation and report the test performance on each dataset -in the S3 bucket. - ## Column Map Column map detection is evaluated by: diff --git a/benchmark/benchmark.py b/benchmark/benchmark.py index d9d3107..7aa8873 100644 --- a/benchmark/benchmark.py +++ b/benchmark/benchmark.py @@ -1,25 +1,58 @@ +import argparse +import os +from io import BytesIO from time import time -from datatracer import load_datasets, DataTracer - -def evaluate_primary_key(solver, metadata, tables): - """Evaluate a primary key solver. - - This computes the accuracy of a primary key solver on the given dataset. It - skips tables without primary keys as well as tables which have composite - primary keys. - - Args: - solver (DataTracer): - A DataTracer instance which implements primary key detection. - metadata (MetaData): - A MetaData instance which describes the tables. - tables (dict): - A dictionary mapping table names to dataframes - - Returns: - dict: - A dictionary mapping metric names to values. +from urllib.parse import urljoin +from urllib.request import urlopen +from zipfile import ZipFile + +import boto3 +import dask +import pandas as pd + +from dask.diagnostics import ProgressBar +from datatracer import DataTracer, load_datasets + +BUCKET_NAME = 'tracer-data' +DATA_URL = 'http://{}.s3.amazonaws.com/'.format(BUCKET_NAME) +DATA_DIR = os.path.expanduser("~/tracer_data") + + +def download(): + """ + This downloads the benchmark datasets from S3. """ + rows = [] + client = boto3.client('s3') + for dataset in client.list_objects(Bucket=BUCKET_NAME)['Contents']: + rows.append(dataset) + dataset_name = dataset['Key'].replace(".zip", "") + dataset_path = os.path.join(DATA_DIR, dataset_name) + if os.path.exists(dataset_path): + dataset["Status"] = "Skipped" + print("Skipping %s" % dataset_name) + else: + dataset["Status"] = "Downloaded" + print("Downloading %s" % dataset_name) + with urlopen(urljoin(DATA_URL, dataset['Key'])) as fp: + with ZipFile(BytesIO(fp.read())) as zipfile: + zipfile.extractall(dataset_path) + return pd.DataFrame(rows) + + +@dask.delayed +def primary_key(solver, target, datasets): + """ + solver - the name of the pipeline? + target - a key in dataset + datasets - map from dataset name to (metadata, tables) + """ + datasets = datasets.copy() + metadata, tables = datasets.pop(target) + + tracer = DataTracer(solver) + tracer.fit(datasets) + y_true = {} for table in metadata.get_tables(): if "primary_key" not in table: @@ -27,10 +60,10 @@ def evaluate_primary_key(solver, metadata, tables): if not isinstance(table["primary_key"], str): continue # Skip tables with composite primary keys y_true[table["name"]] = table["primary_key"] - + correct, total = 0, 0 start = time() - y_pred = solver.solve(tables) + y_pred = tracer.solve(tables) end = time() for table_name, primary_key in y_true.items(): if y_pred.get(table_name) == primary_key: @@ -43,56 +76,203 @@ def evaluate_primary_key(solver, metadata, tables): "inference_time": end - start } -def evaluate_foreign_key(solver, metadata, tables): - """Evaluate a foreign key solver. - This computes the precision, recall, and f1 score of a foreign key solver - on the given dataset. It skips composite foreign primary keys. +def benchmark_primary_key(solver="datatracer.primary_key.basic"): + datasets = load_datasets(DATA_DIR) + dataset_names = list(datasets.keys()) + datasets = dask.delayed(datasets) + dataset_to_metrics = {} + for dataset_name in dataset_names: + dataset_to_metrics[dataset_name] = primary_key( + solver=solver, target=dataset_name, datasets=datasets) - Args: - solver (DataTracer): - A DataTracer instance which implements foreign key detection. - metadata (MetaData): - A MetaData instance which describes the tables. - tables (dict): - A dictionary mapping table names to dataframes + with ProgressBar(): + results = dask.compute(dataset_to_metrics)[0] + for dataset_name, metrics in results.items(): + metrics["dataset"] = dataset_name + return pd.DataFrame(list(results.values())) - Returns: - dict: - A dictionary mapping metric names to values. + +@dask.delayed +def foreign_key(solver, target, datasets): + """ + solver - the name of the pipeline? + target - a key in dataset + datasets - map from dataset name to (metadata, tables) """ + datasets = datasets.copy() + metadata, tables = datasets.pop(target) + + tracer = DataTracer(solver) + tracer.fit(datasets) + y_true = set() for fk in metadata.get_foreign_keys(): if not isinstance(fk["field"], str): continue # Skip composite foreign keys y_true.add((fk["table"], fk["field"], fk["ref_table"], fk["ref_field"])) - start = time() - fk_pred = solver.solve(tables) - end = time() + try: + start = time() + fk_pred = tracer.solve(tables) + end = time() - y_pred = set() - for fk in fk_pred: - y_pred.add((fk["table"], fk["field"], fk["ref_table"], fk["ref_field"])) + y_pred = set() + for fk in fk_pred: + y_pred.add((fk["table"], fk["field"], fk["ref_table"], fk["ref_field"])) - precision = len(y_true.intersection(y_pred)) / len(y_pred) - recall = len(y_true.intersection(y_pred)) / len(y_true) - f1 = 2.0 * precision * recall / (precision + recall) + precision = len(y_true.intersection(y_pred)) / len(y_pred) + recall = len(y_true.intersection(y_pred)) / len(y_true) + f1 = 2.0 * precision * recall / (precision + recall) - return { - "precision": precision, - "recall": recall, - "f1": f1, - "inference_time": end - start - } + return { + "precision": precision, + "recall": recall, + "f1": f1, + "inference_time": end - start + } -if __name__ == "__main__": - datasets = load_datasets("../datatracer/datasets") + except Exception as e: + return { + "error": str(e) + } + + +def benchmark_foreign_key(solver="datatracer.foreign_key.standard"): + datasets = load_datasets(DATA_DIR) + dataset_names = list(datasets.keys()) + datasets = dask.delayed(datasets) + dataset_to_metrics = {} + for dataset_name in dataset_names: + dataset_to_metrics[dataset_name] = foreign_key( + solver=solver, target=dataset_name, datasets=datasets) + + with ProgressBar(): + results = dask.compute(dataset_to_metrics)[0] + for dataset_name, metrics in results.items(): + metrics["dataset"] = dataset_name + return pd.DataFrame(list(results.values())) - solver = DataTracer.load('datatracer.primary_key.basic') - for dataset_name, (metadata, tables) in datasets.items(): - print(dataset_name, evaluate_primary_key(solver, metadata, tables)) - solver = DataTracer.load('datatracer.foreign_key.standard') - for dataset_name, (metadata, tables) in datasets.items(): - print(dataset_name, evaluate_foreign_key(solver, metadata, tables)) +@dask.delayed +def column_map(solver, target, datasets): + """ + solver - the name of the pipeline? + target - a key in dataset + datasets - map from dataset name to (metadata, tables) + """ + datasets = datasets.copy() + metadata, tables = datasets.pop(target) + + tracer = DataTracer(solver) + tracer.fit(datasets) + + list_of_metrics = [] + for constraint in metadata.data["constraints"]: + try: + field = constraint["fields_under_consideration"][0] + related_fields = constraint["related_fields"] + + y_true = set() + for related_field in related_fields: + y_true.add((related_field["table"], related_field["field"])) + + start = time() + y_pred = tracer.solve(tables, target_table=field["table"], target_field=field["field"]) + y_pred = {field for field, score in y_pred.items() if score > 0.0} + end = time() + + precision = len(y_true.intersection(y_pred)) / len(y_pred) + recall = len(y_true.intersection(y_pred)) / len(y_true) + f1 = 2.0 * precision * recall / (precision + recall) + + list_of_metrics.append({ + "table": field["table"], + "field": field["field"], + "precision": precision, + "recall": recall, + "f1": f1, + "inference_time": end - start + }) + + except Exception as e: + list_of_metrics.append({ + "table": field["table"], + "field": field["field"], + "error": str(e) + }) + + return list_of_metrics + + +def benchmark_column_map(solver="datatracer.column_map.basic"): + datasets = load_datasets(DATA_DIR) + dataset_names = list(datasets.keys()) + datasets = dask.delayed(datasets) + dataset_to_metrics = {} + for dataset_name in dataset_names: + dataset_to_metrics[dataset_name] = column_map( + solver=solver, target=dataset_name, datasets=datasets) + + rows = [] + with ProgressBar(): + results = dask.compute(dataset_to_metrics)[0] + for dataset_name, list_of_metrics in results.items(): + for metrics in list_of_metrics: + metrics["dataset"] = dataset_name + rows.append(metrics) + return pd.DataFrame(rows) + + +def _get_parser(): + shared_args = argparse.ArgumentParser(add_help=False) + shared_args.add_argument('-o', '--output', type=str, required=False, help='Path to the CSV file where the report will be written.') + + parser = argparse.ArgumentParser( + prog='datatracer-benchmark', + description='DataTracer Benchmark CLI' + ) + + command = parser.add_subparsers(title='command', help='Command to execute') + parser.set_defaults(benchmark=None) + + subparser = command.add_parser( + 'download', + parents=[shared_args], + help='Download datasets from S3.' + ) + subparser.set_defaults(command=download) + + subparser = command.add_parser( + 'primary', + parents=[shared_args], + help='Primary key benchmark.' + ) + subparser.set_defaults(command=benchmark_primary_key) + + subparser = command.add_parser( + 'foreign', + parents=[shared_args], + help='Foreign key benchmark.' + ) + subparser.set_defaults(command=benchmark_foreign_key) + + subparser = command.add_parser( + 'column', + parents=[shared_args], + help='Column map benchmark.' + ) + subparser.set_defaults(command=benchmark_column_map) + + return parser + +def main(): + parser = _get_parser() + args = parser.parse_args() + df = args.command() + if args.output: + df.to_csv(args.output, index=False) + print(df) + +if __name__ == "__main__": + main() diff --git a/datatracer/data.py b/datatracer/data.py index 35507e6..400acfe 100644 --- a/datatracer/data.py +++ b/datatracer/data.py @@ -6,12 +6,7 @@ """ import os import shutil -from io import BytesIO -from urllib.parse import urljoin -from urllib.request import urlopen -from zipfile import ZipFile -import boto3 import pandas as pd from metad import MetaData @@ -96,25 +91,3 @@ def get_demo_data(path='datatracer_demo', force=False): demo_path = os.path.join(os.path.dirname(__file__), 'datasets') shutil.copytree(demo_path, path) - - -def download(): - BUCKET_NAME = 'tracer-data' # Bucket where the datasets are stored - DATA_URL = 'http://{}.s3.amazonaws.com/'.format(BUCKET_NAME) - - homedir = os.path.expanduser("~/") - if homedir == "~/": - raise ValueError("Could not find a default download directory") - data_dir = os.path.join(homedir, "tracer_data") - - client = boto3.client('s3') - for dataset in client.list_objects(Bucket=BUCKET_NAME)['Contents']: - dataset_name = dataset['Key'].replace(".zip", "") - dataset_path = os.path.join(data_dir, dataset_name) - if os.path.exists(dataset_path): - print("Skipping %s" % dataset_name) - else: - print("Downloading %s" % dataset_name) - with urlopen(urljoin(DATA_URL, dataset['Key'])) as fp: - with ZipFile(BytesIO(fp.read())) as zipfile: - zipfile.extractall(dataset_path) diff --git a/setup.py b/setup.py index 3b7dd33..b9618a6 100644 --- a/setup.py +++ b/setup.py @@ -63,6 +63,9 @@ # Advanced testing 'coverage>=4.5.1,<6', 'tox>=2.9.1,<4', + + # benchmarking + 'dask>=2.15,<3', ] setup( @@ -85,7 +88,8 @@ 'pipelines=datatracer:MLBLOCKS_PIPELINES' ], 'console_scripts': [ - 'datatracer=datatracer.__main__:main' + 'datatracer=datatracer.__main__:main', + 'datatracer-benchmark=benchmark.benchmark:main' ], }, extras_require={ @@ -105,7 +109,7 @@ setup_requires=setup_requires, test_suite='tests', tests_require=tests_require, - url='https://github.com/HDI-Project/DataTracer', + url='https://github.com/data-dev/DataTracer', version='0.0.5.dev0', zip_safe=False, ) From e79ccb682901a574f594eed095fed4bbf0e21e55 Mon Sep 17 00:00:00 2001 From: Kevin Alex Zhang Date: Wed, 10 Jun 2020 01:08:05 -0400 Subject: [PATCH 05/42] Added data directory option --- benchmark/README.md | 6 +- benchmark/benchmark.gif | Bin 0 -> 119681 bytes benchmark/benchmark.py | 223 ++++++++++++------ datatracer/column_map/base.py | 9 +- datatracer/core.py | 12 +- datatracer/foreign_key/base.py | 9 +- datatracer/foreign_key/standard.py | 15 +- ....foreign_key.StandardForeignKeySolver.json | 2 +- ...cer.primary_key.BasicPrimaryKeySolver.json | 2 +- datatracer/primary_key/base.py | 9 +- datatracer/primary_key/basic.py | 14 +- setup.py | 2 +- 12 files changed, 191 insertions(+), 112 deletions(-) create mode 100644 benchmark/benchmark.gif diff --git a/benchmark/README.md b/benchmark/README.md index e51398a..d8a3bd2 100644 --- a/benchmark/README.md +++ b/benchmark/README.md @@ -3,10 +3,14 @@ This directory contains code for benchmarking the performance of `DataTracer` on user-supplied datasets. The datasets for benchmarking can be found in the `s3://tracer-data` bucket. +

+ +

+ Each benchmark - `primary`, `foreign`, and `column` - can be executed by running the following command -> datatracer-benchmark --output /path/to/results.csv +> datatracer-benchmark --csv /path/to/results.csv which will (optionally) generate a CSV file with the benchmark results. diff --git a/benchmark/benchmark.gif b/benchmark/benchmark.gif new file mode 100644 index 0000000000000000000000000000000000000000..578f0a9225d3b3d5b30650189ab999d2bc4a34b4 GIT binary patch literal 119681 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�OuKjjt zMzk367&>Yp=O@a!Q5;F&5d~&Q2DCdeQt9kymRyQ}Lra;5Yq@9X1-91vl-E_I>cP*S z=*dy@)x_PDwz)mc^6)iPNUc$rjR4@-rPLGJP2f7$Oj(vG5GCm7J*AGJ0=t$JBo_sH zwr8phFIk1Nk6729Payd4>rh3FH*Xr(&k#BVk6-(++wcE^toOeKrMiF9ILP0v!D!8W za#OlbSIohWs=~B*Hgcv3pYc2fY_ib=dxf`;&<2d>X@jIDjlz^HK2#MDB4CRUMWQyG z%1pcSWplw^Sm|GfO03)N8*}%cdiC<9#3$V=-WOW&N2jLtn*27rdUBPPLMdT4d(g`V zw?1eCTv_r3=$4z^i0pjj;&%JNg=46IBsNT+1}cNbUpOEBnEKJk)}snJ&WO-y)NyNi z!{O2sr+!T#?fw{l@jDq&$a-QG8}o8zkbC1q*hg5aE!uC$`+~dxHa)#c8&F%hdrB}# zbL^Z{dCcsQjfSyfGF_I6vhac>_>QA}!?Ihx4Ib_OTr2;JPTsdr&_ex`oVO}fE;9c> zW`2#`=Z+4brKrxPf5ygj&Za&7@j7n`CXjb<85k^8q-d{n$;&Jpe*ANKy<~Oh_@7JX zxBQylF8MzC_|N5AQrp8J$q$kIU8zE7%kRZglAp6%?tXvq`1kT3l3(KEcYn@ok$itC z`MvV^?(c7pCBJ`2uMUYUh%E~i%tA=(bUYTiorM`?VOLlJBdIWft}Eie&3@`bUcf zSBi#+#lyD6&w`7eGmA%g#pCV8uSSbsuM~@jC6l%#Q^6(E%#vAN$$WdshtZNxDK93aimfOyHY|( zDU8ikt|}$VWHC&E%Ppl;QkkY*nRZB-ZhDzsRhdCYnbBC8$!eJ?>4dr63CoZZ*6Al~ zB~>TvJ5FpEJK?Z;f<`KLvMYB9DR)gTcdsh<>?rphEB9F~_a#;M*;NFDR0O711Xopr zc2tCoRfMlrFi4e=c9qc~l`-j+u~n6OI;49||BpZdP+d5>9VWo4yox)uj#9*>F=l+7^`X4mlI6a%-@0xhn((ruX|Zt X0Lrs|?GwHpIdbjodcxmCVEz9AP{%zy literal 0 HcmV?d00001 diff --git a/benchmark/benchmark.py b/benchmark/benchmark.py index 7aa8873..2b869d9 100644 --- a/benchmark/benchmark.py +++ b/benchmark/benchmark.py @@ -9,25 +9,35 @@ import boto3 import dask import pandas as pd - from dask.diagnostics import ProgressBar + from datatracer import DataTracer, load_datasets BUCKET_NAME = 'tracer-data' DATA_URL = 'http://{}.s3.amazonaws.com/'.format(BUCKET_NAME) -DATA_DIR = os.path.expanduser("~/tracer_data") -def download(): - """ - This downloads the benchmark datasets from S3. +def download(data_dir): + """Download benchmark datasets from S3. + + This downloads the benchmark datasets from S3 into the target folder in an + uncompressed format. It skips datasets that have already been downloaded. + + Args: + data_dir: The directory to download the datasets to. + + Returns: + A DataFrame describing the downloaded datasets. + + Raises: + NoCredentialsError: If AWS S3 credentials are not found. """ rows = [] client = boto3.client('s3') for dataset in client.list_objects(Bucket=BUCKET_NAME)['Contents']: rows.append(dataset) dataset_name = dataset['Key'].replace(".zip", "") - dataset_path = os.path.join(DATA_DIR, dataset_name) + dataset_path = os.path.join(data_dir, dataset_name) if os.path.exists(dataset_path): dataset["Status"] = "Skipped" print("Skipping %s" % dataset_name) @@ -42,10 +52,15 @@ def download(): @dask.delayed def primary_key(solver, target, datasets): - """ - solver - the name of the pipeline? - target - a key in dataset - datasets - map from dataset name to (metadata, tables) + """Benchmark the primary key solver on the target dataset. + + Args: + solver: The name of the primary key pipeline. + target: The name of the target dataset. + datases: A dictionary mapping dataset names to (metadata, tables) tuples. + + Returns: + A dictionary mapping metric names to values. """ datasets = datasets.copy() metadata, tables = datasets.pop(target) @@ -61,6 +76,9 @@ def primary_key(solver, target, datasets): continue # Skip tables with composite primary keys y_true[table["name"]] = table["primary_key"] + if len(y_true) == 0: + return {} # Skip dataset, no primary keys found. + correct, total = 0, 0 start = time() y_pred = tracer.solve(tables) @@ -77,8 +95,20 @@ def primary_key(solver, target, datasets): } -def benchmark_primary_key(solver="datatracer.primary_key.basic"): - datasets = load_datasets(DATA_DIR) +def benchmark_primary_key(data_dir, solver="datatracer.primary_key.basic"): + """Benchmark the primary key solver. + + This uses leave-one-out validation and evaluates the performance of the + solver on the specified datasets. + + Args: + data_dir: The directory containing the datasets. + solver: The name of the primary key pipeline. + + Returns: + A DataFrame containing the benchmark resuls. + """ + datasets = load_datasets(data_dir) dataset_names = list(datasets.keys()) datasets = dask.delayed(datasets) dataset_to_metrics = {} @@ -95,10 +125,15 @@ def benchmark_primary_key(solver="datatracer.primary_key.basic"): @dask.delayed def foreign_key(solver, target, datasets): - """ - solver - the name of the pipeline? - target - a key in dataset - datasets - map from dataset name to (metadata, tables) + """Benchmark the foreign key solver on the target dataset. + + Args: + solver: The name of the foreign key pipeline. + target: The name of the target dataset. + datasets: A dictionary mapping dataset names to (metadata, tables) tuples. + + Returns: + A dictionary mapping metric names to values. """ datasets = datasets.copy() metadata, tables = datasets.pop(target) @@ -112,34 +147,49 @@ def foreign_key(solver, target, datasets): continue # Skip composite foreign keys y_true.add((fk["table"], fk["field"], fk["ref_table"], fk["ref_field"])) - try: - start = time() - fk_pred = tracer.solve(tables) - end = time() + start = time() + fk_pred = tracer.solve(tables) + end = time() - y_pred = set() - for fk in fk_pred: - y_pred.add((fk["table"], fk["field"], fk["ref_table"], fk["ref_field"])) - - precision = len(y_true.intersection(y_pred)) / len(y_pred) - recall = len(y_true.intersection(y_pred)) / len(y_true) - f1 = 2.0 * precision * recall / (precision + recall) + y_pred = set() + for fk in fk_pred: + y_pred.add((fk["table"], fk["field"], fk["ref_table"], fk["ref_field"])) + if len(y_pred) == 0 or len(y_true) == 0 or \ + len(y_true.intersection(y_pred)) == 0: return { - "precision": precision, - "recall": recall, - "f1": f1, + "precision": 0.0, + "recall": 0.0, + "f1": 0.0, "inference_time": end - start } - except Exception as e: - return { - "error": str(e) - } + precision = len(y_true.intersection(y_pred)) / len(y_pred) + recall = len(y_true.intersection(y_pred)) / len(y_true) + f1 = 2.0 * precision * recall / (precision + recall) + + return { + "precision": precision, + "recall": recall, + "f1": f1, + "inference_time": end - start + } -def benchmark_foreign_key(solver="datatracer.foreign_key.standard"): - datasets = load_datasets(DATA_DIR) +def benchmark_foreign_key(data_dir, solver="datatracer.foreign_key.standard"): + """Benchmark the foreign key solver. + + This uses leave-one-out validation and evaluates the performance of the + solver on the specified datasets. + + Args: + data_dir: The directory containing the datasets. + solver: The name of the foreign key pipeline. + + Returns: + A DataFrame containing the benchmark resuls. + """ + datasets = load_datasets(data_dir) dataset_names = list(datasets.keys()) datasets = dask.delayed(datasets) dataset_to_metrics = {} @@ -156,57 +206,68 @@ def benchmark_foreign_key(solver="datatracer.foreign_key.standard"): @dask.delayed def column_map(solver, target, datasets): - """ - solver - the name of the pipeline? - target - a key in dataset - datasets - map from dataset name to (metadata, tables) + """Benchmark the column map solver on the target dataset. + + Args: + solver: The name of the column map pipeline. + target: The name of the target dataset. + datases: A dictionary mapping dataset names to (metadata, tables) tuples. + + Returns: + A list of dictionaries mapping metric names to values for each deived column. """ datasets = datasets.copy() metadata, tables = datasets.pop(target) + if not metadata.data.get("constraints"): + return {} # Skip dataset, no constraints found. tracer = DataTracer(solver) tracer.fit(datasets) list_of_metrics = [] for constraint in metadata.data["constraints"]: - try: - field = constraint["fields_under_consideration"][0] - related_fields = constraint["related_fields"] - - y_true = set() - for related_field in related_fields: - y_true.add((related_field["table"], related_field["field"])) - - start = time() - y_pred = tracer.solve(tables, target_table=field["table"], target_field=field["field"]) - y_pred = {field for field, score in y_pred.items() if score > 0.0} - end = time() - - precision = len(y_true.intersection(y_pred)) / len(y_pred) - recall = len(y_true.intersection(y_pred)) / len(y_true) - f1 = 2.0 * precision * recall / (precision + recall) - - list_of_metrics.append({ - "table": field["table"], - "field": field["field"], - "precision": precision, - "recall": recall, - "f1": f1, - "inference_time": end - start - }) - - except Exception as e: - list_of_metrics.append({ - "table": field["table"], - "field": field["field"], - "error": str(e) - }) + field = constraint["fields_under_consideration"][0] + related_fields = constraint["related_fields"] + + y_true = set() + for related_field in related_fields: + y_true.add((related_field["table"], related_field["field"])) + + start = time() + y_pred = tracer.solve(tables, target_table=field["table"], target_field=field["field"]) + y_pred = {field for field, score in y_pred.items() if score > 0.0} + end = time() + + precision = len(y_true.intersection(y_pred)) / len(y_pred) + recall = len(y_true.intersection(y_pred)) / len(y_true) + f1 = 2.0 * precision * recall / (precision + recall) + + list_of_metrics.append({ + "table": field["table"], + "field": field["field"], + "precision": precision, + "recall": recall, + "f1": f1, + "inference_time": end - start + }) return list_of_metrics -def benchmark_column_map(solver="datatracer.column_map.basic"): - datasets = load_datasets(DATA_DIR) +def benchmark_column_map(data_dir, solver="datatracer.column_map.basic"): + """Benchmark the column map solver. + + This uses leave-one-out validation and evaluates the performance of the + solver on the specified datasets. + + Args: + data_dir: The directory containing the datasets. + solver: The name of the column map pipeline. + + Returns: + A DataFrame containing the benchmark resuls. + """ + datasets = load_datasets(data_dir) dataset_names = list(datasets.keys()) datasets = dask.delayed(datasets) dataset_to_metrics = {} @@ -226,7 +287,11 @@ def benchmark_column_map(solver="datatracer.column_map.basic"): def _get_parser(): shared_args = argparse.ArgumentParser(add_help=False) - shared_args.add_argument('-o', '--output', type=str, required=False, help='Path to the CSV file where the report will be written.') + shared_args.add_argument('--data_dir', type=str, + default=os.path.expanduser("~/tracer_data"), required=False, + help='Path to the benchmark datasets.') + shared_args.add_argument('--csv', type=str, required=False, + help='Path to the CSV file where the report will be written.') parser = argparse.ArgumentParser( prog='datatracer-benchmark', @@ -266,13 +331,15 @@ def _get_parser(): return parser + def main(): parser = _get_parser() args = parser.parse_args() - df = args.command() - if args.output: - df.to_csv(args.output, index=False) + df = args.command(args.data_dir) + if args.csv: + df.to_csv(args.csv, index=False) print(df) + if __name__ == "__main__": main() diff --git a/datatracer/column_map/base.py b/datatracer/column_map/base.py index 435bbf3..a4d5104 100644 --- a/datatracer/column_map/base.py +++ b/datatracer/column_map/base.py @@ -4,13 +4,14 @@ class ColumnMapSolver: """Base Solver for the data lineage problem of column dependency.""" - def fit(self, list_of_databases): + def fit(self, dict_of_databases): """Fit this solver. Args: - list_of_databases (list): - List of tuples containing ``MetaData`` instnces and table dictinaries, - which contain table names as input and ``pandas.DataFrames`` as values. + dict_of_databases (dict): + Map from database names to tuples containing ``MetaData`` + instances and table dictionaries, which contain table names + as input and ``pandas.DataFrames`` as values. """ pass diff --git a/datatracer/core.py b/datatracer/core.py index 58c2f7b..82fd1db 100644 --- a/datatracer/core.py +++ b/datatracer/core.py @@ -52,15 +52,13 @@ def fit(self, datasets): """Fit the pipeline to the given data. Args: - datasets (list or dict): - List or dict of tuples containing a MetaData instance and a dict - with the tables of the dataset loaded as DataFrames. + datasets (dict): + Dict mapping dataset names to tuples containing a MetaData + instance and a dict with the tables of the dataset loaded + as DataFrames. """ - if isinstance(datasets, dict): - datasets = list(datasets.values()) - self._mlpipeline = self._get_mlpipeline() - self._mlpipeline.fit(list_of_databases=datasets, tables={}) + self._mlpipeline.fit(dict_of_databases=datasets, tables={}) def solve(self, tables, **kwargs): """Solve the data lineage problem. diff --git a/datatracer/foreign_key/base.py b/datatracer/foreign_key/base.py index d31a96e..4019651 100644 --- a/datatracer/foreign_key/base.py +++ b/datatracer/foreign_key/base.py @@ -3,13 +3,14 @@ class ForeignKeySolver(): - def fit(self, list_of_databases): + def fit(self, dict_of_databases): """Fit this solver. Args: - list_of_databases (list): - List of tuples containing ``MetaData`` instnces and table dictinaries, - which contain table names as input and ``pandas.DataFrames`` as values. + dict_of_databases (dict): + Map from database names to tuples containing ``MetaData`` + instances and table dictionaries, which contain table names + as input and ``pandas.DataFrames`` as values. """ pass diff --git a/datatracer/foreign_key/standard.py b/datatracer/foreign_key/standard.py index 6087fb9..73d8519 100644 --- a/datatracer/foreign_key/standard.py +++ b/datatracer/foreign_key/standard.py @@ -1,8 +1,8 @@ from collections import Counter from itertools import permutations -from sklearn.ensemble import RandomForestClassifier from tqdm import tqdm +from sklearn.ensemble import RandomForestClassifier from datatracer.foreign_key.base import ForeignKeySolver @@ -50,16 +50,19 @@ def _feature_vector(self, parent_col, child_col): 1.0 if child_col.dtype == "object" else 0.0, ] - def fit(self, list_of_databases): + def fit(self, dict_of_databases): """Fit this solver. Args: - list_of_databases (list): - List of tuples containing ``MetaData`` instnces and table dictinaries, - which contain table names as input and ``pandas.DataFrames`` as values. + dict_of_databases (dict): + Map from database names to tuples containing ``MetaData`` + instances and table dictionaries, which contain table names + as input and ``pandas.DataFrames`` as values. """ X, y = [], [] - for metadata, tables in tqdm(list_of_databases, "extracting features"): + iterator = tqdm(dict_of_databases.items()) + for database_name, (metadata, tables) in iterator: + iterator.set_description("Extracting features from %s" % database_name) fks = metadata.get_foreign_keys() fks = set([ (fk["table"], fk["field"], fk["ref_table"], fk["ref_field"]) diff --git a/datatracer/jsons/primitives/datatracer.foreign_key.StandardForeignKeySolver.json b/datatracer/jsons/primitives/datatracer.foreign_key.StandardForeignKeySolver.json index 51d9518..87ae0a7 100644 --- a/datatracer/jsons/primitives/datatracer.foreign_key.StandardForeignKeySolver.json +++ b/datatracer/jsons/primitives/datatracer.foreign_key.StandardForeignKeySolver.json @@ -6,7 +6,7 @@ "method": "fit", "args": [ { - "name": "list_of_databases", + "name": "dict_of_databases", "type": "list" } ] diff --git a/datatracer/jsons/primitives/datatracer.primary_key.BasicPrimaryKeySolver.json b/datatracer/jsons/primitives/datatracer.primary_key.BasicPrimaryKeySolver.json index ff81725..c387f9f 100644 --- a/datatracer/jsons/primitives/datatracer.primary_key.BasicPrimaryKeySolver.json +++ b/datatracer/jsons/primitives/datatracer.primary_key.BasicPrimaryKeySolver.json @@ -6,7 +6,7 @@ "method": "fit", "args": [ { - "name": "list_of_databases", + "name": "dict_of_databases", "type": "list" } ] diff --git a/datatracer/primary_key/base.py b/datatracer/primary_key/base.py index f033f8f..d03c359 100644 --- a/datatracer/primary_key/base.py +++ b/datatracer/primary_key/base.py @@ -3,13 +3,14 @@ class PrimaryKeySolver(): - def fit(self, list_of_databases): + def fit(self, dict_of_databases): """Fit this solver. Args: - list_of_databases (list): - List of tuples containing ``MetaData`` instnces and table dictinaries, - which contain table names as input and ``pandas.DataFrames`` as values. + dict_of_databases (dict): + Map from database names to tuples containing ``MetaData`` + instances and table dictionaries, which contain table names + as input and ``pandas.DataFrames`` as values. """ pass diff --git a/datatracer/primary_key/basic.py b/datatracer/primary_key/basic.py index 9280cd6..be9064f 100644 --- a/datatracer/primary_key/basic.py +++ b/datatracer/primary_key/basic.py @@ -1,6 +1,7 @@ """Basic Primary Key Solver module.""" import numpy as np +from tqdm import tqdm from sklearn.ensemble import RandomForestClassifier from datatracer.primary_key.base import PrimaryKeySolver @@ -27,16 +28,19 @@ def _feature_vector(self, table, column_name): 1.0 if column.dtype == "object" else 0.0, ] - def fit(self, list_of_databases): + def fit(self, dict_of_databases): """Fit this solver. Args: - list_of_databases (list): - List of tuples containing ``MetaData`` instnces and table dictinaries, - which contain table names as input and ``pandas.DataFrames`` as values. + dict_of_databases (dict): + Map from database names to tuples containing ``MetaData`` + instances and table dictionaries, which contain table names + as input and ``pandas.DataFrames`` as values. """ X, y = [], [] - for metadata, tables in list_of_databases: + iterator = tqdm(dict_of_databases.items()) + for database_name, (metadata, tables) in iterator: + iterator.set_description("Extracting features from %s" % database_name) for table in metadata.get_tables(): if "primary_key" not in table: continue diff --git a/setup.py b/setup.py index b9618a6..1331ef5 100644 --- a/setup.py +++ b/setup.py @@ -105,7 +105,7 @@ keywords='datatracer data-tracer Data Tracer', name='datatracer', packages=find_packages(include=['datatracer', 'datatracer.*']), - python_requires='>=3.5,<=3.8', + python_requires='>=3.5', setup_requires=setup_requires, test_suite='tests', tests_require=tests_require, From 5a251dedec1a5eabaecc45a2161a8b48f294f259 Mon Sep 17 00:00:00 2001 From: Kevin Alex Zhang Date: Thu, 18 Jun 2020 21:18:36 -0400 Subject: [PATCH 06/42] Addressed feedback --- datatracer/column_map/base.py | 5 +- datatracer/core.py | 4 +- datatracer/foreign_key/base.py | 5 +- datatracer/foreign_key/standard.py | 2 +- datatracer/primary_key/base.py | 5 +- datatracer/primary_key/basic.py | 6 +- setup.py | 1 + tutorials/DataTracer Quickstart.ipynb | 83 +++++++++++++------------- tutorials/Introducing DataTracer.ipynb | 14 ++--- 9 files changed, 62 insertions(+), 63 deletions(-) diff --git a/datatracer/column_map/base.py b/datatracer/column_map/base.py index a4d5104..a257ce5 100644 --- a/datatracer/column_map/base.py +++ b/datatracer/column_map/base.py @@ -9,11 +9,10 @@ def fit(self, dict_of_databases): Args: dict_of_databases (dict): - Map from database names to tuples containing ``MetaData`` - instances and table dictionaries, which contain table names + Map from database names to tuples containing ``MetaData`` + instances and table dictionaries, which contain table names as input and ``pandas.DataFrames`` as values. """ - pass def solve(self, tables, foreign_keys, target_table, target_field): """Find the fields which contributed to the target_field the most. diff --git a/datatracer/core.py b/datatracer/core.py index 82fd1db..fcc4104 100644 --- a/datatracer/core.py +++ b/datatracer/core.py @@ -53,8 +53,8 @@ def fit(self, datasets): Args: datasets (dict): - Dict mapping dataset names to tuples containing a MetaData - instance and a dict with the tables of the dataset loaded + Dict mapping dataset names to tuples containing a MetaData + instance and a dict with the tables of the dataset loaded as DataFrames. """ self._mlpipeline = self._get_mlpipeline() diff --git a/datatracer/foreign_key/base.py b/datatracer/foreign_key/base.py index 4019651..82a336e 100644 --- a/datatracer/foreign_key/base.py +++ b/datatracer/foreign_key/base.py @@ -8,11 +8,10 @@ def fit(self, dict_of_databases): Args: dict_of_databases (dict): - Map from database names to tuples containing ``MetaData`` - instances and table dictionaries, which contain table names + Map from database names to tuples containing ``MetaData`` + instances and table dictionaries, which contain table names as input and ``pandas.DataFrames`` as values. """ - pass def solve(self, tables, primary_keys=None): """Solve the foreign key detection problem. diff --git a/datatracer/foreign_key/standard.py b/datatracer/foreign_key/standard.py index 2ae949c..adb5690 100644 --- a/datatracer/foreign_key/standard.py +++ b/datatracer/foreign_key/standard.py @@ -1,8 +1,8 @@ from collections import Counter from itertools import permutations -from tqdm import tqdm from sklearn.ensemble import RandomForestClassifier +from tqdm import tqdm from datatracer.foreign_key.base import ForeignKeySolver diff --git a/datatracer/primary_key/base.py b/datatracer/primary_key/base.py index d03c359..df316d2 100644 --- a/datatracer/primary_key/base.py +++ b/datatracer/primary_key/base.py @@ -8,11 +8,10 @@ def fit(self, dict_of_databases): Args: dict_of_databases (dict): - Map from database names to tuples containing ``MetaData`` - instances and table dictionaries, which contain table names + Map from database names to tuples containing ``MetaData`` + instances and table dictionaries, which contain table names as input and ``pandas.DataFrames`` as values. """ - pass def solve(self, tables): """Solve the primary key detection problem. diff --git a/datatracer/primary_key/basic.py b/datatracer/primary_key/basic.py index d7c14a7..b0c657a 100644 --- a/datatracer/primary_key/basic.py +++ b/datatracer/primary_key/basic.py @@ -1,8 +1,8 @@ """Basic Primary Key Solver module.""" import numpy as np -from tqdm import tqdm from sklearn.ensemble import RandomForestClassifier +from tqdm import tqdm from datatracer.primary_key.base import PrimaryKeySolver @@ -33,8 +33,8 @@ def fit(self, dict_of_databases): Args: dict_of_databases (dict): - Map from database names to tuples containing ``MetaData`` - instances and table dictionaries, which contain table names + Map from database names to tuples containing ``MetaData`` + instances and table dictionaries, which contain table names as input and ``pandas.DataFrames`` as values. """ X, y = [], [] diff --git a/setup.py b/setup.py index 2014830..9385f01 100644 --- a/setup.py +++ b/setup.py @@ -66,6 +66,7 @@ # benchmarking 'dask>=2.15,<3', + 'distributed>=2.15,<3', ] setup( diff --git a/tutorials/DataTracer Quickstart.ipynb b/tutorials/DataTracer Quickstart.ipynb index 26a1840..fb5037f 100644 --- a/tutorials/DataTracer Quickstart.ipynb +++ b/tutorials/DataTracer Quickstart.ipynb @@ -33,7 +33,9 @@ { "cell_type": "code", "execution_count": 1, - "metadata": {}, + "metadata": { + "tags": [] + }, "outputs": [ { "name": "stdout", @@ -83,7 +85,7 @@ { "data": { "text/plain": [ - "dict_keys(['mutagenesis', 'Chess', 'classicmodels', 'university', 'Bupa', 'trains', 'SameGen', 'NBA', 'pubs'])" + "dict_keys(['posts', 'NBA', 'university', 'pubs', 'Chess', 'classicmodels', 'mutagenesis', 'Bupa', 'trains', 'SameGen'])" ] }, "execution_count": 3, @@ -395,15 +397,6 @@ "the dataset that we just explored, using the rest of the datasets as our training data." ] }, - { - "cell_type": "code", - "execution_count": 10, - "metadata": {}, - "outputs": [], - "source": [ - "training_datasets = list(datasets.values())" - ] - }, { "cell_type": "markdown", "metadata": {}, @@ -423,7 +416,7 @@ }, { "cell_type": "code", - "execution_count": 11, + "execution_count": 10, "metadata": {}, "outputs": [ { @@ -435,7 +428,7 @@ " 'datatracer.primary_key.basic']" ] }, - "execution_count": 11, + "execution_count": 10, "metadata": {}, "output_type": "execute_result" } @@ -467,7 +460,7 @@ }, { "cell_type": "code", - "execution_count": 12, + "execution_count": 11, "metadata": {}, "outputs": [], "source": [ @@ -486,11 +479,19 @@ }, { "cell_type": "code", - "execution_count": 13, + "execution_count": 12, "metadata": {}, - "outputs": [], + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "Extracting features from SameGen: 100%|██████████| 9/9 [00:00<00:00, 145.54it/s]\n" + ] + } + ], "source": [ - "dtr.fit(training_datasets)" + "dtr.fit(datasets)" ] }, { @@ -503,7 +504,7 @@ }, { "cell_type": "code", - "execution_count": 14, + "execution_count": 13, "metadata": {}, "outputs": [], "source": [ @@ -519,7 +520,7 @@ }, { "cell_type": "code", - "execution_count": 15, + "execution_count": 14, "metadata": {}, "outputs": [ { @@ -535,7 +536,7 @@ " 'products': 'productCode'}" ] }, - "execution_count": 15, + "execution_count": 14, "metadata": {}, "output_type": "execute_result" } @@ -557,14 +558,14 @@ }, { "cell_type": "code", - "execution_count": 16, + "execution_count": 15, "metadata": {}, "outputs": [ { "name": "stderr", "output_type": "stream", "text": [ - "extracting features: 100%|██████████| 8/8 [00:00<00:00, 19.07it/s]\n" + "Extracting features from SameGen: 100%|██████████| 9/9 [00:01<00:00, 8.46it/s] \n" ] } ], @@ -572,7 +573,7 @@ "from datatracer import DataTracer\n", "\n", "dtr = DataTracer('datatracer.foreign_key.standard')\n", - "dtr.fit(training_datasets)\n", + "dtr.fit(datasets)\n", "foreign_keys = dtr.solve(tables)" ] }, @@ -586,7 +587,7 @@ }, { "cell_type": "code", - "execution_count": 17, + "execution_count": 16, "metadata": {}, "outputs": [ { @@ -618,7 +619,7 @@ " 'ref_field': 'officeCode'}]" ] }, - "execution_count": 17, + "execution_count": 16, "metadata": {}, "output_type": "execute_result" } @@ -649,14 +650,14 @@ }, { "cell_type": "code", - "execution_count": 18, + "execution_count": 17, "metadata": {}, "outputs": [ { "name": "stderr", "output_type": "stream", "text": [ - "extracting features: 100%|██████████| 8/8 [00:00<00:00, 18.00it/s]\n" + "Extracting features from SameGen: 100%|██████████| 9/9 [00:01<00:00, 8.10it/s] \n" ] } ], @@ -664,7 +665,7 @@ "from datatracer import DataTracer\n", "\n", "dtr = DataTracer('datatracer.column_map.basic')\n", - "dtr.fit(training_datasets)\n", + "dtr.fit(datasets)\n", "column_map = dtr.solve(\n", " tables,\n", " target_table='orderdetails',\n", @@ -682,18 +683,18 @@ }, { "cell_type": "code", - "execution_count": 19, + "execution_count": 18, "metadata": {}, "outputs": [ { "data": { "text/plain": [ - "{('orderdetails', 'orderNumber'): 0.3854540808399761,\n", - " ('orderdetails', 'priceEach'): 0.43313416486749556,\n", - " ('orderdetails', 'orderLineNumber'): 0.18141175429252837}" + "{('orderdetails', 'orderNumber'): 0.3831749750863929,\n", + " ('orderdetails', 'priceEach'): 0.4397562935716433,\n", + " ('orderdetails', 'orderLineNumber'): 0.1770687313419638}" ] }, - "execution_count": 19, + "execution_count": 18, "metadata": {}, "output_type": "execute_result" } @@ -719,7 +720,7 @@ }, { "cell_type": "code", - "execution_count": 20, + "execution_count": 19, "metadata": {}, "outputs": [], "source": [ @@ -735,7 +736,7 @@ }, { "cell_type": "code", - "execution_count": 21, + "execution_count": 20, "metadata": {}, "outputs": [], "source": [ @@ -748,19 +749,19 @@ }, { "cell_type": "code", - "execution_count": 22, + "execution_count": 21, "metadata": {}, "outputs": [ { "data": { "text/plain": [ - "{('orderdetails', 'orderNumber'): 0.00022471076974350297,\n", - " ('orderdetails', 'priceEach'): 0.00015177917546761553,\n", - " ('orderdetails', 'orderLineNumber'): 0.00014925873321498974,\n", - " ('orderdetails', 'quantityOrdered_x2'): 0.9994742513215739}" + "{('orderdetails', 'orderNumber'): 0.00019290156505023432,\n", + " ('orderdetails', 'priceEach'): 0.00014624354192835704,\n", + " ('orderdetails', 'orderLineNumber'): 9.105599151739842e-05,\n", + " ('orderdetails', 'quantityOrdered_x2'): 0.9995697989015039}" ] }, - "execution_count": 22, + "execution_count": 21, "metadata": {}, "output_type": "execute_result" } diff --git a/tutorials/Introducing DataTracer.ipynb b/tutorials/Introducing DataTracer.ipynb index 9555b19..2598548 100644 --- a/tutorials/Introducing DataTracer.ipynb +++ b/tutorials/Introducing DataTracer.ipynb @@ -338,12 +338,12 @@ { "data": { "text/plain": [ - "{('users', 'id'): 0.0,\n", - " ('users', 'birthyear'): 0.9999980097244106,\n", - " ('users', 'height'): 5.180246597799326e-07,\n", + "{('users', 'id'): 5.046102180418218e-07,\n", + " ('users', 'birthyear'): 0.9999978458447893,\n", + " ('users', 'height'): 0.0,\n", " ('users', 'nb_posts'): 0.0,\n", - " ('posts', 'uid'): 6.40004263964262e-07,\n", - " ('posts', 'id'): 2.8343526963126543e-07}" + " ('posts', 'uid'): 0.0,\n", + " ('posts', 'id'): 7.225373384175289e-07}" ] }, "execution_count": 7, @@ -383,8 +383,8 @@ " ('users', 'age'): 0.0,\n", " ('users', 'birthyear'): 0.0,\n", " ('users', 'height'): 0.0,\n", - " ('posts', 'uid'): 0.4785790560832036,\n", - " ('posts', 'id'): 0.5136442420361127}" + " ('posts', 'uid'): 0.5263822157542845,\n", + " ('posts', 'id'): 0.46999603701224835}" ] }, "execution_count": 8, From af41462a19faba6204e7d7c9bb1c080ba0eee80f Mon Sep 17 00:00:00 2001 From: Zhuofan Xie Date: Sun, 7 Mar 2021 21:49:11 +0800 Subject: [PATCH 07/42] Updated test output layouts --- benchmark/benchmark.py | 38 ++++++++++++++++++++++++++++++-------- 1 file changed, 30 insertions(+), 8 deletions(-) diff --git a/benchmark/benchmark.py b/benchmark/benchmark.py index 2b869d9..00f6a65 100644 --- a/benchmark/benchmark.py +++ b/benchmark/benchmark.py @@ -1,7 +1,7 @@ import argparse import os from io import BytesIO -from time import time +from time import ctime, time from urllib.parse import urljoin from urllib.request import urlopen from zipfile import ZipFile @@ -120,7 +120,10 @@ def benchmark_primary_key(data_dir, solver="datatracer.primary_key.basic"): results = dask.compute(dataset_to_metrics)[0] for dataset_name, metrics in results.items(): metrics["dataset"] = dataset_name - return pd.DataFrame(list(results.values())) + df = pd.DataFrame(list(results.values())) + dataset_col = df.pop('dataset') + df.insert(0, 'dataset', dataset_col) + return df @dask.delayed @@ -201,7 +204,10 @@ def benchmark_foreign_key(data_dir, solver="datatracer.foreign_key.standard"): results = dask.compute(dataset_to_metrics)[0] for dataset_name, metrics in results.items(): metrics["dataset"] = dataset_name - return pd.DataFrame(list(results.values())) + df = pd.DataFrame(list(results.values())) + dataset_col = df.pop('dataset') + df.insert(0, 'dataset', dataset_col) + return df @dask.delayed @@ -235,11 +241,18 @@ def column_map(solver, target, datasets): start = time() y_pred = tracer.solve(tables, target_table=field["table"], target_field=field["field"]) - y_pred = {field for field, score in y_pred.items() if score > 0.0} end = time() - precision = len(y_true.intersection(y_pred)) / len(y_pred) - recall = len(y_true.intersection(y_pred)) / len(y_true) + y_pred_total = 0 + intersection_total = 0 + + for field_temp, score in y_pred.items(): + if field_temp in y_true: + intersection_total += max(0, min(1, score)) + y_pred_total += max(0, min(1, score)) + + precision = intersection_total / y_pred_total + recall = intersection_total / len(y_true) f1 = 2.0 * precision * recall / (precision + recall) list_of_metrics.append({ @@ -282,7 +295,14 @@ def benchmark_column_map(data_dir, solver="datatracer.column_map.basic"): for metrics in list_of_metrics: metrics["dataset"] = dataset_name rows.append(metrics) - return pd.DataFrame(rows) + df = pd.DataFrame(rows) + dataset_col = df.pop('dataset') + table_col = df.pop('table') + field_col = df.pop('field') + df.insert(0, 'field', field_col) + df.insert(0, 'table', table_col) + df.insert(0, 'dataset', dataset_col) + return df def _get_parser(): @@ -290,7 +310,9 @@ def _get_parser(): shared_args.add_argument('--data_dir', type=str, default=os.path.expanduser("~/tracer_data"), required=False, help='Path to the benchmark datasets.') - shared_args.add_argument('--csv', type=str, required=False, + default_csv = "report_" + ctime().replace(" ", "_") + ".csv" + shared_args.add_argument('--csv', type=str, + default=os.path.expanduser(default_csv), required=False, help='Path to the CSV file where the report will be written.') parser = argparse.ArgumentParser( From bc21be4391f83aaae8ff507a561856a50a3ef21f Mon Sep 17 00:00:00 2001 From: Zhuofan Xie Date: Sun, 7 Mar 2021 23:26:37 +0800 Subject: [PATCH 08/42] add script for downloading datasets --- download.py | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 download.py diff --git a/download.py b/download.py new file mode 100644 index 0000000..4f7b429 --- /dev/null +++ b/download.py @@ -0,0 +1,2 @@ +from benchmark.benchmark import download +download('benchmark/tracer_data') \ No newline at end of file From 5b619282f41450511c0cf975818659fcb119db8a Mon Sep 17 00:00:00 2001 From: Zhuofan Xie Date: Sun, 7 Mar 2021 23:51:47 +0800 Subject: [PATCH 09/42] skip non-zip files in the s3 database --- benchmark/benchmark.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/benchmark/benchmark.py b/benchmark/benchmark.py index 00f6a65..0ef87fd 100644 --- a/benchmark/benchmark.py +++ b/benchmark/benchmark.py @@ -35,6 +35,8 @@ def download(data_dir): rows = [] client = boto3.client('s3') for dataset in client.list_objects(Bucket=BUCKET_NAME)['Contents']: + if not '.zip' in dataset['Key']: + continue rows.append(dataset) dataset_name = dataset['Key'].replace(".zip", "") dataset_path = os.path.join(data_dir, dataset_name) From f0b841e65a05bce92e26277156dc6b7662b8b9c4 Mon Sep 17 00:00:00 2001 From: Zhuofan Xie Date: Mon, 8 Mar 2021 14:38:05 +0800 Subject: [PATCH 10/42] temporarily modified core.py for testing w/o installation --- datatracer/core.py | 26 ++++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/datatracer/core.py b/datatracer/core.py index fcc4104..3a0d3d5 100644 --- a/datatracer/core.py +++ b/datatracer/core.py @@ -12,7 +12,8 @@ from mlblocks import MLPipeline PRETRAINED_DIR = os.path.join(os.path.dirname(__file__), 'pretrained') - +PIPELINE_DIR = os.path.join(os.path.dirname(__file__), 'jsons/pipelines') +PRIMITIVE_DIR = os.path.join(os.path.dirname(__file__), 'jsons/primitives') class DataTracer: """DataTracer Class. @@ -33,9 +34,26 @@ class DataTracer: def _get_mlpipeline(self): pipeline = self._pipeline - if isinstance(pipeline, str) and os.path.isfile(pipeline): - with open(pipeline) as json_file: - pipeline = json.load(json_file) + if isinstance(pipeline, str): + if os.path.isfile(pipeline): + with open(pipeline) as json_file: + pipeline = json.load(json_file) + elif os.path.isfile(os.path.join(PIPELINE_DIR, pipeline + '.json')): + with open(os.path.join(PIPELINE_DIR, pipeline + '.json')) as json_file: + pipeline = json.load(json_file) + + if isinstance(pipeline, dict): + if 'primitives' in pipeline: + for idx in range(len(pipeline['primitives'])): + primitive = pipeline['primitives'][idx] + if isinstance(primitive, str): + if os.path.isfile(primitive): + with open(primitive) as json_file: + primitive = json.load(json_file) + elif os.path.isfile(os.path.join(PRIMITIVE_DIR, primitive + '.json')): + with open(os.path.join(PRIMITIVE_DIR, primitive + '.json')) as json_file: + primitive = json.load(json_file) + pipeline['primitives'][idx] = primitive mlpipeline = MLPipeline(pipeline) if self._hyperparameters: From f3a79c0fa717d9b726f52ad4a64f55b1647e98b7 Mon Sep 17 00:00:00 2001 From: Zhuofan Xie Date: Sun, 21 Mar 2021 22:07:04 +0800 Subject: [PATCH 11/42] Modified benchmark output formats --- benchmark/benchmark.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/benchmark/benchmark.py b/benchmark/benchmark.py index 0ef87fd..1dbb21c 100644 --- a/benchmark/benchmark.py +++ b/benchmark/benchmark.py @@ -1,5 +1,8 @@ import argparse import os +import queue +import threading +import time from io import BytesIO from time import ctime, time from urllib.parse import urljoin @@ -360,8 +363,12 @@ def main(): parser = _get_parser() args = parser.parse_args() df = args.command(args.data_dir) - if args.csv: - df.to_csv(args.csv, index=False) + cmd_str = { benchmark_column_map: 'ColMap_', + benchmark_foreign_key: 'ForeignKey_', + benchmark_primary_key: 'PrimaryKey_' + } + if args.csv and args.command in cmd_str: + df.to_csv("Reports/" + cmd_str[args.command] + args.csv, index=False) print(df) From 38a065171f5d3995fdc25f409d2e9e8425955eaa Mon Sep 17 00:00:00 2001 From: Zhuofan Xie Date: Mon, 22 Mar 2021 07:53:04 +0800 Subject: [PATCH 12/42] Fixed div by zero error in PKD --- benchmark/benchmark.py | 9 +++++++-- datatracer/primary_key/basic.py | 4 ++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/benchmark/benchmark.py b/benchmark/benchmark.py index 1dbb21c..498e4fa 100644 --- a/benchmark/benchmark.py +++ b/benchmark/benchmark.py @@ -256,8 +256,13 @@ def column_map(solver, target, datasets): intersection_total += max(0, min(1, score)) y_pred_total += max(0, min(1, score)) - precision = intersection_total / y_pred_total - recall = intersection_total / len(y_true) + #precision = intersection_total / y_pred_total + #recall = intersection_total / len(y_true) + #f1 = 2.0 * precision * recall / (precision + recall) + + y_pred = {field for field, score in y_pred.items() if score > 0.0} + precision = len(y_true.intersection(y_pred)) / len(y_pred) + recall = len(y_true.intersection(y_pred)) / len(y_true) f1 = 2.0 * precision * recall / (precision + recall) list_of_metrics.append({ diff --git a/datatracer/primary_key/basic.py b/datatracer/primary_key/basic.py index b0c657a..e0bc93a 100644 --- a/datatracer/primary_key/basic.py +++ b/datatracer/primary_key/basic.py @@ -17,9 +17,9 @@ def _feature_vector(self, table, column_name): column = table[column_name] return [ list(table.columns).index(column_name), - list(table.columns).index(column_name) / len(table.columns), + 0.0 if len(table.columns) == 0 else list(table.columns).index(column_name) / len(table.columns), 1.0 if column.nunique() == len(column) else 0.0, - column.nunique() / len(column), + 0.0 if len(column) == 0 else column.nunique() / len(column), 1.0 if "key" in column.name else 0.0, 1.0 if "id" in column.name else 0.0, 1.0 if "_key" in column.name else 0.0, From 14d60badf2bc20506b44cb5054f0c1278f287782 Mon Sep 17 00:00:00 2001 From: Zhuofan Xie Date: Tue, 23 Mar 2021 09:06:54 +0800 Subject: [PATCH 13/42] Fixed some div by zero bugs --- benchmark/benchmark.py | 14 +++++++------- datatracer/foreign_key/standard.py | 12 ++++++++---- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/benchmark/benchmark.py b/benchmark/benchmark.py index 498e4fa..21d5794 100644 --- a/benchmark/benchmark.py +++ b/benchmark/benchmark.py @@ -172,9 +172,9 @@ def foreign_key(solver, target, datasets): "inference_time": end - start } - precision = len(y_true.intersection(y_pred)) / len(y_pred) - recall = len(y_true.intersection(y_pred)) / len(y_true) - f1 = 2.0 * precision * recall / (precision + recall) + precision = 0.0 if len(y_pred) == 0 else len(y_true.intersection(y_pred)) / len(y_pred) + recall = 0.0 if len(y_true) == 0 else len(y_true.intersection(y_pred)) / len(y_true) + f1 = 0.0 if precision + recall == 0 else 2.0 * precision * recall / (precision + recall) return { "precision": precision, @@ -259,11 +259,11 @@ def column_map(solver, target, datasets): #precision = intersection_total / y_pred_total #recall = intersection_total / len(y_true) #f1 = 2.0 * precision * recall / (precision + recall) - + y_pred = {field for field, score in y_pred.items() if score > 0.0} - precision = len(y_true.intersection(y_pred)) / len(y_pred) - recall = len(y_true.intersection(y_pred)) / len(y_true) - f1 = 2.0 * precision * recall / (precision + recall) + precision = 0.0 if len(y_pred) == 0 else len(y_true.intersection(y_pred)) / len(y_pred) + recall = 0.0 if len(y_true) == 0 else len(y_true.intersection(y_pred)) / len(y_true) + f1 = 0.0 if precision + recall == 0 else 2.0 * precision * recall / (precision + recall) list_of_metrics.append({ "table": field["table"], diff --git a/datatracer/foreign_key/standard.py b/datatracer/foreign_key/standard.py index adb5690..daeab56 100644 --- a/datatracer/foreign_key/standard.py +++ b/datatracer/foreign_key/standard.py @@ -64,10 +64,14 @@ def fit(self, dict_of_databases): for database_name, (metadata, tables) in iterator: iterator.set_description("Extracting features from %s" % database_name) fks = metadata.get_foreign_keys() - fks = set([ - (fk["table"], fk["field"], fk["ref_table"], fk["ref_field"]) - for fk in fks - ]) + fks_new = [] + for fk in fks: + if isinstance(fk["field"], list): + for field, ref_field in zip(fk["field"], fk["ref_field"]): + fks_new.append((fk["table"], field, fk["ref_table"], ref_field)) + else: + fks_new.append((fk["table"], fk["field"], fk["ref_table"], fk["ref_field"])) + fks = set(fks_new) for t1, t2 in permutations(tables.keys(), r=2): for c1 in tables[t1].columns: From 6e1ff75e6bfcee58abb83662f5a1db9616a29652 Mon Sep 17 00:00:00 2001 From: Zhuofan Xie Date: Tue, 23 Mar 2021 12:21:20 +0800 Subject: [PATCH 14/42] Modified output filename --- benchmark/benchmark.py | 1 + 1 file changed, 1 insertion(+) diff --git a/benchmark/benchmark.py b/benchmark/benchmark.py index 21d5794..85262de 100644 --- a/benchmark/benchmark.py +++ b/benchmark/benchmark.py @@ -321,6 +321,7 @@ def _get_parser(): default=os.path.expanduser("~/tracer_data"), required=False, help='Path to the benchmark datasets.') default_csv = "report_" + ctime().replace(" ", "_") + ".csv" + default_csv = default_csv.replace("/", "_") shared_args.add_argument('--csv', type=str, default=os.path.expanduser(default_csv), required=False, help='Path to the CSV file where the report will be written.') From a7cf6c9ae2b8ebd6807c647ac19e8f2b9363d031 Mon Sep 17 00:00:00 2001 From: Zhuofan Xie Date: Mon, 12 Apr 2021 06:02:36 +0800 Subject: [PATCH 15/42] changed maximum size to 500mb --- benchmark/benchmark.py | 47 +++++++++++---- datatracer/__init__.py | 2 + datatracer/data_sampler.py | 118 +++++++++++++++++++++++++++++++++++++ 3 files changed, 155 insertions(+), 12 deletions(-) create mode 100644 datatracer/data_sampler.py diff --git a/benchmark/benchmark.py b/benchmark/benchmark.py index 85262de..1fceb7b 100644 --- a/benchmark/benchmark.py +++ b/benchmark/benchmark.py @@ -14,7 +14,7 @@ import pandas as pd from dask.diagnostics import ProgressBar -from datatracer import DataTracer, load_datasets +from datatracer import DataTracer, load_datasets, sample_datasets BUCKET_NAME = 'tracer-data' DATA_URL = 'http://{}.s3.amazonaws.com/'.format(BUCKET_NAME) @@ -76,26 +76,47 @@ def primary_key(solver, target, datasets): y_true = {} for table in metadata.get_tables(): if "primary_key" not in table: - continue # Skip tables without primary keys - if not isinstance(table["primary_key"], str): - continue # Skip tables with composite primary keys - y_true[table["name"]] = table["primary_key"] + y_true[table["name"]] = set() + elif not isinstance(table["primary_key"], str): + y_true[table["name"]] = set(table["primary_key"]) + else: + y_true[table["name"]] = set([table["primary_key"]]) + """ if len(y_true) == 0: return {} # Skip dataset, no primary keys found. + """ - correct, total = 0, 0 + correct, total_pred, total_true = 0, 0, 0 start = time() y_pred = tracer.solve(tables) end = time() for table_name, primary_key in y_true.items(): - if y_pred.get(table_name) == primary_key: - correct += 1 - total += 1 - accuracy = correct / total + ans = y_pred.get(table_name) + if isinstance(ans, str): + ans = set([ans]) + else: + ans = set(ans) + correct += len(ans.intersection(primary_key)) + total_pred += len(ans) + total_true += len(primary_key) + + if correct == 0 or total_pred == 0 or \ + total_true == 0: + return { + "precision": 0.0, + "recall": 0.0, + "f1": 0.0, + "inference_time": end - start + } + precision = correct / total_pred + recall = correct / total_true + f1 = 2 * precision * recall / (precision + recall) return { - "accuracy": accuracy, + "precision": precision, + "recall": recall, + "f1": f1, "inference_time": end - start } @@ -198,6 +219,7 @@ def benchmark_foreign_key(data_dir, solver="datatracer.foreign_key.standard"): A DataFrame containing the benchmark resuls. """ datasets = load_datasets(data_dir) + datasets = sample_datasets(datasets, max_size=1000) dataset_names = list(datasets.keys()) datasets = dask.delayed(datasets) dataset_to_metrics = {} @@ -291,6 +313,7 @@ def benchmark_column_map(data_dir, solver="datatracer.column_map.basic"): A DataFrame containing the benchmark resuls. """ datasets = load_datasets(data_dir) + datasets = sample_datasets(datasets, max_size=1000) dataset_names = list(datasets.keys()) datasets = dask.delayed(datasets) dataset_to_metrics = {} @@ -321,7 +344,7 @@ def _get_parser(): default=os.path.expanduser("~/tracer_data"), required=False, help='Path to the benchmark datasets.') default_csv = "report_" + ctime().replace(" ", "_") + ".csv" - default_csv = default_csv.replace("/", "_") + default_csv = default_csv.replace(":", "_") shared_args.add_argument('--csv', type=str, default=os.path.expanduser(default_csv), required=False, help='Path to the CSV file where the report will be written.') diff --git a/datatracer/__init__.py b/datatracer/__init__.py index 81762dc..55588ac 100644 --- a/datatracer/__init__.py +++ b/datatracer/__init__.py @@ -12,6 +12,7 @@ from datatracer.core import PRETRAINED_DIR, DataTracer from datatracer.data import get_demo_data, load_dataset, load_datasets +from datatracer.data_sampler import sample_datasets _BASE_PATH = os.path.abspath(os.path.dirname(__file__)) _JSONS_PATH = os.path.join(_BASE_PATH, 'jsons') @@ -26,6 +27,7 @@ 'get_primitives', 'load_dataset', 'load_datasets', + 'sample_datasets', ) diff --git a/datatracer/data_sampler.py b/datatracer/data_sampler.py new file mode 100644 index 0000000..7995490 --- /dev/null +++ b/datatracer/data_sampler.py @@ -0,0 +1,118 @@ +# -*- coding: utf-8 -*- + +"""DataTracer core module. + +This module introduces tools for sampling from databases while respecting the row lineage. +""" +import sys + +import random + +from tqdm import tqdm + +def calculate_size(transformed_dataset): + size = 0 + for table in transformed_dataset.values(): + size += table['row_size'] * len(table['chosen']) + return size + +def transform_dataset(metadata, dataset): + fks = metadata.get_foreign_keys() + transformed_fk = {} + for fk in fks: + table, all_field, ref_table, all_ref_field = fk["table"], fk["field"], fk["ref_table"], fk["ref_field"] + if isinstance(all_field, str): + all_field = [all_field] + all_ref_field = [all_ref_field] + for field, ref_field in zip(all_field, all_ref_field): + if ref_table not in transformed_fk: + transformed_fk[ref_table] = [] + transformed_fk[ref_table].append((ref_table, ref_field, table, field)) + transformed_dataset = {} + size = 0 + for table_name in dataset: + table = dataset[table_name] + columns = list(table.columns) + transformed_table = {'size': table.memory_usage().sum(), + 'row_size': float(table.memory_usage().sum()) / len(table), + 'entries': {col: {} for col in columns}, + 'chosen': set(range(len(table))),} + for idx in range(len(table)): + for col in columns: + val = table.iloc[idx][col] + if val not in transformed_table['entries'][col]: + transformed_table['entries'][col][val] = [] + transformed_table['entries'][col][val].append(idx) + transformed_dataset[table_name] = transformed_table + size += transformed_table['size'] + return transformed_fk, transformed_dataset, size + +def backward_transform(transformed_dataset, dataset): + new_dataset = {} + for table_name in dataset: + idxes = list(transformed_dataset[table_name]['chosen']) + new_dataset[table_name] = dataset[table_name].iloc[idxes] + return new_dataset + +def remove_row(dataset, transformed_fk, transformed_dataset, table_name, idx): + if idx in transformed_dataset[table_name]['chosen']: + transformed_dataset[table_name]['chosen'].remove(idx) + if len(transformed_dataset[table_name]['chosen']) == 0: + return None + row = dataset[table_name].iloc[idx] + if table_name in transformed_fk: + for table, col, other_table, other_col in transformed_fk[table_name]: + val = row[col] + if val in transformed_dataset[other_table]['entries'][other_col]: + for new_idx in transformed_dataset[other_table]['entries'][other_col][val]: + if new_idx in transformed_dataset[other_table]['chosen']: + if remove_row(dataset, transformed_fk, transformed_dataset, other_table, new_idx) is None: + return None + return True + +def get_root_tables(metadata): + all_tables = {table['name'] for table in metadata.get_tables()} + + for fk in metadata.get_foreign_keys(): + if fk['table'] in all_tables: + all_tables.remove(fk['table']) + if len(all_tables) > 0: + return all_tables + else: + return {table['name'] for table in metadata.get_tables()} + +def sample_dataset(metadata, dataset, max_size=None, max_ratio=1.0, rand_seed=0): + if len(dataset) == 0: + return None #empty dataset + + random.seed(rand_seed) + transformed_fk, transformed_dataset, size = transform_dataset(metadata, dataset) + if max_size is not None: + max_size *= (1024.0**2) #input max_size is in MB + else: + max_size = size + target_size = min(max_size, size * float(max_ratio)) + root_tables = get_root_tables(metadata) + while calculate_size(transformed_dataset) > target_size: + table_name = random.sample(root_tables, 1)[0] + if len(transformed_dataset[table_name]['chosen']) > 0: + idx = random.sample(transformed_dataset[table_name]['chosen'], 1)[0] + if remove_row(dataset, transformed_fk, transformed_dataset, table_name, idx) is None: + return None + else: + return None + + return backward_transform(transformed_dataset, dataset) + +def sample_datasets(dict_of_databases, max_size=None, max_ratio=1.0, rand_seed=0): + sys.setrecursionlimit(10**6) + iterator = tqdm(dict_of_databases.items()) + new_dict_of_databases = {} + for database_name, (metadata, tables) in iterator: + iterator.set_description("Sampling from %s" % database_name) + new_tables = sample_dataset(metadata, tables, max_size=max_size, max_ratio=max_ratio, rand_seed=rand_seed) + if new_tables is None: + print("%s is dropped because of empty tables when sampling" % database_name) + else: + new_dict_of_databases[database_name] = (metadata, new_tables) + return new_dict_of_databases \ No newline at end of file From 4c186e4c648e12fb4dfc159c21c847b5e2d9a154 Mon Sep 17 00:00:00 2001 From: Zhuofan Xie Date: Mon, 12 Apr 2021 07:56:18 +0800 Subject: [PATCH 16/42] speed up table transformations in sampling --- datatracer/data_sampler.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/datatracer/data_sampler.py b/datatracer/data_sampler.py index 7995490..d598892 100644 --- a/datatracer/data_sampler.py +++ b/datatracer/data_sampler.py @@ -19,12 +19,15 @@ def calculate_size(transformed_dataset): def transform_dataset(metadata, dataset): fks = metadata.get_foreign_keys() transformed_fk = {} + key_columns = {table_name: set() for table_name in dataset} for fk in fks: table, all_field, ref_table, all_ref_field = fk["table"], fk["field"], fk["ref_table"], fk["ref_field"] if isinstance(all_field, str): all_field = [all_field] all_ref_field = [all_ref_field] for field, ref_field in zip(all_field, all_ref_field): + key_columns[table].add(field) + key_columns[ref_table].add(ref_field) if ref_table not in transformed_fk: transformed_fk[ref_table] = [] transformed_fk[ref_table].append((ref_table, ref_field, table, field)) @@ -32,7 +35,7 @@ def transform_dataset(metadata, dataset): size = 0 for table_name in dataset: table = dataset[table_name] - columns = list(table.columns) + columns = key_columns[table_name] transformed_table = {'size': table.memory_usage().sum(), 'row_size': float(table.memory_usage().sum()) / len(table), 'entries': {col: {} for col in columns}, @@ -93,7 +96,7 @@ def sample_dataset(metadata, dataset, max_size=None, max_ratio=1.0, rand_seed=0) max_size = size target_size = min(max_size, size * float(max_ratio)) root_tables = get_root_tables(metadata) - while calculate_size(transformed_dataset) > target_size: + while calculate_size(transformed_dataset) > target_size + 1: #+1 is for preventing precision issues table_name = random.sample(root_tables, 1)[0] if len(transformed_dataset[table_name]['chosen']) > 0: idx = random.sample(transformed_dataset[table_name]['chosen'], 1)[0] From ff1bdf86388574e2640803ebe962fc24eaa4346b Mon Sep 17 00:00:00 2001 From: Zhuofan Xie Date: Mon, 12 Apr 2021 21:35:02 +0800 Subject: [PATCH 17/42] enabled parallel computing for pre-processing --- benchmark/benchmark.py | 2 +- datatracer/data_sampler.py | 30 ++++++++++++++++++++++-------- 2 files changed, 23 insertions(+), 9 deletions(-) diff --git a/benchmark/benchmark.py b/benchmark/benchmark.py index 1fceb7b..c1ef3f5 100644 --- a/benchmark/benchmark.py +++ b/benchmark/benchmark.py @@ -313,7 +313,7 @@ def benchmark_column_map(data_dir, solver="datatracer.column_map.basic"): A DataFrame containing the benchmark resuls. """ datasets = load_datasets(data_dir) - datasets = sample_datasets(datasets, max_size=1000) + datasets = sample_datasets(datasets, max_size=500) dataset_names = list(datasets.keys()) datasets = dask.delayed(datasets) dataset_to_metrics = {} diff --git a/datatracer/data_sampler.py b/datatracer/data_sampler.py index d598892..528d531 100644 --- a/datatracer/data_sampler.py +++ b/datatracer/data_sampler.py @@ -8,7 +8,9 @@ import random +import dask from tqdm import tqdm +from dask.diagnostics import ProgressBar def calculate_size(transformed_dataset): size = 0 @@ -84,7 +86,12 @@ def get_root_tables(metadata): else: return {table['name'] for table in metadata.get_tables()} -def sample_dataset(metadata, dataset, max_size=None, max_ratio=1.0, rand_seed=0): +@dask.delayed +def sample_dataset(metadata=None, dataset=None, max_size=None, max_ratio=1.0, rand_seed=0, database_name=None, + dict_of_databases=None): + if dict_of_databases is not None: + metadata, dataset = dict_of_databases[database_name] + if len(dataset) == 0: return None #empty dataset @@ -108,14 +115,21 @@ def sample_dataset(metadata, dataset, max_size=None, max_ratio=1.0, rand_seed=0) return backward_transform(transformed_dataset, dataset) def sample_datasets(dict_of_databases, max_size=None, max_ratio=1.0, rand_seed=0): - sys.setrecursionlimit(10**6) - iterator = tqdm(dict_of_databases.items()) + db_names = list(dict_of_databases.keys()) + immediate_dict_of_db = dict_of_databases + dict_of_databases = dask.delayed(dict_of_databases) new_dict_of_databases = {} - for database_name, (metadata, tables) in iterator: - iterator.set_description("Sampling from %s" % database_name) - new_tables = sample_dataset(metadata, tables, max_size=max_size, max_ratio=max_ratio, rand_seed=rand_seed) - if new_tables is None: + for database_name in db_names: + new_dict_of_databases[database_name] = sample_dataset(max_size=max_size, + max_ratio=max_ratio, rand_seed=rand_seed, + dict_of_databases=dict_of_databases, database_name=database_name) + with ProgressBar(): + new_dict_of_databases = dask.compute(new_dict_of_databases)[0] + for database_name in db_names: + if new_dict_of_databases[database_name] is None: print("%s is dropped because of empty tables when sampling" % database_name) + del new_dict_of_databases[database_name] else: - new_dict_of_databases[database_name] = (metadata, new_tables) + metadata = immediate_dict_of_db[database_name][0] + new_dict_of_databases[database_name] = (metadata, new_dict_of_databases[database_name]) return new_dict_of_databases \ No newline at end of file From bdc6e510c7207c68b9e6b9e1551d659708653d1d Mon Sep 17 00:00:00 2001 From: Zhuofan Xie Date: Tue, 13 Apr 2021 14:52:40 +0800 Subject: [PATCH 18/42] skip datasets which does not need sampling --- datatracer/data_sampler.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/datatracer/data_sampler.py b/datatracer/data_sampler.py index 528d531..e5aceba 100644 --- a/datatracer/data_sampler.py +++ b/datatracer/data_sampler.py @@ -95,13 +95,17 @@ def sample_dataset(metadata=None, dataset=None, max_size=None, max_ratio=1.0, ra if len(dataset) == 0: return None #empty dataset - random.seed(rand_seed) - transformed_fk, transformed_dataset, size = transform_dataset(metadata, dataset) + size = sum([table.memory_usage().sum() for _, table in dataset.items()]) if max_size is not None: max_size *= (1024.0**2) #input max_size is in MB else: max_size = size target_size = min(max_size, size * float(max_ratio)) + if size <= target_size + 1: + return dataset + + random.seed(rand_seed) + transformed_fk, transformed_dataset, size = transform_dataset(metadata, dataset) root_tables = get_root_tables(metadata) while calculate_size(transformed_dataset) > target_size + 1: #+1 is for preventing precision issues table_name = random.sample(root_tables, 1)[0] @@ -113,6 +117,8 @@ def sample_dataset(metadata=None, dataset=None, max_size=None, max_ratio=1.0, ra return None return backward_transform(transformed_dataset, dataset) + + return backward_transform(transformed_dataset, dataset) def sample_datasets(dict_of_databases, max_size=None, max_ratio=1.0, rand_seed=0): db_names = list(dict_of_databases.keys()) From 164f606faefddbf88de375df1c8bb62faf91697f Mon Sep 17 00:00:00 2001 From: Zhuofan Xie Date: Thu, 22 Apr 2021 19:15:25 +0800 Subject: [PATCH 19/42] make data sampler able to drop huge datasets --- benchmark/benchmark.py | 2 +- datatracer/data_sampler.py | 26 +++++++++++++++++--------- 2 files changed, 18 insertions(+), 10 deletions(-) diff --git a/benchmark/benchmark.py b/benchmark/benchmark.py index c1ef3f5..70a66c9 100644 --- a/benchmark/benchmark.py +++ b/benchmark/benchmark.py @@ -313,7 +313,7 @@ def benchmark_column_map(data_dir, solver="datatracer.column_map.basic"): A DataFrame containing the benchmark resuls. """ datasets = load_datasets(data_dir) - datasets = sample_datasets(datasets, max_size=500) + datasets = sample_datasets(datasets, max_size=100) dataset_names = list(datasets.keys()) datasets = dask.delayed(datasets) dataset_to_metrics = {} diff --git a/datatracer/data_sampler.py b/datatracer/data_sampler.py index e5aceba..2e44fde 100644 --- a/datatracer/data_sampler.py +++ b/datatracer/data_sampler.py @@ -87,13 +87,13 @@ def get_root_tables(metadata): return {table['name'] for table in metadata.get_tables()} @dask.delayed -def sample_dataset(metadata=None, dataset=None, max_size=None, max_ratio=1.0, rand_seed=0, database_name=None, - dict_of_databases=None): +def sample_dataset(metadata=None, dataset=None, max_size=None, max_ratio=1.0, drop_threshold=None, + rand_seed=0, database_name=None, dict_of_databases=None): if dict_of_databases is not None: metadata, dataset = dict_of_databases[database_name] if len(dataset) == 0: - return None #empty dataset + return (None, 'empty') #empty dataset size = sum([table.memory_usage().sum() for _, table in dataset.items()]) if max_size is not None: @@ -101,8 +101,13 @@ def sample_dataset(metadata=None, dataset=None, max_size=None, max_ratio=1.0, ra else: max_size = size target_size = min(max_size, size * float(max_ratio)) + + if drop_threshold is None: + drop_threshold = 5 * target_size if size <= target_size + 1: return dataset + elif size > drop_threshold: + return (None, 'size') random.seed(rand_seed) transformed_fk, transformed_dataset, size = transform_dataset(metadata, dataset) @@ -112,28 +117,31 @@ def sample_dataset(metadata=None, dataset=None, max_size=None, max_ratio=1.0, ra if len(transformed_dataset[table_name]['chosen']) > 0: idx = random.sample(transformed_dataset[table_name]['chosen'], 1)[0] if remove_row(dataset, transformed_fk, transformed_dataset, table_name, idx) is None: - return None + return (None, 'empty') else: - return None + return (None, 'empty') return backward_transform(transformed_dataset, dataset) return backward_transform(transformed_dataset, dataset) -def sample_datasets(dict_of_databases, max_size=None, max_ratio=1.0, rand_seed=0): +def sample_datasets(dict_of_databases, max_size=None, max_ratio=1.0, drop_threshold=None, rand_seed=0): db_names = list(dict_of_databases.keys()) immediate_dict_of_db = dict_of_databases dict_of_databases = dask.delayed(dict_of_databases) new_dict_of_databases = {} for database_name in db_names: new_dict_of_databases[database_name] = sample_dataset(max_size=max_size, - max_ratio=max_ratio, rand_seed=rand_seed, + max_ratio=max_ratio, drop_threshold=drop_threshold, rand_seed=rand_seed, dict_of_databases=dict_of_databases, database_name=database_name) with ProgressBar(): new_dict_of_databases = dask.compute(new_dict_of_databases)[0] for database_name in db_names: - if new_dict_of_databases[database_name] is None: - print("%s is dropped because of empty tables when sampling" % database_name) + if isinstance(new_dict_of_databases[database_name], tuple): + if new_dict_of_databases[database_name][1] == 'empty': + print("%s is dropped because of empty tables when sampling" % database_name) + elif new_dict_of_databases[database_name][1] == 'size': + print("%s is dropped because it's too big" % database_name) del new_dict_of_databases[database_name] else: metadata = immediate_dict_of_db[database_name][0] From 9dd0149a56ae1329a1f12dee154f5438f99ac550 Mon Sep 17 00:00:00 2001 From: Zhuofan Xie Date: Mon, 3 May 2021 14:49:02 +0800 Subject: [PATCH 20/42] Fixed code styles --- benchmark/benchmark.py | 59 ++++++++++++++++----------------- datatracer/core.py | 4 ++- datatracer/data_sampler.py | 53 ++++++++++++++++++----------- datatracer/primary_key/basic.py | 3 +- 4 files changed, 67 insertions(+), 52 deletions(-) diff --git a/benchmark/benchmark.py b/benchmark/benchmark.py index 70a66c9..ae5911f 100644 --- a/benchmark/benchmark.py +++ b/benchmark/benchmark.py @@ -193,9 +193,9 @@ def foreign_key(solver, target, datasets): "inference_time": end - start } - precision = 0.0 if len(y_pred) == 0 else len(y_true.intersection(y_pred)) / len(y_pred) - recall = 0.0 if len(y_true) == 0 else len(y_true.intersection(y_pred)) / len(y_true) - f1 = 0.0 if precision + recall == 0 else 2.0 * precision * recall / (precision + recall) + precision = len(y_true.intersection(y_pred)) / len(y_pred) + recall = len(y_true.intersection(y_pred)) / len(y_true) + f1 = 2.0 * precision * recall / (precision + recall) return { "precision": precision, @@ -219,7 +219,7 @@ def benchmark_foreign_key(data_dir, solver="datatracer.foreign_key.standard"): A DataFrame containing the benchmark resuls. """ datasets = load_datasets(data_dir) - datasets = sample_datasets(datasets, max_size=1000) + datasets = sample_datasets(datasets, max_size=20) dataset_names = list(datasets.keys()) datasets = dask.delayed(datasets) dataset_to_metrics = {} @@ -268,33 +268,32 @@ def column_map(solver, target, datasets): start = time() y_pred = tracer.solve(tables, target_table=field["table"], target_field=field["field"]) + y_pred = {field for field, score in y_pred.items() if score > 0.0} end = time() - y_pred_total = 0 - intersection_total = 0 - - for field_temp, score in y_pred.items(): - if field_temp in y_true: - intersection_total += max(0, min(1, score)) - y_pred_total += max(0, min(1, score)) - - #precision = intersection_total / y_pred_total - #recall = intersection_total / len(y_true) - #f1 = 2.0 * precision * recall / (precision + recall) - - y_pred = {field for field, score in y_pred.items() if score > 0.0} - precision = 0.0 if len(y_pred) == 0 else len(y_true.intersection(y_pred)) / len(y_pred) - recall = 0.0 if len(y_true) == 0 else len(y_true.intersection(y_pred)) / len(y_true) - f1 = 0.0 if precision + recall == 0 else 2.0 * precision * recall / (precision + recall) - - list_of_metrics.append({ - "table": field["table"], - "field": field["field"], - "precision": precision, - "recall": recall, - "f1": f1, - "inference_time": end - start - }) + if len(y_pred) == 0 or len(y_true) == 0 or \ + len(y_true.intersection(y_pred)) == 0: + list_of_metrics.append({ + "table": field["table"], + "field": field["field"], + "precision": 0.0, + "recall": 0.0, + "f1": 0.0, + "inference_time": end - start + }) + else: + precision = len(y_true.intersection(y_pred)) / len(y_pred) + recall = len(y_true.intersection(y_pred)) / len(y_true) + f1 = 2.0 * precision * recall / (precision + recall) + + list_of_metrics.append({ + "table": field["table"], + "field": field["field"], + "precision": precision, + "recall": recall, + "f1": f1, + "inference_time": end - start + }) return list_of_metrics @@ -313,7 +312,7 @@ def benchmark_column_map(data_dir, solver="datatracer.column_map.basic"): A DataFrame containing the benchmark resuls. """ datasets = load_datasets(data_dir) - datasets = sample_datasets(datasets, max_size=100) + datasets = sample_datasets(datasets, max_size=20) dataset_names = list(datasets.keys()) datasets = dask.delayed(datasets) dataset_to_metrics = {} diff --git a/datatracer/core.py b/datatracer/core.py index 3a0d3d5..cdeb737 100644 --- a/datatracer/core.py +++ b/datatracer/core.py @@ -15,6 +15,7 @@ PIPELINE_DIR = os.path.join(os.path.dirname(__file__), 'jsons/pipelines') PRIMITIVE_DIR = os.path.join(os.path.dirname(__file__), 'jsons/primitives') + class DataTracer: """DataTracer Class. @@ -51,7 +52,8 @@ def _get_mlpipeline(self): with open(primitive) as json_file: primitive = json.load(json_file) elif os.path.isfile(os.path.join(PRIMITIVE_DIR, primitive + '.json')): - with open(os.path.join(PRIMITIVE_DIR, primitive + '.json')) as json_file: + with open(os.path.join(PRIMITIVE_DIR, primitive + '.json'))\ + as json_file: primitive = json.load(json_file) pipeline['primitives'][idx] = primitive diff --git a/datatracer/data_sampler.py b/datatracer/data_sampler.py index 2e44fde..2b0f0c2 100644 --- a/datatracer/data_sampler.py +++ b/datatracer/data_sampler.py @@ -4,26 +4,27 @@ This module introduces tools for sampling from databases while respecting the row lineage. """ -import sys import random import dask -from tqdm import tqdm from dask.diagnostics import ProgressBar + def calculate_size(transformed_dataset): size = 0 for table in transformed_dataset.values(): size += table['row_size'] * len(table['chosen']) return size + def transform_dataset(metadata, dataset): fks = metadata.get_foreign_keys() transformed_fk = {} key_columns = {table_name: set() for table_name in dataset} for fk in fks: - table, all_field, ref_table, all_ref_field = fk["table"], fk["field"], fk["ref_table"], fk["ref_field"] + table, all_field, ref_table, all_ref_field =\ + fk["table"], fk["field"], fk["ref_table"], fk["ref_field"] if isinstance(all_field, str): all_field = [all_field] all_ref_field = [all_ref_field] @@ -41,7 +42,7 @@ def transform_dataset(metadata, dataset): transformed_table = {'size': table.memory_usage().sum(), 'row_size': float(table.memory_usage().sum()) / len(table), 'entries': {col: {} for col in columns}, - 'chosen': set(range(len(table))),} + 'chosen': set(range(len(table))), } for idx in range(len(table)): for col in columns: val = table.iloc[idx][col] @@ -52,6 +53,7 @@ def transform_dataset(metadata, dataset): size += transformed_table['size'] return transformed_fk, transformed_dataset, size + def backward_transform(transformed_dataset, dataset): new_dataset = {} for table_name in dataset: @@ -59,6 +61,7 @@ def backward_transform(transformed_dataset, dataset): new_dataset[table_name] = dataset[table_name].iloc[idxes] return new_dataset + def remove_row(dataset, transformed_fk, transformed_dataset, table_name, idx): if idx in transformed_dataset[table_name]['chosen']: transformed_dataset[table_name]['chosen'].remove(idx) @@ -71,13 +74,15 @@ def remove_row(dataset, transformed_fk, transformed_dataset, table_name, idx): if val in transformed_dataset[other_table]['entries'][other_col]: for new_idx in transformed_dataset[other_table]['entries'][other_col][val]: if new_idx in transformed_dataset[other_table]['chosen']: - if remove_row(dataset, transformed_fk, transformed_dataset, other_table, new_idx) is None: + if remove_row(dataset, transformed_fk, transformed_dataset, + other_table, new_idx) is None: return None return True + def get_root_tables(metadata): all_tables = {table['name'] for table in metadata.get_tables()} - + for fk in metadata.get_foreign_keys(): if fk['table'] in all_tables: all_tables.remove(fk['table']) @@ -86,18 +91,19 @@ def get_root_tables(metadata): else: return {table['name'] for table in metadata.get_tables()} + @dask.delayed def sample_dataset(metadata=None, dataset=None, max_size=None, max_ratio=1.0, drop_threshold=None, - rand_seed=0, database_name=None, dict_of_databases=None): + rand_seed=0, database_name=None, dict_of_databases=None): if dict_of_databases is not None: metadata, dataset = dict_of_databases[database_name] - + if len(dataset) == 0: - return (None, 'empty') #empty dataset - + return (None, 'empty') # empty dataset + size = sum([table.memory_usage().sum() for _, table in dataset.items()]) if max_size is not None: - max_size *= (1024.0**2) #input max_size is in MB + max_size *= (1024.0**2) # input max_size is in MB else: max_size = size target_size = min(max_size, size * float(max_ratio)) @@ -108,11 +114,12 @@ def sample_dataset(metadata=None, dataset=None, max_size=None, max_ratio=1.0, dr return dataset elif size > drop_threshold: return (None, 'size') - + random.seed(rand_seed) transformed_fk, transformed_dataset, size = transform_dataset(metadata, dataset) root_tables = get_root_tables(metadata) - while calculate_size(transformed_dataset) > target_size + 1: #+1 is for preventing precision issues + while calculate_size(transformed_dataset) > target_size + \ + 1: # +1 is for preventing precision issues table_name = random.sample(root_tables, 1)[0] if len(transformed_dataset[table_name]['chosen']) > 0: idx = random.sample(transformed_dataset[table_name]['chosen'], 1)[0] @@ -120,20 +127,26 @@ def sample_dataset(metadata=None, dataset=None, max_size=None, max_ratio=1.0, dr return (None, 'empty') else: return (None, 'empty') - + return backward_transform(transformed_dataset, dataset) - + return backward_transform(transformed_dataset, dataset) -def sample_datasets(dict_of_databases, max_size=None, max_ratio=1.0, drop_threshold=None, rand_seed=0): + +def sample_datasets(dict_of_databases, max_size=None, max_ratio=1.0, + drop_threshold=None, rand_seed=0): db_names = list(dict_of_databases.keys()) immediate_dict_of_db = dict_of_databases dict_of_databases = dask.delayed(dict_of_databases) new_dict_of_databases = {} for database_name in db_names: - new_dict_of_databases[database_name] = sample_dataset(max_size=max_size, - max_ratio=max_ratio, drop_threshold=drop_threshold, rand_seed=rand_seed, - dict_of_databases=dict_of_databases, database_name=database_name) + new_dict_of_databases[database_name] = sample_dataset( + max_size=max_size, + max_ratio=max_ratio, + drop_threshold=drop_threshold, + rand_seed=rand_seed, + dict_of_databases=dict_of_databases, + database_name=database_name) with ProgressBar(): new_dict_of_databases = dask.compute(new_dict_of_databases)[0] for database_name in db_names: @@ -146,4 +159,4 @@ def sample_datasets(dict_of_databases, max_size=None, max_ratio=1.0, drop_thresh else: metadata = immediate_dict_of_db[database_name][0] new_dict_of_databases[database_name] = (metadata, new_dict_of_databases[database_name]) - return new_dict_of_databases \ No newline at end of file + return new_dict_of_databases diff --git a/datatracer/primary_key/basic.py b/datatracer/primary_key/basic.py index e0bc93a..a2fffdc 100644 --- a/datatracer/primary_key/basic.py +++ b/datatracer/primary_key/basic.py @@ -17,7 +17,8 @@ def _feature_vector(self, table, column_name): column = table[column_name] return [ list(table.columns).index(column_name), - 0.0 if len(table.columns) == 0 else list(table.columns).index(column_name) / len(table.columns), + 0.0 if len(table.columns) == 0 else list( + table.columns).index(column_name) / len(table.columns), 1.0 if column.nunique() == len(column) else 0.0, 0.0 if len(column) == 0 else column.nunique() / len(column), 1.0 if "key" in column.name else 0.0, From 1400867e0e3da0efa7685caeaf800598eab88119 Mon Sep 17 00:00:00 2001 From: Zhuofan Xie Date: Sun, 16 May 2021 20:54:14 +0800 Subject: [PATCH 21/42] Add support for multiple/none primary key predictions --- datatracer/primary_key/basic.py | 51 ++++++++++++++++++++++++--------- 1 file changed, 38 insertions(+), 13 deletions(-) diff --git a/datatracer/primary_key/basic.py b/datatracer/primary_key/basic.py index a2fffdc..3901572 100644 --- a/datatracer/primary_key/basic.py +++ b/datatracer/primary_key/basic.py @@ -9,9 +9,10 @@ class BasicPrimaryKeySolver(PrimaryKeySolver): - def __init__(self, *args, **kwargs): + def __init__(self, threshold = [i/20 for i in range(20)], *args, **kwargs): self._model_args = args self._model_kwargs = kwargs + self._threshold = threshold def _feature_vector(self, table, column_name): column = table[column_name] @@ -44,29 +45,53 @@ def fit(self, dict_of_databases): iterator.set_description("Extracting features from %s" % database_name) for table in metadata.get_tables(): if "primary_key" not in table: - continue - if not isinstance(table["primary_key"], str): - continue + pk = [] + elif not isinstance(table["primary_key"], str): + pk = table["primary_key"] + else: + pk = [table["primary_key"]] primary_key = table["primary_key"] for column in tables[table["name"]].columns: X.append(self._feature_vector(tables[table["name"]], column)) - y.append(1.0 if primary_key == column else 0.0) + y.append(1.0 if column in pk else 0.0) X, y = np.array(X), np.array(y) self.model = RandomForestClassifier(*self._model_args, **self._model_kwargs) self.model.fit(X, y) + if isinstance(self._threshold, list): + best_f1 = -float('inf') + best_threshold = None + pred_y = self.model.predict(X) + len_true = sum(y) + for threshold in self._threshold: + filtered_y = (pred_y >= threshold).astype(float) + intersect = sum(filtered_y*y) + len_pred = sum(filtered_y) + if intersect * len_true * len_pred == 0: + f1 = 0 + else: + precision = intersect / len_pred + recall = intersect / len_true + f1 = 2.0 * precision * recall / (precision + recall) + if f1 > best_f1: + best_f1 = f1 + best_threshold = threshold + self._threshold = best_threshold + + def _score_all_keys(self, table): + return [(column, self.model.predict([self._feature_vector(table, column)])) + for column in table.columns] + def _find_primary_key(self, table): - best_column, best_score = None, float("-inf") - for column in table.columns: - score = self.model.predict([self._feature_vector(table, column)]) - if score > best_score: - best_column = column - best_score = score - - return best_column + ret = [] + for column, score in self._score_all_keys(table): + if score >= self._threshold: + ret.append(column) + + return ret def solve(self, tables): """Solve the problem. From 59a5d44922682be9abf68ce8706b1029c62fb11c Mon Sep 17 00:00:00 2001 From: Zhuofan Xie Date: Sun, 16 May 2021 23:32:15 +0800 Subject: [PATCH 22/42] let FKD take in primary key predictions and only look at relations involving primary keys --- datatracer/foreign_key/standard.py | 45 +++++++++++++++++-- .../datatracer.column_map.basic.json | 1 + .../datatracer.foreign_key.standard.json | 1 + 3 files changed, 43 insertions(+), 4 deletions(-) diff --git a/datatracer/foreign_key/standard.py b/datatracer/foreign_key/standard.py index daeab56..62645c7 100644 --- a/datatracer/foreign_key/standard.py +++ b/datatracer/foreign_key/standard.py @@ -9,7 +9,7 @@ class StandardForeignKeySolver(ForeignKeySolver): - def __init__(self, threshold=0.9, add_details=False, *args, **kwargs): + def __init__(self, threshold=[i/20 for i in range(20)], add_details=False, *args, **kwargs): self._threshold = threshold self._add_details = add_details self._model_args = args @@ -64,6 +64,7 @@ def fit(self, dict_of_databases): for database_name, (metadata, tables) in iterator: iterator.set_description("Extracting features from %s" % database_name) fks = metadata.get_foreign_keys() + tables_info = {table_info['name']: table_info for table_info in metadata.get_tables()} fks_new = [] for fk in fks: if isinstance(fk["field"], list): @@ -74,8 +75,16 @@ def fit(self, dict_of_databases): fks = set(fks_new) for t1, t2 in permutations(tables.keys(), r=2): - for c1 in tables[t1].columns: - for c2 in tables[t2].columns: + table = tables_info[t2] + if "primary_key" not in table: + t2_columns = tables[t2].columns + elif not isinstance(table["primary_key"], str): + t2_columns = table["primary_key"] + else: + t2_columns = [table["primary_key"]] + + for c2 in t2_columns: + for c1 in tables[t1].columns: if tables[t1][c1].dtype.kind != tables[t2][c2].dtype.kind: continue @@ -89,6 +98,28 @@ def fit(self, dict_of_databases): self.model = RandomForestClassifier(*self._model_args, **self._model_kwargs) self.model.fit(X, y) + if isinstance(self._threshold, list): + best_f1 = -float('inf') + best_threshold = None + pred_y = self.model.predict(X) + len_true = sum(y) + for threshold in self._threshold: + filtered_y = (pred_y >= threshold).astype(float) + intersect = sum(filtered_y*y) + len_pred = sum(filtered_y) + if intersect * len_true * len_pred == 0: + f1 = 0 + else: + precision = intersect / len_pred + recall = intersect / len_true + f1 = 2.0 * precision * recall / (precision + recall) + if f1 > best_f1: + best_f1 = f1 + best_threshold = threshold + self._threshold = best_threshold + print(best_threshold) + + def solve(self, tables, primary_keys=None): """Solve the foreign key detection problem. @@ -109,7 +140,13 @@ def solve(self, tables, primary_keys=None): X, foreign_keys = [], [] for t1, t2 in permutations(tables.keys(), r=2): for c1 in tables[t1].columns: - for c2 in tables[t2].columns: + if (primary_keys is None) or (t2 not in primary_keys): + t2_columns = tables[t2].columns + elif not isinstance(primary_keys[t2], str): + t2_columns = primary_keys[t2] + else: + t2_columns = [primary_keys[t2]] + for c2 in t2_columns: if tables[t1][c1].dtype.kind != tables[t2][c2].dtype.kind: continue diff --git a/datatracer/jsons/pipelines/datatracer.column_map.basic.json b/datatracer/jsons/pipelines/datatracer.column_map.basic.json index ad66877..9f328ec 100644 --- a/datatracer/jsons/pipelines/datatracer.column_map.basic.json +++ b/datatracer/jsons/pipelines/datatracer.column_map.basic.json @@ -1,5 +1,6 @@ { "primitives": [ + "datatracer.primary_key.BasicPrimaryKeySolver", "datatracer.foreign_key.StandardForeignKeySolver", "datatracer.column_map.BasicColumnMapSolver" ] diff --git a/datatracer/jsons/pipelines/datatracer.foreign_key.standard.json b/datatracer/jsons/pipelines/datatracer.foreign_key.standard.json index 0ed3ab6..266075b 100644 --- a/datatracer/jsons/pipelines/datatracer.foreign_key.standard.json +++ b/datatracer/jsons/pipelines/datatracer.foreign_key.standard.json @@ -1,5 +1,6 @@ { "primitives": [ + "datatracer.primary_key.BasicPrimaryKeySolver", "datatracer.foreign_key.StandardForeignKeySolver" ] } From f103181b21ef227526d49b0d0e47f92370abab7c Mon Sep 17 00:00:00 2001 From: Zhuofan Xie Date: Mon, 17 May 2021 03:24:19 +0800 Subject: [PATCH 23/42] modified default parameters for FKD --- .../datatracer.foreign_key.StandardForeignKeySolver.json | 8 -------- 1 file changed, 8 deletions(-) diff --git a/datatracer/jsons/primitives/datatracer.foreign_key.StandardForeignKeySolver.json b/datatracer/jsons/primitives/datatracer.foreign_key.StandardForeignKeySolver.json index 87ae0a7..2429d4d 100644 --- a/datatracer/jsons/primitives/datatracer.foreign_key.StandardForeignKeySolver.json +++ b/datatracer/jsons/primitives/datatracer.foreign_key.StandardForeignKeySolver.json @@ -56,14 +56,6 @@ } }, "tunable": { - "threshold": { - "type": "float", - "default": 0.9, - "range": [ - 0.0, - 1.0 - ] - }, "n_estimators": { "type": "int", "default": 10, From bf4dea0b1d5727cc8d83919263b29f809467ebcd Mon Sep 17 00:00:00 2001 From: Zhuofan Xie Date: Fri, 21 May 2021 10:29:09 -0400 Subject: [PATCH 24/42] Let CMD detect empty training tables, in which case we skip the fitting and directly return no lineage found --- datatracer/column_map/basic.py | 4 ++++ datatracer/foreign_key/standard.py | 2 -- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/datatracer/column_map/basic.py b/datatracer/column_map/basic.py index 1c0e48f..208b883 100644 --- a/datatracer/column_map/basic.py +++ b/datatracer/column_map/basic.py @@ -46,6 +46,10 @@ def solve(self, tables, foreign_keys, target_table, target_field): transformer = Transformer(tables, foreign_keys) X, y = transformer.forward(target_table, target_field) + if len(X.shape) != 2: #invalid X shape + return {} + elif X.shape[0] == 0 or X.shape[1] == 0: #empty dimension + return {} importances = self._get_importances(X, y) return transformer.backward(importances) diff --git a/datatracer/foreign_key/standard.py b/datatracer/foreign_key/standard.py index 62645c7..38e7ab9 100644 --- a/datatracer/foreign_key/standard.py +++ b/datatracer/foreign_key/standard.py @@ -117,8 +117,6 @@ def fit(self, dict_of_databases): best_f1 = f1 best_threshold = threshold self._threshold = best_threshold - print(best_threshold) - def solve(self, tables, primary_keys=None): """Solve the foreign key detection problem. From b51d72b27098e368d4e276bb4228d5045807010f Mon Sep 17 00:00:00 2001 From: Zhuofan Xie Date: Tue, 1 Jun 2021 15:25:54 -0400 Subject: [PATCH 25/42] Add linear map detector, and change the feature importance mapping to summation --- datatracer/column_map/basic.py | 31 +++++++++++++++++++++++++--- datatracer/column_map/transformer.py | 5 ++++- 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/datatracer/column_map/basic.py b/datatracer/column_map/basic.py index 208b883..d1ce6a5 100644 --- a/datatracer/column_map/basic.py +++ b/datatracer/column_map/basic.py @@ -1,6 +1,7 @@ import logging from sklearn.ensemble import RandomForestRegressor +from sklearn.linear_model import LinearRegression from datatracer.column_map.base import ColumnMapSolver from datatracer.column_map.transformer import Transformer @@ -11,15 +12,23 @@ class BasicColumnMapSolver(ColumnMapSolver): """Basic Solver for the data lineage problem of column dependency.""" - def __init__(self, *args, **kwargs): + def __init__(self, threshold=0.1, *args, **kwargs): self._model_args = args self._model_kwargs = kwargs + self._threshold = threshold + self._linear_weight_threshold = 1e-4 + self._linear_score_threshold = 0.95 def _get_importances(self, X, y): model = RandomForestRegressor(*self._model_args, **self._model_kwargs) model.fit(X, y) return model.feature_importances_ + + def _convert_linear_importances(self, weights): + new_weights = (weights > self._linear_weight_threshold) / sum(weights > self._linear_weight_threshold) + + return new_weights def solve(self, tables, foreign_keys, target_table, target_field): """Find the fields which contributed to the target_field the most. @@ -51,5 +60,21 @@ def solve(self, tables, foreign_keys, target_table, target_field): elif X.shape[0] == 0 or X.shape[1] == 0: #empty dimension return {} - importances = self._get_importances(X, y) - return transformer.backward(importances) + reg = LinearRegression(fit_intercept=False).fit(X, y) + if reg.score(X, y) > self._linear_score_threshold: + importances = self._convert_linear_importances(reg.coef_) + else: + importances = self._get_importances(X, y) + + ret_dict = transformer.backward(importances) + flag = True + while flag: + flag = False + new_rets = ret_dict.copy() + total_score = sum(ret_dict.values()) + for field, score in ret_dict.items(): + if score < total_score * self._threshold / len(ret_dict): + del new_rets[field] + flag = True + ret_dict = new_rets + return ret_dict \ No newline at end of file diff --git a/datatracer/column_map/transformer.py b/datatracer/column_map/transformer.py index 28b7441..856bf62 100644 --- a/datatracer/column_map/transformer.py +++ b/datatracer/column_map/transformer.py @@ -116,6 +116,9 @@ def backward(self, feature_importances): """ obj = {} for column, importance in zip(self.columns, feature_importances): - obj[column] = importance + if column in obj: + obj[column] += importance + else: + obj[column] = importance return obj From d1deb228ca3b5d651040219ec6c9afb459197950 Mon Sep 17 00:00:00 2001 From: Zhuofan Xie Date: Mon, 7 Jun 2021 13:59:46 -0400 Subject: [PATCH 26/42] Add testing on one dataset and aggregating multiple test results --- benchmark/benchmark.py | 80 +++++++++++++++++++++++++++++++--- datatracer/column_map/basic.py | 11 +++-- 2 files changed, 80 insertions(+), 11 deletions(-) diff --git a/benchmark/benchmark.py b/benchmark/benchmark.py index ae5911f..e191fc5 100644 --- a/benchmark/benchmark.py +++ b/benchmark/benchmark.py @@ -121,7 +121,7 @@ def primary_key(solver, target, datasets): } -def benchmark_primary_key(data_dir, solver="datatracer.primary_key.basic"): +def benchmark_primary_key(data_dir, dataset_name=None, solver="datatracer.primary_key.basic"): """Benchmark the primary key solver. This uses leave-one-out validation and evaluates the performance of the @@ -136,6 +136,11 @@ def benchmark_primary_key(data_dir, solver="datatracer.primary_key.basic"): """ datasets = load_datasets(data_dir) dataset_names = list(datasets.keys()) + if dataset_name is not None: + if dataset_name in dataset_names: + dataset_names = [dataset_name] + else: + return None datasets = dask.delayed(datasets) dataset_to_metrics = {} for dataset_name in dataset_names: @@ -205,7 +210,7 @@ def foreign_key(solver, target, datasets): } -def benchmark_foreign_key(data_dir, solver="datatracer.foreign_key.standard"): +def benchmark_foreign_key(data_dir, dataset_name=None, solver="datatracer.foreign_key.standard"): """Benchmark the foreign key solver. This uses leave-one-out validation and evaluates the performance of the @@ -221,6 +226,11 @@ def benchmark_foreign_key(data_dir, solver="datatracer.foreign_key.standard"): datasets = load_datasets(data_dir) datasets = sample_datasets(datasets, max_size=20) dataset_names = list(datasets.keys()) + if dataset_name is not None: + if dataset_name in dataset_names: + dataset_names = [dataset_name] + else: + return None datasets = dask.delayed(datasets) dataset_to_metrics = {} for dataset_name in dataset_names: @@ -298,7 +308,7 @@ def column_map(solver, target, datasets): return list_of_metrics -def benchmark_column_map(data_dir, solver="datatracer.column_map.basic"): +def benchmark_column_map(data_dir, dataset_name=None, solver="datatracer.column_map.basic"): """Benchmark the column map solver. This uses leave-one-out validation and evaluates the performance of the @@ -314,6 +324,11 @@ def benchmark_column_map(data_dir, solver="datatracer.column_map.basic"): datasets = load_datasets(data_dir) datasets = sample_datasets(datasets, max_size=20) dataset_names = list(datasets.keys()) + if dataset_name is not None: + if dataset_name in dataset_names: + dataset_names = [dataset_name] + else: + return None datasets = dask.delayed(datasets) dataset_to_metrics = {} for dataset_name in dataset_names: @@ -337,6 +352,31 @@ def benchmark_column_map(data_dir, solver="datatracer.column_map.basic"): return df +def start_with(target, source): + return len(source) <= len(target) and target[:len(source)] == source + + +def aggregate(cmd_name): + cmd_abbrv = { 'column': 'ColMap_st', + 'foreign': 'ForeignKey_st', + 'primary': 'PrimaryKey_st' + } + if cmd_name not in cmd_abbrv: + print("Invalid command name!") + return None #invalid command name + cmd_name = cmd_abbrv[cmd_name] + dfs = [] + for file in os.listdir("Reports"): + if start_with(file, cmd_name): + dfs.append(pd.read_csv("Reports/"+file)) + if len(dfs) == 0: + print("No available test results!") + return None + df = pd.concat(dfs, axis=0, ignore_index=True) + os.system("rm Reports/"+cmd_name+"*") #Clean up the caches + return df + + def _get_parser(): shared_args = argparse.ArgumentParser(add_help=False) shared_args.add_argument('--data_dir', type=str, @@ -347,6 +387,12 @@ def _get_parser(): shared_args.add_argument('--csv', type=str, default=os.path.expanduser(default_csv), required=False, help='Path to the CSV file where the report will be written.') + shared_args.add_argument('--ds_name', type=str, + default=None, required=False, + help='Name of the dataset to test on. Default is all available datasets.') + shared_args.add_argument('--cmd', type=str, + default=None, required=False, + help='Name of the tests results to aggregate.') parser = argparse.ArgumentParser( prog='datatracer-benchmark', @@ -384,19 +430,39 @@ def _get_parser(): ) subparser.set_defaults(command=benchmark_column_map) + subparser = command.add_parser( + 'aggregate', + parents=[shared_args], + help='Aggregate separate test results.' + ) + subparser.set_defaults(command=aggregate) + return parser def main(): parser = _get_parser() args = parser.parse_args() - df = args.command(args.data_dir) + if args.command == download: + df = args.command(args.data_dir) + elif args.command == aggregate: + df = args.command(args.cmd) + else: + df = args.command(args.data_dir, args.ds_name) + cmd_abbrv = { 'column': 'ColMap_', + 'foreign': 'ForeignKey_', + 'primary': 'PrimaryKey_' + } cmd_str = { benchmark_column_map: 'ColMap_', benchmark_foreign_key: 'ForeignKey_', - benchmark_primary_key: 'PrimaryKey_' + benchmark_primary_key: 'PrimaryKey_', + aggregate: cmd_abbrv[args.cmd] if args.cmd in cmd_abbrv else '' } - if args.csv and args.command in cmd_str: - df.to_csv("Reports/" + cmd_str[args.command] + args.csv, index=False) + csv_name = "st_" + args.ds_name + ".csv" if args.ds_name else args.csv + # st is for recognition in the aggregation step + + if csv_name and (args.command in cmd_str) and (df is not None): + df.to_csv("Reports/" + cmd_str[args.command] + csv_name, index=False) print(df) diff --git a/datatracer/column_map/basic.py b/datatracer/column_map/basic.py index d1ce6a5..5dbb66f 100644 --- a/datatracer/column_map/basic.py +++ b/datatracer/column_map/basic.py @@ -60,10 +60,13 @@ def solve(self, tables, foreign_keys, target_table, target_field): elif X.shape[0] == 0 or X.shape[1] == 0: #empty dimension return {} - reg = LinearRegression(fit_intercept=False).fit(X, y) - if reg.score(X, y) > self._linear_score_threshold: - importances = self._convert_linear_importances(reg.coef_) - else: + try: + reg = LinearRegression(fit_intercept=False).fit(X, y) + if reg.score(X, y) > self._linear_score_threshold: + importances = self._convert_linear_importances(reg.coef_) + else: + importances = self._get_importances(X, y) + except: importances = self._get_importances(X, y) ret_dict = transformer.backward(importances) From 75c4f742480a0f6dbdccfe8f5e6489aa3c093020 Mon Sep 17 00:00:00 2001 From: Zhuofan Xie Date: Mon, 7 Jun 2021 19:09:45 -0400 Subject: [PATCH 27/42] disable sampling by default --- benchmark/benchmark.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/benchmark/benchmark.py b/benchmark/benchmark.py index e191fc5..9181866 100644 --- a/benchmark/benchmark.py +++ b/benchmark/benchmark.py @@ -224,7 +224,7 @@ def benchmark_foreign_key(data_dir, dataset_name=None, solver="datatracer.foreig A DataFrame containing the benchmark resuls. """ datasets = load_datasets(data_dir) - datasets = sample_datasets(datasets, max_size=20) + #datasets = sample_datasets(datasets, max_size=20) dataset_names = list(datasets.keys()) if dataset_name is not None: if dataset_name in dataset_names: @@ -322,7 +322,7 @@ def benchmark_column_map(data_dir, dataset_name=None, solver="datatracer.column_ A DataFrame containing the benchmark resuls. """ datasets = load_datasets(data_dir) - datasets = sample_datasets(datasets, max_size=20) + #datasets = sample_datasets(datasets, max_size=20) dataset_names = list(datasets.keys()) if dataset_name is not None: if dataset_name in dataset_names: From 4c01e9ef5832ca72dd14b54aff2871c72ba33e83 Mon Sep 17 00:00:00 2001 From: Zhuofan Xie Date: Tue, 8 Jun 2021 23:55:40 -0400 Subject: [PATCH 28/42] Enable multi-threading in column map tests --- benchmark/benchmark.py | 75 ++++++++++++++++++++++-------------------- 1 file changed, 40 insertions(+), 35 deletions(-) diff --git a/benchmark/benchmark.py b/benchmark/benchmark.py index 9181866..40eb573 100644 --- a/benchmark/benchmark.py +++ b/benchmark/benchmark.py @@ -247,6 +247,44 @@ def benchmark_foreign_key(data_dir, dataset_name=None, solver="datatracer.foreig return df +@dask.delayed +def evaluate_single_column_map(constraint, tracer, tables): + field = constraint["fields_under_consideration"][0] + related_fields = constraint["related_fields"] + + y_true = set() + for related_field in related_fields: + y_true.add((related_field["table"], related_field["field"])) + + start = time() + y_pred = tracer.solve(tables, target_table=field["table"], target_field=field["field"]) + y_pred = {field for field, score in y_pred.items() if score > 0.0} + end = time() + + if len(y_pred) == 0 or len(y_true) == 0 or \ + len(y_true.intersection(y_pred)) == 0: + return { + "table": field["table"], + "field": field["field"], + "precision": 0.0, + "recall": 0.0, + "f1": 0.0, + "inference_time": end - start + } + else: + precision = len(y_true.intersection(y_pred)) / len(y_pred) + recall = len(y_true.intersection(y_pred)) / len(y_true) + f1 = 2.0 * precision * recall / (precision + recall) + + return { + "table": field["table"], + "field": field["field"], + "precision": precision, + "recall": recall, + "f1": f1, + "inference_time": end - start + } + @dask.delayed def column_map(solver, target, datasets): """Benchmark the column map solver on the target dataset. @@ -269,42 +307,9 @@ def column_map(solver, target, datasets): list_of_metrics = [] for constraint in metadata.data["constraints"]: - field = constraint["fields_under_consideration"][0] - related_fields = constraint["related_fields"] - - y_true = set() - for related_field in related_fields: - y_true.add((related_field["table"], related_field["field"])) - - start = time() - y_pred = tracer.solve(tables, target_table=field["table"], target_field=field["field"]) - y_pred = {field for field, score in y_pred.items() if score > 0.0} - end = time() - - if len(y_pred) == 0 or len(y_true) == 0 or \ - len(y_true.intersection(y_pred)) == 0: - list_of_metrics.append({ - "table": field["table"], - "field": field["field"], - "precision": 0.0, - "recall": 0.0, - "f1": 0.0, - "inference_time": end - start - }) - else: - precision = len(y_true.intersection(y_pred)) / len(y_pred) - recall = len(y_true.intersection(y_pred)) / len(y_true) - f1 = 2.0 * precision * recall / (precision + recall) - - list_of_metrics.append({ - "table": field["table"], - "field": field["field"], - "precision": precision, - "recall": recall, - "f1": f1, - "inference_time": end - start - }) + list_of_metrics.append(evaluate_single_column_map(constraint, tracer, tables)) + list_of_metrics = dask.compute(list_of_metrics)[0] return list_of_metrics From bc7a0fa931e43e889e183130d8aee9c2836b3450 Mon Sep 17 00:00:00 2001 From: Zhuofan Xie Date: Sat, 19 Jun 2021 12:58:43 -0400 Subject: [PATCH 29/42] Add the option to choose primitive --- benchmark/benchmark.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/benchmark/benchmark.py b/benchmark/benchmark.py index 40eb573..8d812d8 100644 --- a/benchmark/benchmark.py +++ b/benchmark/benchmark.py @@ -395,9 +395,12 @@ def _get_parser(): shared_args.add_argument('--ds_name', type=str, default=None, required=False, help='Name of the dataset to test on. Default is all available datasets.') - shared_args.add_argument('--cmd', type=str, + shared_args.add_argument('--problem', type=str, default=None, required=False, help='Name of the tests results to aggregate.') + shared_args.add_argument('--primitive', type=str, + default=None, required=False, + help='Name of the primitive to be tested.') parser = argparse.ArgumentParser( prog='datatracer-benchmark', @@ -451,9 +454,12 @@ def main(): if args.command == download: df = args.command(args.data_dir) elif args.command == aggregate: - df = args.command(args.cmd) + df = args.command(args.problem) else: - df = args.command(args.data_dir, args.ds_name) + if args.primitive is None: + df = args.command(args.data_dir, args.ds_name) + else: + df = args.command(args.data_dir, args.ds_name, solver=args.primitive) cmd_abbrv = { 'column': 'ColMap_', 'foreign': 'ForeignKey_', 'primary': 'PrimaryKey_' @@ -461,7 +467,7 @@ def main(): cmd_str = { benchmark_column_map: 'ColMap_', benchmark_foreign_key: 'ForeignKey_', benchmark_primary_key: 'PrimaryKey_', - aggregate: cmd_abbrv[args.cmd] if args.cmd in cmd_abbrv else '' + aggregate: cmd_abbrv[args.problem] if args.problem in cmd_abbrv else '' } csv_name = "st_" + args.ds_name + ".csv" if args.ds_name else args.csv # st is for recognition in the aggregation step From 712c2bddf07196d8cd476c725bab1a73b3a43424 Mon Sep 17 00:00:00 2001 From: Zhuofan Xie Date: Sat, 19 Jun 2021 13:45:05 -0400 Subject: [PATCH 30/42] Made pylint fixes --- benchmark/benchmark.py | 74 +++++++++++++++++++++++------- datatracer/column_map/basic.py | 13 +++--- datatracer/foreign_key/standard.py | 4 +- datatracer/primary_key/basic.py | 8 ++-- download.py | 2 - 5 files changed, 71 insertions(+), 30 deletions(-) delete mode 100644 download.py diff --git a/benchmark/benchmark.py b/benchmark/benchmark.py index 8d812d8..38af141 100644 --- a/benchmark/benchmark.py +++ b/benchmark/benchmark.py @@ -26,6 +26,9 @@ def download(data_dir): This downloads the benchmark datasets from S3 into the target folder in an uncompressed format. It skips datasets that have already been downloaded. + Please make sure an appropriate S3 credential is installed before you call + this method. + Args: data_dir: The directory to download the datasets to. @@ -88,9 +91,19 @@ def primary_key(solver, target, datasets): """ correct, total_pred, total_true = 0, 0, 0 - start = time() - y_pred = tracer.solve(tables) - end = time() + + try: + start = time() + y_pred = tracer.solve(tables) + end = time() + except: + return { + "precision": 0, + "recall": 0, + "f1": 0, + "inference_time": 0, + "status": "ERROR" + } for table_name, primary_key in y_true.items(): ans = y_pred.get(table_name) if isinstance(ans, str): @@ -107,7 +120,8 @@ def primary_key(solver, target, datasets): "precision": 0.0, "recall": 0.0, "f1": 0.0, - "inference_time": end - start + "inference_time": end - start, + "status": "OK" } precision = correct / total_pred recall = correct / total_true @@ -117,7 +131,8 @@ def primary_key(solver, target, datasets): "precision": precision, "recall": recall, "f1": f1, - "inference_time": end - start + "inference_time": end - start, + "status": "OK" } @@ -129,6 +144,7 @@ def benchmark_primary_key(data_dir, dataset_name=None, solver="datatracer.primar Args: data_dir: The directory containing the datasets. + dataset_name: The target dataset to test on. If none is provided, will test on all available datasets by default. solver: The name of the primary key pipeline. Returns: @@ -181,9 +197,18 @@ def foreign_key(solver, target, datasets): continue # Skip composite foreign keys y_true.add((fk["table"], fk["field"], fk["ref_table"], fk["ref_field"])) - start = time() - fk_pred = tracer.solve(tables) - end = time() + try: + start = time() + fk_pred = tracer.solve(tables) + end = time() + except: + return { + "precision": 0, + "recall": 0, + "f1": 0, + "inference_time": 0, + "status": "ERROR" + } y_pred = set() for fk in fk_pred: @@ -195,7 +220,8 @@ def foreign_key(solver, target, datasets): "precision": 0.0, "recall": 0.0, "f1": 0.0, - "inference_time": end - start + "inference_time": end - start, + "status": "OK" } precision = len(y_true.intersection(y_pred)) / len(y_pred) @@ -206,7 +232,8 @@ def foreign_key(solver, target, datasets): "precision": precision, "recall": recall, "f1": f1, - "inference_time": end - start + "inference_time": end - start, + "status": "OK" } @@ -218,6 +245,7 @@ def benchmark_foreign_key(data_dir, dataset_name=None, solver="datatracer.foreig Args: data_dir: The directory containing the datasets. + dataset_name: The target dataset to test on. If none is provided, will test on all available datasets by default. solver: The name of the foreign key pipeline. Returns: @@ -256,10 +284,21 @@ def evaluate_single_column_map(constraint, tracer, tables): for related_field in related_fields: y_true.add((related_field["table"], related_field["field"])) - start = time() - y_pred = tracer.solve(tables, target_table=field["table"], target_field=field["field"]) - y_pred = {field for field, score in y_pred.items() if score > 0.0} - end = time() + try: + start = time() + y_pred = tracer.solve(tables, target_table=field["table"], target_field=field["field"]) + y_pred = {field for field, score in y_pred.items() if score > 0.0} + end = time() + except: + return { + "table": field["table"], + "field": field["field"], + "precision": 0, + "recall": 0, + "f1": 0, + "inference_time": 0, + "status": "ERROR" + } if len(y_pred) == 0 or len(y_true) == 0 or \ len(y_true.intersection(y_pred)) == 0: @@ -269,7 +308,8 @@ def evaluate_single_column_map(constraint, tracer, tables): "precision": 0.0, "recall": 0.0, "f1": 0.0, - "inference_time": end - start + "inference_time": end - start, + "status": "OK" } else: precision = len(y_true.intersection(y_pred)) / len(y_pred) @@ -282,7 +322,8 @@ def evaluate_single_column_map(constraint, tracer, tables): "precision": precision, "recall": recall, "f1": f1, - "inference_time": end - start + "inference_time": end - start, + "status": "OK" } @dask.delayed @@ -321,6 +362,7 @@ def benchmark_column_map(data_dir, dataset_name=None, solver="datatracer.column_ Args: data_dir: The directory containing the datasets. + dataset_name: The target dataset to test on. If none is provided, will test on all available datasets by default. solver: The name of the column map pipeline. Returns: diff --git a/datatracer/column_map/basic.py b/datatracer/column_map/basic.py index 5dbb66f..402cfaf 100644 --- a/datatracer/column_map/basic.py +++ b/datatracer/column_map/basic.py @@ -24,9 +24,10 @@ def _get_importances(self, X, y): model.fit(X, y) return model.feature_importances_ - + def _convert_linear_importances(self, weights): - new_weights = (weights > self._linear_weight_threshold) / sum(weights > self._linear_weight_threshold) + new_weights = (weights > self._linear_weight_threshold) / \ + sum(weights > self._linear_weight_threshold) return new_weights @@ -55,9 +56,9 @@ def solve(self, tables, foreign_keys, target_table, target_field): transformer = Transformer(tables, foreign_keys) X, y = transformer.forward(target_table, target_field) - if len(X.shape) != 2: #invalid X shape + if len(X.shape) != 2: # invalid X shape return {} - elif X.shape[0] == 0 or X.shape[1] == 0: #empty dimension + elif X.shape[0] == 0 or X.shape[1] == 0: # empty dimension return {} try: @@ -66,7 +67,7 @@ def solve(self, tables, foreign_keys, target_table, target_field): importances = self._convert_linear_importances(reg.coef_) else: importances = self._get_importances(X, y) - except: + except BaseException: importances = self._get_importances(X, y) ret_dict = transformer.backward(importances) @@ -80,4 +81,4 @@ def solve(self, tables, foreign_keys, target_table, target_field): del new_rets[field] flag = True ret_dict = new_rets - return ret_dict \ No newline at end of file + return ret_dict diff --git a/datatracer/foreign_key/standard.py b/datatracer/foreign_key/standard.py index 38e7ab9..fd027e1 100644 --- a/datatracer/foreign_key/standard.py +++ b/datatracer/foreign_key/standard.py @@ -9,7 +9,7 @@ class StandardForeignKeySolver(ForeignKeySolver): - def __init__(self, threshold=[i/20 for i in range(20)], add_details=False, *args, **kwargs): + def __init__(self, threshold=[i / 20 for i in range(20)], add_details=False, *args, **kwargs): self._threshold = threshold self._add_details = add_details self._model_args = args @@ -105,7 +105,7 @@ def fit(self, dict_of_databases): len_true = sum(y) for threshold in self._threshold: filtered_y = (pred_y >= threshold).astype(float) - intersect = sum(filtered_y*y) + intersect = sum(filtered_y * y) len_pred = sum(filtered_y) if intersect * len_true * len_pred == 0: f1 = 0 diff --git a/datatracer/primary_key/basic.py b/datatracer/primary_key/basic.py index 3901572..7e717ea 100644 --- a/datatracer/primary_key/basic.py +++ b/datatracer/primary_key/basic.py @@ -9,7 +9,7 @@ class BasicPrimaryKeySolver(PrimaryKeySolver): - def __init__(self, threshold = [i/20 for i in range(20)], *args, **kwargs): + def __init__(self, threshold=[i / 20 for i in range(20)], *args, **kwargs): self._model_args = args self._model_kwargs = kwargs self._threshold = threshold @@ -51,7 +51,7 @@ def fit(self, dict_of_databases): else: pk = [table["primary_key"]] - primary_key = table["primary_key"] + table["primary_key"] for column in tables[table["name"]].columns: X.append(self._feature_vector(tables[table["name"]], column)) y.append(1.0 if column in pk else 0.0) @@ -68,7 +68,7 @@ def fit(self, dict_of_databases): len_true = sum(y) for threshold in self._threshold: filtered_y = (pred_y >= threshold).astype(float) - intersect = sum(filtered_y*y) + intersect = sum(filtered_y * y) len_pred = sum(filtered_y) if intersect * len_true * len_pred == 0: f1 = 0 @@ -83,7 +83,7 @@ def fit(self, dict_of_databases): def _score_all_keys(self, table): return [(column, self.model.predict([self._feature_vector(table, column)])) - for column in table.columns] + for column in table.columns] def _find_primary_key(self, table): ret = [] diff --git a/download.py b/download.py deleted file mode 100644 index 4f7b429..0000000 --- a/download.py +++ /dev/null @@ -1,2 +0,0 @@ -from benchmark.benchmark import download -download('benchmark/tracer_data') \ No newline at end of file From 4b0ea0a461e9540426528cf756949f30a7e08d15 Mon Sep 17 00:00:00 2001 From: Zhuofan Xie Date: Sat, 19 Jun 2021 14:16:45 -0400 Subject: [PATCH 31/42] Provided docstrings for data sampler module --- datatracer/data_sampler.py | 86 +++++++++++++++++++++++++++++++++++++- 1 file changed, 84 insertions(+), 2 deletions(-) diff --git a/datatracer/data_sampler.py b/datatracer/data_sampler.py index 2b0f0c2..5daea8b 100644 --- a/datatracer/data_sampler.py +++ b/datatracer/data_sampler.py @@ -12,6 +12,16 @@ def calculate_size(transformed_dataset): + """Helper function to calculate the total size of a dataset + + Args: + transformed_dataset (dict): a ``TransformedDataset`` instance, which maps (str) table name + to {'size': (float) size of the table in byte, 'row_size': (float) the size of a + row in byte, 'entries': (set) the column names, 'chosen': (set) the rows selected} + + Returns: + float: the dataset size in byte + """ size = 0 for table in transformed_dataset.values(): size += table['row_size'] * len(table['chosen']) @@ -19,6 +29,18 @@ def calculate_size(transformed_dataset): def transform_dataset(metadata, dataset): + """Pack the foreign key relations, sizes, and the rows selected of a dataset into dictionaries. + + Args: + metadata (dict): a ``MetaData`` instance + dataset (dict): maps table name to pd.DataFrame object + + Returns: + dict: a ``TransformedForeignKey`` instance, which maps (str) table name to (list(tuple)) + its associated foreign key relations + dict: a ``TransformedDataset`` instance + float: the dataset size in byte + """ fks = metadata.get_foreign_keys() transformed_fk = {} key_columns = {table_name: set() for table_name in dataset} @@ -55,6 +77,16 @@ def transform_dataset(metadata, dataset): def backward_transform(transformed_dataset, dataset): + """Transform a ``TransformedDataset`` instance back into a dictionary mapping name + to pd.DataFrame objects + + Args: + transformed_dataset (dict): a ``TransformedDataset`` instance + dataset (dict): a dictionary mapping table name to pd.DataFrame object + + Returns: + dict: a dictionary mapping table name to pd.DataFrame object + """ new_dataset = {} for table_name in dataset: idxes = list(transformed_dataset[table_name]['chosen']) @@ -63,6 +95,19 @@ def backward_transform(transformed_dataset, dataset): def remove_row(dataset, transformed_fk, transformed_dataset, table_name, idx): + """Remove a row from a table, and recursively removed all other rows associated + by some foreign key relations + + Args: + dataset (dict): a dictionary mapping table name to pd.DataFrame object + transformed_fk (dict): a ``TransformedForeignKey`` instance + transformed_dataset (dict): a ``TransformedDataset`` instance + table_name (string): the name of the table in which a row is to be removed + idx (int): the index of the row to be removed + + Returns: + None if at least of the tables is empty after the recursive removal. True otherwise. + """ if idx in transformed_dataset[table_name]['chosen']: transformed_dataset[table_name]['chosen'].remove(idx) if len(transformed_dataset[table_name]['chosen']) == 0: @@ -81,6 +126,15 @@ def remove_row(dataset, transformed_fk, transformed_dataset, table_name, idx): def get_root_tables(metadata): + """Get all root tables (tables who are never a child table in a foreign key relation) + of the dataset. + + Args: + metadata (dict): a ``MetaData`` instance + + Returns: + set: the root table names + """ all_tables = {table['name'] for table in metadata.get_tables()} for fk in metadata.get_foreign_keys(): @@ -95,6 +149,24 @@ def get_root_tables(metadata): @dask.delayed def sample_dataset(metadata=None, dataset=None, max_size=None, max_ratio=1.0, drop_threshold=None, rand_seed=0, database_name=None, dict_of_databases=None): + """Sample from a dataset + + Args: + metadata (dict): a ``MetaData`` instance. + Will be extracted from datasets if None is provided. + dataset (dict): maps table name to pd.DataFrame object. + Will be extracted from datasets if None is provided. + max_size (float): the target size to sample down to, in MB. + max_ratio (float): the target fraction to sample down to. + drop_threshold (float): the maximum dataset size allowed. + rand_seed (int): seed for random. + database_name (str): name of the dataset. + dict_of_databases (dict): maps (str) dataset name to (metadata, dataset) tuple. + + Returns: + tuple (None, str) describing the reason of dropping the dataset if it is dropped. + dict, which maps table name to pd.DataFrame object, otherwise. + """ if dict_of_databases is not None: metadata, dataset = dict_of_databases[database_name] @@ -130,11 +202,21 @@ def sample_dataset(metadata=None, dataset=None, max_size=None, max_ratio=1.0, dr return backward_transform(transformed_dataset, dataset) - return backward_transform(transformed_dataset, dataset) - def sample_datasets(dict_of_databases, max_size=None, max_ratio=1.0, drop_threshold=None, rand_seed=0): + """Sample from multiple datasets + + Args: + dict_of_databases (dict): maps (str) dataset name to (metadata, dataset) tuple. + max_size (float): the target size to sample down to, in MB. + max_ratio (float): the target fraction to sample down to. + drop_threshold (float): the maximum dataset size allowed. + rand_seed (int): seed for random. + + Returns: + dict: maps (str) dataset name to (metadata, dataset) tuple. + """ db_names = list(dict_of_databases.keys()) immediate_dict_of_db = dict_of_databases dict_of_databases = dask.delayed(dict_of_databases) From b984702cbe3672147a92c81b7f1e334d762460d9 Mon Sep 17 00:00:00 2001 From: Zhuofan Xie Date: Wed, 23 Jun 2021 17:22:44 -0400 Subject: [PATCH 32/42] Implemented confidence estimate for CMD. And updated the tests accordingly to save those outputs --- benchmark/benchmark.py | 17 +++++++++++++---- datatracer/column_map/basic.py | 19 ++++++++++++------- 2 files changed, 25 insertions(+), 11 deletions(-) diff --git a/benchmark/benchmark.py b/benchmark/benchmark.py index 38af141..23fe139 100644 --- a/benchmark/benchmark.py +++ b/benchmark/benchmark.py @@ -286,7 +286,10 @@ def evaluate_single_column_map(constraint, tracer, tables): try: start = time() - y_pred = tracer.solve(tables, target_table=field["table"], target_field=field["field"]) + ret_dict = tracer.solve(tables, target_table=field["table"], target_field=field["field"]) + y_pred = ret_dict["ans"] + linear_status = ret_dict["linear"] + confidence_estimate = ret_dict["confidence"] y_pred = {field for field, score in y_pred.items() if score > 0.0} end = time() except: @@ -297,7 +300,9 @@ def evaluate_single_column_map(constraint, tracer, tables): "recall": 0, "f1": 0, "inference_time": 0, - "status": "ERROR" + "status": "ERROR", + "linear": False, + "confidence": 0 } if len(y_pred) == 0 or len(y_true) == 0 or \ @@ -309,7 +314,9 @@ def evaluate_single_column_map(constraint, tracer, tables): "recall": 0.0, "f1": 0.0, "inference_time": end - start, - "status": "OK" + "status": "OK", + "linear": linear_status, + "confidence": confidence_estimate } else: precision = len(y_true.intersection(y_pred)) / len(y_pred) @@ -323,7 +330,9 @@ def evaluate_single_column_map(constraint, tracer, tables): "recall": recall, "f1": f1, "inference_time": end - start, - "status": "OK" + "status": "OK", + "linear": linear_status, + "confidence": confidence_estimate } @dask.delayed diff --git a/datatracer/column_map/basic.py b/datatracer/column_map/basic.py index 402cfaf..c0d5092 100644 --- a/datatracer/column_map/basic.py +++ b/datatracer/column_map/basic.py @@ -22,8 +22,9 @@ def __init__(self, threshold=0.1, *args, **kwargs): def _get_importances(self, X, y): model = RandomForestRegressor(*self._model_args, **self._model_kwargs) model.fit(X, y) + score = model.score(X, y) - return model.feature_importances_ + return model.feature_importances_, score def _convert_linear_importances(self, weights): new_weights = (weights > self._linear_weight_threshold) / \ @@ -57,18 +58,22 @@ def solve(self, tables, foreign_keys, target_table, target_field): X, y = transformer.forward(target_table, target_field) if len(X.shape) != 2: # invalid X shape - return {} + return {"ans":{}, "linear": False, "confidence": 0} elif X.shape[0] == 0 or X.shape[1] == 0: # empty dimension - return {} + return {"ans":{}, "linear": False, "confidence": 0} + linear = False try: reg = LinearRegression(fit_intercept=False).fit(X, y) - if reg.score(X, y) > self._linear_score_threshold: + score = reg.score(X, y) + if score > self._linear_score_threshold: importances = self._convert_linear_importances(reg.coef_) + linear = True + confidence = score else: - importances = self._get_importances(X, y) + importances, confidence = self._get_importances(X, y) except BaseException: - importances = self._get_importances(X, y) + importances, confidence = self._get_importances(X, y) ret_dict = transformer.backward(importances) flag = True @@ -81,4 +86,4 @@ def solve(self, tables, foreign_keys, target_table, target_field): del new_rets[field] flag = True ret_dict = new_rets - return ret_dict + return {"ans": ret_dict, "linear": linear, "confidence": confidence} From 34b837d3fbf33f2324cb130fc21503ee1484de4a Mon Sep 17 00:00:00 2001 From: Zhuofan Xie Date: Tue, 6 Jul 2021 01:01:47 -0400 Subject: [PATCH 33/42] Restrict linear maps to sum/diff/avg --- datatracer/column_map/basic.py | 82 +++++++++++++++++++++++++++++++--- 1 file changed, 77 insertions(+), 5 deletions(-) diff --git a/datatracer/column_map/basic.py b/datatracer/column_map/basic.py index c0d5092..cbda0cb 100644 --- a/datatracer/column_map/basic.py +++ b/datatracer/column_map/basic.py @@ -2,12 +2,85 @@ from sklearn.ensemble import RandomForestRegressor from sklearn.linear_model import LinearRegression +from itertools import combinations +import numpy as np +import time from datatracer.column_map.base import ColumnMapSolver from datatracer.column_map.transformer import Transformer LOGGER = logging.getLogger(__name__) +def approx_equal(num, target, add_margin, multi_margin): + if target >= 0: + return (num <= target * (1 + multi_margin) + add_margin) and (num >= target * (1 - multi_margin) - add_margin) + else: + return (num <= target * (1 - multi_margin) + add_margin) and (num >= target * (1 + multi_margin) - add_margin) + +def approx_equal_arrays(num, target, add_margin, multi_margin): + for n, t in zip(num, target): + if not approx_equal(n, t, add_margin, multi_margin): + return False + return True + +def check_sum(indicies, X, y, add_margin, multi_margin): + return approx_equal_arrays(X[:, indicies].sum(axis = 1), y, add_margin, multi_margin) + +def check_avg(indicies, X, y, add_margin, multi_margin): + return approx_equal_arrays(X[:, indicies].sum(axis = 1)/len(indicies), y, add_margin, multi_margin) + +def check_diff(indicies, X, y, add_margin, multi_margin): + pred_y = X[:, indicies[0]] - X[:, indicies[1]] + return approx_equal_arrays(pred_y, y, 0, 0) + +def detect_restricted_reg(X, y, add_margin=1e-4, mult_margin=1e-4, max_feature=5, timeout=3600): + """ + This method runs a restricted regression where the target column is either the sum + or difference of several columns in the given table, or the average of several columns + in the given table. + + Returns: + (str, tuple): a string ("sum", "diff", "avg" or "None") representing the operation, + and a tuple of coeffs. + """ + start_time = time.time() + + dot_prods = (X.T).dot(y) + length = len(dot_prods) + y2 = y.dot(y) + for num_feature in range(1, max_feature + 1): + for combo in combinations(range(length),num_feature): + if time.time() - start_time > timeout: + return "None", None + + indicies = list(combo) + if approx_equal(dot_prods[indicies].sum(), y2, add_margin, mult_margin): + if check_sum(indicies, X, y, add_margin, mult_margin): + weights = [0] * length + for ind in indicies: + weights[ind] = 1 + return "sum", weights + if (num_feature > 1) and approx_equal(dot_prods[indicies].sum()/num_feature, y2, add_margin, mult_margin): + if check_avg(indicies, X, y, add_margin, mult_margin): + weights = [0] * length + for ind in indicies: + weights[ind] = 1.0/num_feature + return "avg", weights + if num_feature == 2: + if approx_equal(dot_prods[indicies[0]] - dot_prods[indicies[1]], y2, add_margin, mult_margin): + if check_diff(indicies, X, y, add_margin, mult_margin): + weights = [0] * length + weights[indicies[0]] = 1 + weights[indicies[1]] = -1 + return "diff", weights + if approx_equal(dot_prods[indicies[1]] - dot_prods[indicies[0]], y2, add_margin, mult_margin): + if check_diff(indicies[::-1], X, y, add_margin, mult_margin): + weights = [0] * length + weights[indicies[0]] = -1 + weights[indicies[1]] = 1 + return "diff", weights + return "None", None + class BasicColumnMapSolver(ColumnMapSolver): """Basic Solver for the data lineage problem of column dependency.""" @@ -64,12 +137,11 @@ def solve(self, tables, foreign_keys, target_table, target_field): linear = False try: - reg = LinearRegression(fit_intercept=False).fit(X, y) - score = reg.score(X, y) - if score > self._linear_score_threshold: - importances = self._convert_linear_importances(reg.coef_) + restricted_linear_type, weights = detect_restricted_reg(X, y) + if restricted_linear_type != "None": + importances = self._convert_linear_importances(np.array(weights)) linear = True - confidence = score + confidence = 1 else: importances, confidence = self._get_importances(X, y) except BaseException: From cd1cce336cd691903f18efb6e7ed8df889a27763 Mon Sep 17 00:00:00 2001 From: Zhuofan Xie Date: Mon, 12 Jul 2021 23:21:43 -0400 Subject: [PATCH 34/42] Removed all experimental outputs --- benchmark/benchmark.py | 10 +--------- datatracer/column_map/basic.py | 16 ++++++---------- 2 files changed, 7 insertions(+), 19 deletions(-) diff --git a/benchmark/benchmark.py b/benchmark/benchmark.py index 23fe139..58fc329 100644 --- a/benchmark/benchmark.py +++ b/benchmark/benchmark.py @@ -287,9 +287,7 @@ def evaluate_single_column_map(constraint, tracer, tables): try: start = time() ret_dict = tracer.solve(tables, target_table=field["table"], target_field=field["field"]) - y_pred = ret_dict["ans"] - linear_status = ret_dict["linear"] - confidence_estimate = ret_dict["confidence"] + y_pred = ret_dict y_pred = {field for field, score in y_pred.items() if score > 0.0} end = time() except: @@ -301,8 +299,6 @@ def evaluate_single_column_map(constraint, tracer, tables): "f1": 0, "inference_time": 0, "status": "ERROR", - "linear": False, - "confidence": 0 } if len(y_pred) == 0 or len(y_true) == 0 or \ @@ -315,8 +311,6 @@ def evaluate_single_column_map(constraint, tracer, tables): "f1": 0.0, "inference_time": end - start, "status": "OK", - "linear": linear_status, - "confidence": confidence_estimate } else: precision = len(y_true.intersection(y_pred)) / len(y_pred) @@ -331,8 +325,6 @@ def evaluate_single_column_map(constraint, tracer, tables): "f1": f1, "inference_time": end - start, "status": "OK", - "linear": linear_status, - "confidence": confidence_estimate } @dask.delayed diff --git a/datatracer/column_map/basic.py b/datatracer/column_map/basic.py index cbda0cb..19c0c7c 100644 --- a/datatracer/column_map/basic.py +++ b/datatracer/column_map/basic.py @@ -95,9 +95,8 @@ def __init__(self, threshold=0.1, *args, **kwargs): def _get_importances(self, X, y): model = RandomForestRegressor(*self._model_args, **self._model_kwargs) model.fit(X, y) - score = model.score(X, y) - return model.feature_importances_, score + return model.feature_importances_ def _convert_linear_importances(self, weights): new_weights = (weights > self._linear_weight_threshold) / \ @@ -131,21 +130,18 @@ def solve(self, tables, foreign_keys, target_table, target_field): X, y = transformer.forward(target_table, target_field) if len(X.shape) != 2: # invalid X shape - return {"ans":{}, "linear": False, "confidence": 0} + return {} elif X.shape[0] == 0 or X.shape[1] == 0: # empty dimension - return {"ans":{}, "linear": False, "confidence": 0} + return {} - linear = False try: restricted_linear_type, weights = detect_restricted_reg(X, y) if restricted_linear_type != "None": importances = self._convert_linear_importances(np.array(weights)) - linear = True - confidence = 1 else: - importances, confidence = self._get_importances(X, y) + importances = self._get_importances(X, y) except BaseException: - importances, confidence = self._get_importances(X, y) + importances = self._get_importances(X, y) ret_dict = transformer.backward(importances) flag = True @@ -158,4 +154,4 @@ def solve(self, tables, foreign_keys, target_table, target_field): del new_rets[field] flag = True ret_dict = new_rets - return {"ans": ret_dict, "linear": linear, "confidence": confidence} + return ret_dict From 16085706939b2105f6ac388cc0b72bca5fa9e27f Mon Sep 17 00:00:00 2001 From: Zhuofan Xie Date: Mon, 19 Jul 2021 01:33:14 -0400 Subject: [PATCH 35/42] set up APIs and basic solution --- datatracer/how_lineage/__init__.py | 7 ++ datatracer/how_lineage/base.py | 39 ++++++++ datatracer/how_lineage/basic.py | 139 ++++++++++++++++++++++++++ datatracer/how_lineage/transformer.py | 127 +++++++++++++++++++++++ 4 files changed, 312 insertions(+) create mode 100644 datatracer/how_lineage/__init__.py create mode 100644 datatracer/how_lineage/base.py create mode 100644 datatracer/how_lineage/basic.py create mode 100644 datatracer/how_lineage/transformer.py diff --git a/datatracer/how_lineage/__init__.py b/datatracer/how_lineage/__init__.py new file mode 100644 index 0000000..0ccee07 --- /dev/null +++ b/datatracer/how_lineage/__init__.py @@ -0,0 +1,7 @@ +from datatracer.column_map.base import ColumnMapSolver +from datatracer.column_map.basic import BasicColumnMapSolver + +__all__ = ( + 'ColumnMapSolver', + 'BasicColumnMapSolver', +) diff --git a/datatracer/how_lineage/base.py b/datatracer/how_lineage/base.py new file mode 100644 index 0000000..d98f65f --- /dev/null +++ b/datatracer/how_lineage/base.py @@ -0,0 +1,39 @@ +"""Column Mapping base class.""" + + +class HowLineageSolver: + """Base Solver for the data lineage problem of how lineage.""" + + def fit(self, dict_of_databases): + """Fit this solver. + + Args: + dict_of_databases (dict): + Map from database names to tuples containing ``MetaData`` + instances and table dictionaries, which contain table names + as input and ``pandas.DataFrames`` as values. + """ + + def solve(self, tables, foreign_keys, target_table, target_field): + """Find the fields which contributed to the target_field the most. + + The output is a dictionary containing the fields that contributed the + most to the given target field as keys, specified as a tuple containing + both table name and field name, and the score obtained as values. + + Args: + tables (dict): + Dict containing table names as input and ``pandas.DataFrames`` + as values. + foreign_keys (list): + List of foreign key specifications. + target_table (str): + Name of the table that contains the target field. + target_field (str): + Name of the target field. + + Returns: + dict: + Dictionary of field specification tuples and scores. + """ + raise NotImplementedError() diff --git a/datatracer/how_lineage/basic.py b/datatracer/how_lineage/basic.py new file mode 100644 index 0000000..1318ab1 --- /dev/null +++ b/datatracer/how_lineage/basic.py @@ -0,0 +1,139 @@ +import logging + +from sklearn.ensemble import RandomForestRegressor +from sklearn.linear_model import LinearRegression +from itertools import combinations +import numpy as np +import time + +from datatracer.how_lineage.base import HowLineageSolver +from datatracer.how_lineage.transformer import Transformer + +LOGGER = logging.getLogger(__name__) + +def approx_equal(num, target, add_margin, multi_margin): + if target >= 0: + return (num <= target * (1 + multi_margin) + add_margin) and (num >= target * (1 - multi_margin) - add_margin) + else: + return (num <= target * (1 - multi_margin) + add_margin) and (num >= target * (1 + multi_margin) - add_margin) + +def approx_equal_arrays(num, target, add_margin, multi_margin): + for n, t in zip(num, target): + if not approx_equal(n, t, add_margin, multi_margin): + return False + return True + +def check_sum(indicies, X, y, add_margin, multi_margin): + return approx_equal_arrays(X[:, indicies].sum(axis = 1), y, add_margin, multi_margin) + +def check_avg(indicies, X, y, add_margin, multi_margin): + return approx_equal_arrays(X[:, indicies].sum(axis = 1)/len(indicies), y, add_margin, multi_margin) + +def check_diff(indicies, X, y, add_margin, multi_margin): + pred_y = X[:, indicies[0]] - X[:, indicies[1]] + return approx_equal_arrays(pred_y, y, 0, 0) + +def detect_restricted_reg(X, y, add_margin=1e-4, mult_margin=1e-4, max_feature=5, timeout=3600): + """ + This method runs a restricted regression where the target column is either the sum + or difference of several columns in the given table, or the average of several columns + in the given table. + + Returns: + (str, tuple): a string ("sum", "diff", "avg" or "None") representing the operation, + and a tuple of coeffs. + """ + start_time = time.time() + + dot_prods = (X.T).dot(y) + length = len(dot_prods) + y2 = y.dot(y) + for num_feature in range(1, max_feature + 1): + for combo in combinations(range(length),num_feature): + if time.time() - start_time > timeout: + return "None", None + + indicies = list(combo) + if approx_equal(dot_prods[indicies].sum(), y2, add_margin, mult_margin): + if check_sum(indicies, X, y, add_margin, mult_margin): + return "sum", indicies + if (num_feature > 1) and approx_equal(dot_prods[indicies].sum()/num_feature, y2, add_margin, mult_margin): + if check_avg(indicies, X, y, add_margin, mult_margin): + return "avg", indicies + if num_feature == 2: + if approx_equal(dot_prods[indicies[0]] - dot_prods[indicies[1]], y2, add_margin, mult_margin): + if check_diff(indicies, X, y, add_margin, mult_margin): + return "diff", indicies + if approx_equal(dot_prods[indicies[1]] - dot_prods[indicies[0]], y2, add_margin, mult_margin): + if check_diff(indicies[::-1], X, y, add_margin, mult_margin): + return "diff", indicies[::-1] + return "None", None + + +class BasicHowLineageSolver(HowLineageSolver): + """Basic Solver for the data lineage problem of how lineage.""" + + def __init__(self, threshold=0.1, *args, **kwargs): + self._model_args = args + self._model_kwargs = kwargs + self._threshold = threshold + self._linear_weight_threshold = 1e-4 + self._linear_score_threshold = 0.95 + + def _get_importances(self, X, y): + model = RandomForestRegressor(*self._model_args, **self._model_kwargs) + model.fit(X, y) + + return model.feature_importances_ + + def _convert_linear_importances(self, weights): + new_weights = (weights > self._linear_weight_threshold) / \ + sum(weights > self._linear_weight_threshold) + + return new_weights + + def solve(self, tables, foreign_keys, target_table, target_field): + """Find the fields which contributed to the target_field the most. + + The output is a dictionary containing the fields that contributed the + most to the given target field as keys, specified as a tuple containing + both table name and field name, and the score obtained as values. + + Args: + tables (dict): + Dict containing table names as input and ``pandas.DataFrames`` + as values. + foreign_keys (list): + List of foreign key specifications. + target_table (str): + Name of the table that contains the target field. + target_field (str): + Name of the target field. + + Returns: + dict: + Dictionary of field specification tuples and scores. + """ + transformer = Transformer(tables, foreign_keys) + + X, y = transformer.forward(target_table, target_field) + if len(X.shape) != 2: # invalid X shape + return {} + elif X.shape[0] == 0 or X.shape[1] == 0: # empty dimension + return {} + + try: + restricted_linear_type, indicies = detect_restricted_reg(X, y) + if restricted_linear_type != "None": + importances = self._convert_linear_importances(np.array(weights)) + else: + return [], "" + except BaseException: + return [], "" + + lineage = [transformer.columns[idx] for idx in indicies] + + linear_map_dict = {"sum": "datatracer.how_lineage.sum", + "diff": "datatracer.how_lineage.diff", + "avg": "datatracer.how_lineage.avg"} + return lineage, linear_map_dict[restricted_linear_type] diff --git a/datatracer/how_lineage/transformer.py b/datatracer/how_lineage/transformer.py new file mode 100644 index 0000000..119f306 --- /dev/null +++ b/datatracer/how_lineage/transformer.py @@ -0,0 +1,127 @@ +import numpy as np + + +class Transformer: + + def __init__(self, tables, foreign_keys): + """ + The `Transformer` class provides an interface between the database and + the column mapping solver. + + For example, during the forwards pass, it could take a date field and + transform into three columns - day, month, and year - which will allow + the solver to potentially identify the lineage of some target column. + + Then, during the backwards pass, it can take the scores produced by the + solver (i.e. the solver assigns scores indicating how important the day, + month, and year columns are to predicting the target column) and combine + them into a single score for the `date` field. + """ + self.tables = tables + self.foreign_keys = foreign_keys + + def forward(self, table, field): + """ + This function returns a (X, y) tuple containing numerical values which + is suitable for machine learning libraries. The `X` array contains + data from columns that are potentially related to the target field. The + `y` array contains the values of the target field. + """ + df = self.tables[table] + df = df.select_dtypes("number") + df = df.fillna(0.0) + X, y = df.drop([field], axis=1), df[field] + self.columns = [{"source_col": {"table_name": table, "col_name": col_name}, + "row_map": {}, + "aggregation": "" + } for col_name in X.columns] + X, y = X.values, y.values + + X_new, columns_new = self._get_counts(table) + if columns_new: + X = np.concatenate([X, X_new], axis=1) + self.columns.extend(columns_new) + + X_new, columns_new = self._get_aggregations(table) + if columns_new: + X = np.concatenate([X, X_new], axis=1) + self.columns.extend(columns_new) + + return X, y + + def _get_counts(self, table): + """ + Get the foreign keys where the given table is the parent. + """ + X, columns = [], [] + for fk in self.foreign_keys: + if fk["ref_table"] != table: + continue + + # Count the number of rows for each key. + child_table = self.tables[fk["table"]].copy() + child_table["_dummy_"] = 0.0 + child_counts = child_table.groupby(fk["field"]).count().iloc[:, 0:1] + child_counts.columns = ["_tmp_"] + + # Merge the counts into the parent table + parent_table = self.tables[table] + parent_table = parent_table.set_index(fk["ref_field"]) + parent_table = parent_table.join(child_counts).reset_index() + + X.append(parent_table["_tmp_"].fillna(0.0).values) + columns.append({"source_col": {"table_name": fk['table'], "col_name": fk['field']}, + "row_map": fk, + "aggregation": "datatracer.how_lineage.count" + }) + + return np.array(X).transpose(), columns + + def _get_aggregations(self, table): + """ + Get the foreign keys where the given table is the parent. + """ + X, columns = [], [] + for fk in self.foreign_keys: + if fk["ref_table"] != table: + continue + + for op, op_name, op_str in [ + (lambda x: x.sum(), "SUM", "datatracer.how_lineage.sum"), + (lambda x: x.max(), "MAX", "datatracer.how_lineage.max"), + (lambda x: x.min(), "MIN", "datatracer.how_lineage.min"), + (lambda x: x.std(), "STD", "datatracer.how_lineage.std"), + ]: + # Count the number of rows for each key. + child_table = self.tables[fk["table"]].copy() + if len(child_table.columns) <= 1: + continue + + child_counts = op(child_table.groupby(fk["field"])) + old_column_names = list(child_counts.columns) + child_counts.columns = ["%s(%s)" % (op_name, col_name) + for col_name in old_column_names] + + # Merge the counts into the parent table + parent_table = self.tables[table] + parent_table = parent_table.set_index(fk["ref_field"]) + parent_table = parent_table.join(child_counts).reset_index() + + for old_name, col_name in zip(old_column_names, child_counts.columns): + if parent_table[col_name].dtype.kind == "f": + X.append(parent_table[col_name].fillna(0.0).values) + columns.append({"source_col": {"table_name": fk['table'], "col_name": old_name}, + "row_map": fk, + "aggregation": op_str + }) + + return np.array(X).transpose(), columns + + def backward(self, feature_importances): + """ + This function takes an array of `feature_importances` which corresponds + to the `X` matrix produced by the last call to `forward`. It returns a + mapping from fields to importance scores. + """ + return [(column, importance) for column, importance in zip(self.columns, feature_importances)] + From cde0f8701fd876ccebba9ce5db4400233b36d935 Mon Sep 17 00:00:00 2001 From: Zhuofan Xie Date: Tue, 20 Jul 2021 11:20:06 -0400 Subject: [PATCH 36/42] Create pipelines for how-lineage detection --- datatracer/how_lineage/__init__.py | 8 +- datatracer/how_lineage/basic.py | 28 ++-- .../datatracer.how_lineage.basic.json | 7 + ...cer.how_lineage.BasicHowLineageSolver.json | 134 ++++++++++++++++++ 4 files changed, 164 insertions(+), 13 deletions(-) create mode 100644 datatracer/jsons/pipelines/datatracer.how_lineage.basic.json create mode 100644 datatracer/jsons/primitives/datatracer.how_lineage.BasicHowLineageSolver.json diff --git a/datatracer/how_lineage/__init__.py b/datatracer/how_lineage/__init__.py index 0ccee07..f4942f4 100644 --- a/datatracer/how_lineage/__init__.py +++ b/datatracer/how_lineage/__init__.py @@ -1,7 +1,7 @@ -from datatracer.column_map.base import ColumnMapSolver -from datatracer.column_map.basic import BasicColumnMapSolver +from datatracer.how_lineage.base import HowLineageSolver +from datatracer.how_lineage.basic import BasicHowLineageSolver __all__ = ( - 'ColumnMapSolver', - 'BasicColumnMapSolver', + 'HowLineageSolver', + 'BasicHowLineageSolver', ) diff --git a/datatracer/how_lineage/basic.py b/datatracer/how_lineage/basic.py index 1318ab1..b0c8c7d 100644 --- a/datatracer/how_lineage/basic.py +++ b/datatracer/how_lineage/basic.py @@ -117,23 +117,33 @@ def solve(self, tables, foreign_keys, target_table, target_field): transformer = Transformer(tables, foreign_keys) X, y = transformer.forward(target_table, target_field) + print(X, y) if len(X.shape) != 2: # invalid X shape - return {} + print("Encountered invalid X shape in how-lineage detection. Please check if any table is empty or if foreign keys have been provided.") + return {"lineage_columns": [], + "transformation": ""} elif X.shape[0] == 0 or X.shape[1] == 0: # empty dimension - return {} + print("Encountered invalid X shape in how-lineage detection. Please check if any table is empty or if foreign keys have been provided.") + return {"lineage_columns": [], + "transformation": ""} try: restricted_linear_type, indicies = detect_restricted_reg(X, y) - if restricted_linear_type != "None": - importances = self._convert_linear_importances(np.array(weights)) - else: - return [], "" - except BaseException: - return [], "" + if restricted_linear_type == "None": + print("Failed to detect any basic linear maps in how-lineage detection.") + return {"lineage_columns": [], + "transformation": ""} + except BaseException as e: + print("Encountered an error in how-lineage detection, though very likely a standard timeout. Error message is as below.") + print(e) + return {"lineage_columns": [], + "transformation": ""} lineage = [transformer.columns[idx] for idx in indicies] linear_map_dict = {"sum": "datatracer.how_lineage.sum", "diff": "datatracer.how_lineage.diff", "avg": "datatracer.how_lineage.avg"} - return lineage, linear_map_dict[restricted_linear_type] + + return {"lineage_columns": lineage, + "transformation": linear_map_dict[restricted_linear_type]} diff --git a/datatracer/jsons/pipelines/datatracer.how_lineage.basic.json b/datatracer/jsons/pipelines/datatracer.how_lineage.basic.json new file mode 100644 index 0000000..ab9c8f6 --- /dev/null +++ b/datatracer/jsons/pipelines/datatracer.how_lineage.basic.json @@ -0,0 +1,7 @@ +{ + "primitives": [ + "datatracer.primary_key.BasicPrimaryKeySolver", + "datatracer.foreign_key.StandardForeignKeySolver", + "datatracer.how_lineage.BasicHowLineageSolver" + ] +} diff --git a/datatracer/jsons/primitives/datatracer.how_lineage.BasicHowLineageSolver.json b/datatracer/jsons/primitives/datatracer.how_lineage.BasicHowLineageSolver.json new file mode 100644 index 0000000..959939c --- /dev/null +++ b/datatracer/jsons/primitives/datatracer.how_lineage.BasicHowLineageSolver.json @@ -0,0 +1,134 @@ +{ + "name": "datatracer.how_lineage.BasicHowLineageSolver", + "description": "Detect the how-lineage of a column.", + "primitive": "datatracer.how_lineage.BasicHowLineageSolver", + "produce": { + "method": "solve", + "args": [ + { + "name": "tables", + "type": "dict" + }, + { + "name": "foreign_keys", + "type": "dict" + }, + { + "name": "target_table", + "default": null, + "type": "str" + }, + { + "name": "target_field", + "default": null, + "type": "str" + } + ], + "output": [ + { + "name": "how_lineage", + "type": "dict" + } + ] + }, + "hyperparameters": { + "fixed": { + "n_jobs": { + "type": "int", + "default": null + }, + "verbose": { + "type": "int", + "default": 0, + "range": [ + 0, + 100 + ] + }, + "warm_start": { + "type": "bool", + "default": false + } + }, + "tunable": { + "n_estimators": { + "type": "int", + "default": 10, + "range": [ + 1, + 500 + ] + }, + "criterion": { + "type": "str", + "default": "mse", + "values": [ + "mse", + "mae" + ] + }, + "max_features": { + "type": "str", + "default": "auto", + "range": [ + null, + "auto", + "log2", + "sqrt" + ] + }, + "max_depth": { + "type": "int", + "default": null, + "range": [ + 1, + 30 + ] + }, + "min_samples_split": { + "type": "int", + "default": 2, + "range": [ + 2, + 1000 + ] + }, + "min_samples_leaf": { + "type": "int", + "default": 1, + "range": [ + 1, + 1000 + ] + }, + "min_weight_fraction_leaf": { + "type": "float", + "default": 0.0, + "range": [ + 0.0, + 100.0 + ] + }, + "max_leaf_nodes": { + "type": "int", + "default": null + }, + "min_impurity_decrease": { + "type": "float", + "default": 0.0, + "range": [ + 0.0, + 10.0 + ] + }, + "bootstrap": { + "type": "bool", + "default": true + }, + "oob_score": { + "type": "bool", + "default": false + } + } + } +} From 32f6f937174f8f2b0217fa59eae9a15dd4258df8 Mon Sep 17 00:00:00 2001 From: Zhuofan Xie Date: Fri, 23 Jul 2021 03:17:56 -0400 Subject: [PATCH 37/42] Implemented how lineage tests and reorganized benchmark module --- benchmark/benchmark.py | 357 +-------------------- benchmark/column_map_benchmark.py | 133 ++++++++ benchmark/foreign_key_benchmark.py | 111 +++++++ benchmark/how_lineage_benchmark.py | 154 +++++++++ benchmark/primary_key_benchmark.py | 124 +++++++ datatracer/__init__.py | 2 + datatracer/how_lineage/__init__.py | 11 + datatracer/how_lineage/basic.py | 7 +- datatracer/how_lineage/table_transforms.py | 35 ++ datatracer/how_lineage/transformer.py | 10 +- 10 files changed, 592 insertions(+), 352 deletions(-) create mode 100644 benchmark/column_map_benchmark.py create mode 100644 benchmark/foreign_key_benchmark.py create mode 100644 benchmark/how_lineage_benchmark.py create mode 100644 benchmark/primary_key_benchmark.py create mode 100644 datatracer/how_lineage/table_transforms.py diff --git a/benchmark/benchmark.py b/benchmark/benchmark.py index 58fc329..6da4fe2 100644 --- a/benchmark/benchmark.py +++ b/benchmark/benchmark.py @@ -15,6 +15,10 @@ from dask.diagnostics import ProgressBar from datatracer import DataTracer, load_datasets, sample_datasets +from how_lineage_benchmark import benchmark_how_lineage +from column_map_benchmark import benchmark_column_map +from foreign_key_benchmark import benchmark_foreign_key +from primary_key_benchmark import benchmark_primary_key BUCKET_NAME = 'tracer-data' DATA_URL = 'http://{}.s3.amazonaws.com/'.format(BUCKET_NAME) @@ -58,348 +62,6 @@ def download(data_dir): return pd.DataFrame(rows) -@dask.delayed -def primary_key(solver, target, datasets): - """Benchmark the primary key solver on the target dataset. - - Args: - solver: The name of the primary key pipeline. - target: The name of the target dataset. - datases: A dictionary mapping dataset names to (metadata, tables) tuples. - - Returns: - A dictionary mapping metric names to values. - """ - datasets = datasets.copy() - metadata, tables = datasets.pop(target) - - tracer = DataTracer(solver) - tracer.fit(datasets) - - y_true = {} - for table in metadata.get_tables(): - if "primary_key" not in table: - y_true[table["name"]] = set() - elif not isinstance(table["primary_key"], str): - y_true[table["name"]] = set(table["primary_key"]) - else: - y_true[table["name"]] = set([table["primary_key"]]) - - """ - if len(y_true) == 0: - return {} # Skip dataset, no primary keys found. - """ - - correct, total_pred, total_true = 0, 0, 0 - - try: - start = time() - y_pred = tracer.solve(tables) - end = time() - except: - return { - "precision": 0, - "recall": 0, - "f1": 0, - "inference_time": 0, - "status": "ERROR" - } - for table_name, primary_key in y_true.items(): - ans = y_pred.get(table_name) - if isinstance(ans, str): - ans = set([ans]) - else: - ans = set(ans) - correct += len(ans.intersection(primary_key)) - total_pred += len(ans) - total_true += len(primary_key) - - if correct == 0 or total_pred == 0 or \ - total_true == 0: - return { - "precision": 0.0, - "recall": 0.0, - "f1": 0.0, - "inference_time": end - start, - "status": "OK" - } - precision = correct / total_pred - recall = correct / total_true - f1 = 2 * precision * recall / (precision + recall) - - return { - "precision": precision, - "recall": recall, - "f1": f1, - "inference_time": end - start, - "status": "OK" - } - - -def benchmark_primary_key(data_dir, dataset_name=None, solver="datatracer.primary_key.basic"): - """Benchmark the primary key solver. - - This uses leave-one-out validation and evaluates the performance of the - solver on the specified datasets. - - Args: - data_dir: The directory containing the datasets. - dataset_name: The target dataset to test on. If none is provided, will test on all available datasets by default. - solver: The name of the primary key pipeline. - - Returns: - A DataFrame containing the benchmark resuls. - """ - datasets = load_datasets(data_dir) - dataset_names = list(datasets.keys()) - if dataset_name is not None: - if dataset_name in dataset_names: - dataset_names = [dataset_name] - else: - return None - datasets = dask.delayed(datasets) - dataset_to_metrics = {} - for dataset_name in dataset_names: - dataset_to_metrics[dataset_name] = primary_key( - solver=solver, target=dataset_name, datasets=datasets) - - with ProgressBar(): - results = dask.compute(dataset_to_metrics)[0] - for dataset_name, metrics in results.items(): - metrics["dataset"] = dataset_name - df = pd.DataFrame(list(results.values())) - dataset_col = df.pop('dataset') - df.insert(0, 'dataset', dataset_col) - return df - - -@dask.delayed -def foreign_key(solver, target, datasets): - """Benchmark the foreign key solver on the target dataset. - - Args: - solver: The name of the foreign key pipeline. - target: The name of the target dataset. - datasets: A dictionary mapping dataset names to (metadata, tables) tuples. - - Returns: - A dictionary mapping metric names to values. - """ - datasets = datasets.copy() - metadata, tables = datasets.pop(target) - - tracer = DataTracer(solver) - tracer.fit(datasets) - - y_true = set() - for fk in metadata.get_foreign_keys(): - if not isinstance(fk["field"], str): - continue # Skip composite foreign keys - y_true.add((fk["table"], fk["field"], fk["ref_table"], fk["ref_field"])) - - try: - start = time() - fk_pred = tracer.solve(tables) - end = time() - except: - return { - "precision": 0, - "recall": 0, - "f1": 0, - "inference_time": 0, - "status": "ERROR" - } - - y_pred = set() - for fk in fk_pred: - y_pred.add((fk["table"], fk["field"], fk["ref_table"], fk["ref_field"])) - - if len(y_pred) == 0 or len(y_true) == 0 or \ - len(y_true.intersection(y_pred)) == 0: - return { - "precision": 0.0, - "recall": 0.0, - "f1": 0.0, - "inference_time": end - start, - "status": "OK" - } - - precision = len(y_true.intersection(y_pred)) / len(y_pred) - recall = len(y_true.intersection(y_pred)) / len(y_true) - f1 = 2.0 * precision * recall / (precision + recall) - - return { - "precision": precision, - "recall": recall, - "f1": f1, - "inference_time": end - start, - "status": "OK" - } - - -def benchmark_foreign_key(data_dir, dataset_name=None, solver="datatracer.foreign_key.standard"): - """Benchmark the foreign key solver. - - This uses leave-one-out validation and evaluates the performance of the - solver on the specified datasets. - - Args: - data_dir: The directory containing the datasets. - dataset_name: The target dataset to test on. If none is provided, will test on all available datasets by default. - solver: The name of the foreign key pipeline. - - Returns: - A DataFrame containing the benchmark resuls. - """ - datasets = load_datasets(data_dir) - #datasets = sample_datasets(datasets, max_size=20) - dataset_names = list(datasets.keys()) - if dataset_name is not None: - if dataset_name in dataset_names: - dataset_names = [dataset_name] - else: - return None - datasets = dask.delayed(datasets) - dataset_to_metrics = {} - for dataset_name in dataset_names: - dataset_to_metrics[dataset_name] = foreign_key( - solver=solver, target=dataset_name, datasets=datasets) - - with ProgressBar(): - results = dask.compute(dataset_to_metrics)[0] - for dataset_name, metrics in results.items(): - metrics["dataset"] = dataset_name - df = pd.DataFrame(list(results.values())) - dataset_col = df.pop('dataset') - df.insert(0, 'dataset', dataset_col) - return df - - -@dask.delayed -def evaluate_single_column_map(constraint, tracer, tables): - field = constraint["fields_under_consideration"][0] - related_fields = constraint["related_fields"] - - y_true = set() - for related_field in related_fields: - y_true.add((related_field["table"], related_field["field"])) - - try: - start = time() - ret_dict = tracer.solve(tables, target_table=field["table"], target_field=field["field"]) - y_pred = ret_dict - y_pred = {field for field, score in y_pred.items() if score > 0.0} - end = time() - except: - return { - "table": field["table"], - "field": field["field"], - "precision": 0, - "recall": 0, - "f1": 0, - "inference_time": 0, - "status": "ERROR", - } - - if len(y_pred) == 0 or len(y_true) == 0 or \ - len(y_true.intersection(y_pred)) == 0: - return { - "table": field["table"], - "field": field["field"], - "precision": 0.0, - "recall": 0.0, - "f1": 0.0, - "inference_time": end - start, - "status": "OK", - } - else: - precision = len(y_true.intersection(y_pred)) / len(y_pred) - recall = len(y_true.intersection(y_pred)) / len(y_true) - f1 = 2.0 * precision * recall / (precision + recall) - - return { - "table": field["table"], - "field": field["field"], - "precision": precision, - "recall": recall, - "f1": f1, - "inference_time": end - start, - "status": "OK", - } - -@dask.delayed -def column_map(solver, target, datasets): - """Benchmark the column map solver on the target dataset. - - Args: - solver: The name of the column map pipeline. - target: The name of the target dataset. - datases: A dictionary mapping dataset names to (metadata, tables) tuples. - - Returns: - A list of dictionaries mapping metric names to values for each deived column. - """ - datasets = datasets.copy() - metadata, tables = datasets.pop(target) - if not metadata.data.get("constraints"): - return {} # Skip dataset, no constraints found. - - tracer = DataTracer(solver) - tracer.fit(datasets) - - list_of_metrics = [] - for constraint in metadata.data["constraints"]: - list_of_metrics.append(evaluate_single_column_map(constraint, tracer, tables)) - - list_of_metrics = dask.compute(list_of_metrics)[0] - return list_of_metrics - - -def benchmark_column_map(data_dir, dataset_name=None, solver="datatracer.column_map.basic"): - """Benchmark the column map solver. - - This uses leave-one-out validation and evaluates the performance of the - solver on the specified datasets. - - Args: - data_dir: The directory containing the datasets. - dataset_name: The target dataset to test on. If none is provided, will test on all available datasets by default. - solver: The name of the column map pipeline. - - Returns: - A DataFrame containing the benchmark resuls. - """ - datasets = load_datasets(data_dir) - #datasets = sample_datasets(datasets, max_size=20) - dataset_names = list(datasets.keys()) - if dataset_name is not None: - if dataset_name in dataset_names: - dataset_names = [dataset_name] - else: - return None - datasets = dask.delayed(datasets) - dataset_to_metrics = {} - for dataset_name in dataset_names: - dataset_to_metrics[dataset_name] = column_map( - solver=solver, target=dataset_name, datasets=datasets) - - rows = [] - with ProgressBar(): - results = dask.compute(dataset_to_metrics)[0] - for dataset_name, list_of_metrics in results.items(): - for metrics in list_of_metrics: - metrics["dataset"] = dataset_name - rows.append(metrics) - df = pd.DataFrame(rows) - dataset_col = df.pop('dataset') - table_col = df.pop('table') - field_col = df.pop('field') - df.insert(0, 'field', field_col) - df.insert(0, 'table', table_col) - df.insert(0, 'dataset', dataset_col) - return df - - def start_with(target, source): return len(source) <= len(target) and target[:len(source)] == source @@ -481,6 +143,13 @@ def _get_parser(): ) subparser.set_defaults(command=benchmark_column_map) + subparser = command.add_parser( + 'how', + parents=[shared_args], + help='How lineage benchmark.' + ) + subparser.set_defaults(command=benchmark_how_lineage) + subparser = command.add_parser( 'aggregate', parents=[shared_args], @@ -505,11 +174,13 @@ def main(): df = args.command(args.data_dir, args.ds_name, solver=args.primitive) cmd_abbrv = { 'column': 'ColMap_', 'foreign': 'ForeignKey_', - 'primary': 'PrimaryKey_' + 'primary': 'PrimaryKey_', + 'how': 'HowLineage_' } cmd_str = { benchmark_column_map: 'ColMap_', benchmark_foreign_key: 'ForeignKey_', benchmark_primary_key: 'PrimaryKey_', + benchmark_how_lineage: 'HowLineage_', aggregate: cmd_abbrv[args.problem] if args.problem in cmd_abbrv else '' } csv_name = "st_" + args.ds_name + ".csv" if args.ds_name else args.csv diff --git a/benchmark/column_map_benchmark.py b/benchmark/column_map_benchmark.py new file mode 100644 index 0000000..9322a57 --- /dev/null +++ b/benchmark/column_map_benchmark.py @@ -0,0 +1,133 @@ +import time +from time import ctime, time + +import dask +import pandas as pd +from dask.diagnostics import ProgressBar + +import datatracer +from datatracer import DataTracer, load_datasets + +@dask.delayed +def evaluate_single_column_map(constraint, tracer, tables): + field = constraint["fields_under_consideration"][0] + related_fields = constraint["related_fields"] + + y_true = set() + for related_field in related_fields: + y_true.add((related_field["table"], related_field["field"])) + + try: + start = time() + ret_dict = tracer.solve(tables, target_table=field["table"], target_field=field["field"]) + y_pred = ret_dict + y_pred = {field for field, score in y_pred.items() if score > 0.0} + end = time() + except: + return { + "table": field["table"], + "field": field["field"], + "precision": 0, + "recall": 0, + "f1": 0, + "inference_time": 0, + "status": "ERROR", + } + + if len(y_pred) == 0 or len(y_true) == 0 or \ + len(y_true.intersection(y_pred)) == 0: + return { + "table": field["table"], + "field": field["field"], + "precision": 0.0, + "recall": 0.0, + "f1": 0.0, + "inference_time": end - start, + "status": "OK", + } + else: + precision = len(y_true.intersection(y_pred)) / len(y_pred) + recall = len(y_true.intersection(y_pred)) / len(y_true) + f1 = 2.0 * precision * recall / (precision + recall) + + return { + "table": field["table"], + "field": field["field"], + "precision": precision, + "recall": recall, + "f1": f1, + "inference_time": end - start, + "status": "OK", + } + +@dask.delayed +def column_map(solver, target, datasets): + """Benchmark the column map solver on the target dataset. + + Args: + solver: The name of the column map pipeline. + target: The name of the target dataset. + datases: A dictionary mapping dataset names to (metadata, tables) tuples. + + Returns: + A list of dictionaries mapping metric names to values for each deived column. + """ + datasets = datasets.copy() + metadata, tables = datasets.pop(target) + if not metadata.data.get("constraints"): + return {} # Skip dataset, no constraints found. + + tracer = DataTracer(solver) + tracer.fit(datasets) + + list_of_metrics = [] + for constraint in metadata.data["constraints"]: + list_of_metrics.append(evaluate_single_column_map(constraint, tracer, tables)) + + list_of_metrics = dask.compute(list_of_metrics)[0] + return list_of_metrics + + +def benchmark_column_map(data_dir, dataset_name=None, solver="datatracer.column_map.basic"): + """Benchmark the column map solver. + + This uses leave-one-out validation and evaluates the performance of the + solver on the specified datasets. + + Args: + data_dir: The directory containing the datasets. + dataset_name: The target dataset to test on. If none is provided, will test on all available datasets by default. + solver: The name of the column map pipeline. + + Returns: + A DataFrame containing the benchmark resuls. + """ + datasets = load_datasets(data_dir) + #datasets = sample_datasets(datasets, max_size=20) + dataset_names = list(datasets.keys()) + if dataset_name is not None: + if dataset_name in dataset_names: + dataset_names = [dataset_name] + else: + return None + datasets = dask.delayed(datasets) + dataset_to_metrics = {} + for dataset_name in dataset_names: + dataset_to_metrics[dataset_name] = column_map( + solver=solver, target=dataset_name, datasets=datasets) + + rows = [] + with ProgressBar(): + results = dask.compute(dataset_to_metrics)[0] + for dataset_name, list_of_metrics in results.items(): + for metrics in list_of_metrics: + metrics["dataset"] = dataset_name + rows.append(metrics) + df = pd.DataFrame(rows) + dataset_col = df.pop('dataset') + table_col = df.pop('table') + field_col = df.pop('field') + df.insert(0, 'field', field_col) + df.insert(0, 'table', table_col) + df.insert(0, 'dataset', dataset_col) + return df \ No newline at end of file diff --git a/benchmark/foreign_key_benchmark.py b/benchmark/foreign_key_benchmark.py new file mode 100644 index 0000000..36c7ea8 --- /dev/null +++ b/benchmark/foreign_key_benchmark.py @@ -0,0 +1,111 @@ +import time +from time import ctime, time + +import dask +import pandas as pd +from dask.diagnostics import ProgressBar + +import datatracer +from datatracer import DataTracer, load_datasets + + +@dask.delayed +def foreign_key(solver, target, datasets): + """Benchmark the foreign key solver on the target dataset. + + Args: + solver: The name of the foreign key pipeline. + target: The name of the target dataset. + datasets: A dictionary mapping dataset names to (metadata, tables) tuples. + + Returns: + A dictionary mapping metric names to values. + """ + datasets = datasets.copy() + metadata, tables = datasets.pop(target) + + tracer = DataTracer(solver) + tracer.fit(datasets) + + y_true = set() + for fk in metadata.get_foreign_keys(): + if not isinstance(fk["field"], str): + continue # Skip composite foreign keys + y_true.add((fk["table"], fk["field"], fk["ref_table"], fk["ref_field"])) + + try: + start = time() + fk_pred = tracer.solve(tables) + end = time() + except: + return { + "precision": 0, + "recall": 0, + "f1": 0, + "inference_time": 0, + "status": "ERROR" + } + + y_pred = set() + for fk in fk_pred: + y_pred.add((fk["table"], fk["field"], fk["ref_table"], fk["ref_field"])) + + if len(y_pred) == 0 or len(y_true) == 0 or \ + len(y_true.intersection(y_pred)) == 0: + return { + "precision": 0.0, + "recall": 0.0, + "f1": 0.0, + "inference_time": end - start, + "status": "OK" + } + + precision = len(y_true.intersection(y_pred)) / len(y_pred) + recall = len(y_true.intersection(y_pred)) / len(y_true) + f1 = 2.0 * precision * recall / (precision + recall) + + return { + "precision": precision, + "recall": recall, + "f1": f1, + "inference_time": end - start, + "status": "OK" + } + + +def benchmark_foreign_key(data_dir, dataset_name=None, solver="datatracer.foreign_key.standard"): + """Benchmark the foreign key solver. + + This uses leave-one-out validation and evaluates the performance of the + solver on the specified datasets. + + Args: + data_dir: The directory containing the datasets. + dataset_name: The target dataset to test on. If none is provided, will test on all available datasets by default. + solver: The name of the foreign key pipeline. + + Returns: + A DataFrame containing the benchmark resuls. + """ + datasets = load_datasets(data_dir) + #datasets = sample_datasets(datasets, max_size=20) + dataset_names = list(datasets.keys()) + if dataset_name is not None: + if dataset_name in dataset_names: + dataset_names = [dataset_name] + else: + return None + datasets = dask.delayed(datasets) + dataset_to_metrics = {} + for dataset_name in dataset_names: + dataset_to_metrics[dataset_name] = foreign_key( + solver=solver, target=dataset_name, datasets=datasets) + + with ProgressBar(): + results = dask.compute(dataset_to_metrics)[0] + for dataset_name, metrics in results.items(): + metrics["dataset"] = dataset_name + df = pd.DataFrame(list(results.values())) + dataset_col = df.pop('dataset') + df.insert(0, 'dataset', dataset_col) + return df diff --git a/benchmark/how_lineage_benchmark.py b/benchmark/how_lineage_benchmark.py new file mode 100644 index 0000000..00f87e4 --- /dev/null +++ b/benchmark/how_lineage_benchmark.py @@ -0,0 +1,154 @@ +import time +from time import ctime, time + +import dask +import pandas as pd +from dask.diagnostics import ProgressBar + +import datatracer +from datatracer import DataTracer, load_datasets + +def transform_single_column(tables, column_info): + aggregation = column_info['aggregation'] + column_name = column_info['source_col']['col_name'] + fk = column_info['row_map'] + if aggregation: + transformer = eval(aggregation) + return transformer(tables, fk, column_name) + else: + return tables[column_info['source_col']['table_name']][column_name].fillna(0.0).values + +def produce_target_column(tables, map_info): + transformation = map_info['transformation'] + if transformation: + transformed_columns = [] + for col_info in map_info['lineage_columns']: + transformed_columns.append(transform_single_column(tables, col_info)) + transformer = eval(transformation) + return transformer(transformed_columns) + else: + return None + +def approx_equal(num, target, add_margin, multi_margin): + if target >= 0: + return (num <= target * (1 + multi_margin) + add_margin) and (num >= target * (1 - multi_margin) - add_margin) + else: + return (num <= target * (1 - multi_margin) + add_margin) and (num >= target * (1 + multi_margin) - add_margin) + +def approx_equal_arrays(num, target, add_margin, multi_margin): + for n, t in zip(num, target): + if not approx_equal(n, t, add_margin, multi_margin): + return False + return True + +@dask.delayed +def evaluate_single_lineage(constraint, tracer, tables): + field = constraint["fields_under_consideration"][0] + related_fields = constraint["related_fields"] + + y_true = set() + for related_field in related_fields: + y_true.add((related_field["table"], related_field["field"])) + + try: + start = time() + ret_dict = tracer.solve(tables, target_table=field["table"], target_field=field["field"]) + y_pred = {(col['source_col']['table_name'], col['source_col']['col_name']) for col in ret_dict['lineage_columns']} + end = time() + except: + return { + "table": field["table"], + "field": field["field"], + "precision": 0, + "inference_time": 0, + "status": "ERROR", + } + + if len(y_pred) == len(y_true) and \ + len(y_true.intersection(y_pred)) == len(y_pred): + predicted_target = produce_target_column(tables, ret_dict) + target_column = tables[field["table"]][field["field"]].fillna(0.0).values + if approx_equal_arrays(predicted_target, target_column, 1e-8, 1e-8): + precision = 1 + else: + precision = 0 + else: + precision = 0 + return { + "table": field["table"], + "field": field["field"], + "precision": precision, + "inference_time": end-start, + "status": "OK", + } + +@dask.delayed +def how_lineage(solver, target, datasets): + """Benchmark the how lineage solver on the target dataset. + + Args: + solver: The name of the how lineage pipeline. + target: The name of the target dataset. + datases: A dictionary mapping dataset names to (metadata, tables) tuples. + + Returns: + A list of dictionaries mapping metric names to values for each deived column. + """ + datasets = datasets.copy() + metadata, tables = datasets.pop(target) + if not metadata.data.get("constraints"): + return {} # Skip dataset, no constraints found. + + tracer = DataTracer(solver) + tracer.fit(datasets) + + list_of_metrics = [] + for constraint in metadata.data["constraints"]: + list_of_metrics.append(evaluate_single_lineage(constraint, tracer, tables)) + + list_of_metrics = dask.compute(list_of_metrics)[0] + return list_of_metrics + + +def benchmark_how_lineage(data_dir, dataset_name=None, solver="datatracer.how_lineage.basic"): + """Benchmark the how lineage solver. + + This uses leave-one-out validation and evaluates the performance of the + solver on the specified datasets. + + Args: + data_dir: The directory containing the datasets. + dataset_name: The target dataset to test on. If none is provided, will test on all available datasets by default. + solver: The name of the column map pipeline. + + Returns: + A DataFrame containing the benchmark resuls. + """ + datasets = load_datasets(data_dir) + dataset_names = list(datasets.keys()) + if dataset_name is not None: + if dataset_name in dataset_names: + dataset_names = [dataset_name] + else: + return None + datasets = dask.delayed(datasets) + dataset_to_metrics = {} + for dataset_name in dataset_names: + dataset_to_metrics[dataset_name] = how_lineage( + solver=solver, target=dataset_name, datasets=datasets) + + rows = [] + with ProgressBar(): + results = dask.compute(dataset_to_metrics)[0] + for dataset_name, list_of_metrics in results.items(): + for metrics in list_of_metrics: + metrics["dataset"] = dataset_name + rows.append(metrics) + df = pd.DataFrame(rows) + dataset_col = df.pop('dataset') + table_col = df.pop('table') + field_col = df.pop('field') + df.insert(0, 'field', field_col) + df.insert(0, 'table', table_col) + df.insert(0, 'dataset', dataset_col) + return df \ No newline at end of file diff --git a/benchmark/primary_key_benchmark.py b/benchmark/primary_key_benchmark.py new file mode 100644 index 0000000..a4faf96 --- /dev/null +++ b/benchmark/primary_key_benchmark.py @@ -0,0 +1,124 @@ +import time +from time import ctime, time + +import dask +import pandas as pd +from dask.diagnostics import ProgressBar + +import datatracer +from datatracer import DataTracer, load_datasets + + +@dask.delayed +def primary_key(solver, target, datasets): + """Benchmark the primary key solver on the target dataset. + + Args: + solver: The name of the primary key pipeline. + target: The name of the target dataset. + datases: A dictionary mapping dataset names to (metadata, tables) tuples. + + Returns: + A dictionary mapping metric names to values. + """ + datasets = datasets.copy() + metadata, tables = datasets.pop(target) + + tracer = DataTracer(solver) + tracer.fit(datasets) + + y_true = {} + for table in metadata.get_tables(): + if "primary_key" not in table: + y_true[table["name"]] = set() + elif not isinstance(table["primary_key"], str): + y_true[table["name"]] = set(table["primary_key"]) + else: + y_true[table["name"]] = set([table["primary_key"]]) + + """ + if len(y_true) == 0: + return {} # Skip dataset, no primary keys found. + """ + + correct, total_pred, total_true = 0, 0, 0 + + try: + start = time() + y_pred = tracer.solve(tables) + end = time() + except: + return { + "precision": 0, + "recall": 0, + "f1": 0, + "inference_time": 0, + "status": "ERROR" + } + for table_name, primary_key in y_true.items(): + ans = y_pred.get(table_name) + if isinstance(ans, str): + ans = set([ans]) + else: + ans = set(ans) + correct += len(ans.intersection(primary_key)) + total_pred += len(ans) + total_true += len(primary_key) + + if correct == 0 or total_pred == 0 or \ + total_true == 0: + return { + "precision": 0.0, + "recall": 0.0, + "f1": 0.0, + "inference_time": end - start, + "status": "OK" + } + precision = correct / total_pred + recall = correct / total_true + f1 = 2 * precision * recall / (precision + recall) + + return { + "precision": precision, + "recall": recall, + "f1": f1, + "inference_time": end - start, + "status": "OK" + } + + +def benchmark_primary_key(data_dir, dataset_name=None, solver="datatracer.primary_key.basic"): + """Benchmark the primary key solver. + + This uses leave-one-out validation and evaluates the performance of the + solver on the specified datasets. + + Args: + data_dir: The directory containing the datasets. + dataset_name: The target dataset to test on. If none is provided, will test on all available datasets by default. + solver: The name of the primary key pipeline. + + Returns: + A DataFrame containing the benchmark resuls. + """ + datasets = load_datasets(data_dir) + dataset_names = list(datasets.keys()) + if dataset_name is not None: + if dataset_name in dataset_names: + dataset_names = [dataset_name] + else: + return None + datasets = dask.delayed(datasets) + dataset_to_metrics = {} + for dataset_name in dataset_names: + dataset_to_metrics[dataset_name] = primary_key( + solver=solver, target=dataset_name, datasets=datasets) + + with ProgressBar(): + results = dask.compute(dataset_to_metrics)[0] + for dataset_name, metrics in results.items(): + metrics["dataset"] = dataset_name + df = pd.DataFrame(list(results.values())) + dataset_col = df.pop('dataset') + df.insert(0, 'dataset', dataset_col) + return df \ No newline at end of file diff --git a/datatracer/__init__.py b/datatracer/__init__.py index 55588ac..7e6dd8e 100644 --- a/datatracer/__init__.py +++ b/datatracer/__init__.py @@ -13,6 +13,7 @@ from datatracer.core import PRETRAINED_DIR, DataTracer from datatracer.data import get_demo_data, load_dataset, load_datasets from datatracer.data_sampler import sample_datasets +import datatracer.how_lineage _BASE_PATH = os.path.abspath(os.path.dirname(__file__)) _JSONS_PATH = os.path.join(_BASE_PATH, 'jsons') @@ -28,6 +29,7 @@ 'load_dataset', 'load_datasets', 'sample_datasets', + 'how_lineage' ) diff --git a/datatracer/how_lineage/__init__.py b/datatracer/how_lineage/__init__.py index f4942f4..65da3b8 100644 --- a/datatracer/how_lineage/__init__.py +++ b/datatracer/how_lineage/__init__.py @@ -1,7 +1,18 @@ from datatracer.how_lineage.base import HowLineageSolver from datatracer.how_lineage.basic import BasicHowLineageSolver +from datatracer.how_lineage.table_transforms import entries_count,\ + entries_sum, entries_min, entries_max, entries_std, columns_avg,\ + columns_diff, columns_sum __all__ = ( 'HowLineageSolver', 'BasicHowLineageSolver', + 'entries_count', + 'entries_sum', + 'entries_min', + 'entries_max', + 'entries_std', + 'columns_sum', + 'columns_diff', + 'columns_avg' ) diff --git a/datatracer/how_lineage/basic.py b/datatracer/how_lineage/basic.py index b0c8c7d..a5c9b8e 100644 --- a/datatracer/how_lineage/basic.py +++ b/datatracer/how_lineage/basic.py @@ -117,7 +117,6 @@ def solve(self, tables, foreign_keys, target_table, target_field): transformer = Transformer(tables, foreign_keys) X, y = transformer.forward(target_table, target_field) - print(X, y) if len(X.shape) != 2: # invalid X shape print("Encountered invalid X shape in how-lineage detection. Please check if any table is empty or if foreign keys have been provided.") return {"lineage_columns": [], @@ -141,9 +140,9 @@ def solve(self, tables, foreign_keys, target_table, target_field): lineage = [transformer.columns[idx] for idx in indicies] - linear_map_dict = {"sum": "datatracer.how_lineage.sum", - "diff": "datatracer.how_lineage.diff", - "avg": "datatracer.how_lineage.avg"} + linear_map_dict = {"sum": "datatracer.how_lineage.columns_sum", + "diff": "datatracer.how_lineage.columns_diff", + "avg": "datatracer.how_lineage.columns_avg"} return {"lineage_columns": lineage, "transformation": linear_map_dict[restricted_linear_type]} diff --git a/datatracer/how_lineage/table_transforms.py b/datatracer/how_lineage/table_transforms.py new file mode 100644 index 0000000..7f231ac --- /dev/null +++ b/datatracer/how_lineage/table_transforms.py @@ -0,0 +1,35 @@ +def transform(tables, fk, col_name, op): + child_table = tables[fk["table"]].copy() + parent_table = tables[fk["ref_table"]].copy() + child_table["_dummy_"] = 0.0 + if len(child_table.columns) <= 1: + raise ValueError("Invalid lineage table/transformation combo!") + result_col = op(child_table.groupby(fk["field"]))[col_name] + parent_table = parent_table.set_index(fk["ref_field"]) + parent_table = parent_table.join(result_col).reset_index() + + return parent_table[col_name].fillna(0.0).values + +def entries_count(tables, fk, col_name): + return transform(tables, fk, '_dummy_', lambda x: x.count()) + +def entries_sum(tables, fk, col_name): + return transform(tables, fk, col_name, lambda x: x.sum()) + +def entries_min(tables, fk, col_name): + return transform(tables, fk, col_name, lambda x: x.min()) + +def entries_max(tables, fk, col_name): + return transform(tables, fk, col_name, lambda x: x.max()) + +def entries_std(tables, fk, col_name): + return transform(tables, fk, col_name, lambda x: x.std()) + +def columns_sum(columns): + return sum(columns) + +def columns_diff(columns): + return column[0] - column[1] + +def columns_avg(columns): + return sum(columns)/len(columns) \ No newline at end of file diff --git a/datatracer/how_lineage/transformer.py b/datatracer/how_lineage/transformer.py index 119f306..f080c9d 100644 --- a/datatracer/how_lineage/transformer.py +++ b/datatracer/how_lineage/transformer.py @@ -72,7 +72,7 @@ def _get_counts(self, table): X.append(parent_table["_tmp_"].fillna(0.0).values) columns.append({"source_col": {"table_name": fk['table'], "col_name": fk['field']}, "row_map": fk, - "aggregation": "datatracer.how_lineage.count" + "aggregation": "datatracer.how_lineage.entries_count" }) return np.array(X).transpose(), columns @@ -87,10 +87,10 @@ def _get_aggregations(self, table): continue for op, op_name, op_str in [ - (lambda x: x.sum(), "SUM", "datatracer.how_lineage.sum"), - (lambda x: x.max(), "MAX", "datatracer.how_lineage.max"), - (lambda x: x.min(), "MIN", "datatracer.how_lineage.min"), - (lambda x: x.std(), "STD", "datatracer.how_lineage.std"), + (lambda x: x.sum(), "SUM", "datatracer.how_lineage.entries_sum"), + (lambda x: x.max(), "MAX", "datatracer.how_lineage.entries_max"), + (lambda x: x.min(), "MIN", "datatracer.how_lineage.entries_min"), + (lambda x: x.std(), "STD", "datatracer.how_lineage.entries_std"), ]: # Count the number of rows for each key. child_table = self.tables[fk["table"]].copy() From 7cdedd8a5f5a318aaf33be542dfc06a930e1500c Mon Sep 17 00:00:00 2001 From: Zhuofan Xie Date: Thu, 29 Jul 2021 17:45:40 -0400 Subject: [PATCH 38/42] Add benchmark to test and fixed all code styles --- Makefile | 8 +-- benchmark/benchmark.py | 70 ++++++++++------------ benchmark/column_map_benchmark.py | 11 ++-- benchmark/foreign_key_benchmark.py | 7 +-- benchmark/how_lineage_benchmark.py | 38 +++++++----- benchmark/primary_key_benchmark.py | 9 ++- datatracer/__init__.py | 1 - datatracer/column_map/basic.py | 45 ++++++++------ datatracer/how_lineage/__init__.py | 6 +- datatracer/how_lineage/basic.py | 59 +++++++++++------- datatracer/how_lineage/table_transforms.py | 14 ++++- datatracer/how_lineage/transformer.py | 25 ++++---- 12 files changed, 163 insertions(+), 130 deletions(-) diff --git a/Makefile b/Makefile index dff857c..6f5bbe7 100644 --- a/Makefile +++ b/Makefile @@ -87,13 +87,13 @@ install-develop: clean-build clean-pyc ## install the package in editable mode a .PHONY: lint lint: ## check style with flake8 and isort flake8 datatracer tests - isort -c --recursive datatracer tests + isort -c --recursive datatracer tests benchmark .PHONY: fix-lint fix-lint: ## fix lint issues using autoflake, autopep8, and isort - find datatracer tests -name '*.py' | xargs autoflake --in-place --remove-all-unused-imports --remove-unused-variables - autopep8 --in-place --recursive --aggressive datatracer tests - isort --apply --atomic --recursive datatracer tests + find datatracer tests benchmark -name '*.py' | xargs autoflake --in-place --remove-all-unused-imports --remove-unused-variables + autopep8 --in-place --recursive --aggressive datatracer tests benchmark + isort --apply --atomic --recursive datatracer tests benchmark # TEST TARGETS diff --git a/benchmark/benchmark.py b/benchmark/benchmark.py index 6da4fe2..5400bd2 100644 --- a/benchmark/benchmark.py +++ b/benchmark/benchmark.py @@ -1,23 +1,17 @@ import argparse import os -import queue -import threading -import time from io import BytesIO -from time import ctime, time +from time import ctime from urllib.parse import urljoin from urllib.request import urlopen from zipfile import ZipFile import boto3 -import dask import pandas as pd -from dask.diagnostics import ProgressBar -from datatracer import DataTracer, load_datasets, sample_datasets -from how_lineage_benchmark import benchmark_how_lineage from column_map_benchmark import benchmark_column_map from foreign_key_benchmark import benchmark_foreign_key +from how_lineage_benchmark import benchmark_how_lineage from primary_key_benchmark import benchmark_primary_key BUCKET_NAME = 'tracer-data' @@ -27,7 +21,7 @@ def download(data_dir): """Download benchmark datasets from S3. - This downloads the benchmark datasets from S3 into the target folder in an + This downloads the benchmark datasets from S3 into the target folder in an uncompressed format. It skips datasets that have already been downloaded. Please make sure an appropriate S3 credential is installed before you call @@ -67,45 +61,45 @@ def start_with(target, source): def aggregate(cmd_name): - cmd_abbrv = { 'column': 'ColMap_st', - 'foreign': 'ForeignKey_st', - 'primary': 'PrimaryKey_st' - } + cmd_abbrv = {'column': 'ColMap_st', + 'foreign': 'ForeignKey_st', + 'primary': 'PrimaryKey_st' + } if cmd_name not in cmd_abbrv: print("Invalid command name!") - return None #invalid command name + return None # invalid command name cmd_name = cmd_abbrv[cmd_name] dfs = [] for file in os.listdir("Reports"): if start_with(file, cmd_name): - dfs.append(pd.read_csv("Reports/"+file)) + dfs.append(pd.read_csv("Reports/" + file)) if len(dfs) == 0: print("No available test results!") return None df = pd.concat(dfs, axis=0, ignore_index=True) - os.system("rm Reports/"+cmd_name+"*") #Clean up the caches + os.system("rm Reports/" + cmd_name + "*") # Clean up the caches return df def _get_parser(): shared_args = argparse.ArgumentParser(add_help=False) - shared_args.add_argument('--data_dir', type=str, - default=os.path.expanduser("~/tracer_data"), required=False, - help='Path to the benchmark datasets.') + shared_args.add_argument('--data_dir', type=str, + default=os.path.expanduser("~/tracer_data"), required=False, + help='Path to the benchmark datasets.') default_csv = "report_" + ctime().replace(" ", "_") + ".csv" default_csv = default_csv.replace(":", "_") shared_args.add_argument('--csv', type=str, - default=os.path.expanduser(default_csv), required=False, - help='Path to the CSV file where the report will be written.') + default=os.path.expanduser(default_csv), required=False, + help='Path to the CSV file where the report will be written.') shared_args.add_argument('--ds_name', type=str, - default=None, required=False, - help='Name of the dataset to test on. Default is all available datasets.') + default=None, required=False, + help='Name of the dataset to test on. Default is all available datasets.') shared_args.add_argument('--problem', type=str, - default=None, required=False, - help='Name of the tests results to aggregate.') + default=None, required=False, + help='Name of the tests results to aggregate.') shared_args.add_argument('--primitive', type=str, - default=None, required=False, - help='Name of the primitive to be tested.') + default=None, required=False, + help='Name of the primitive to be tested.') parser = argparse.ArgumentParser( prog='datatracer-benchmark', @@ -172,17 +166,17 @@ def main(): df = args.command(args.data_dir, args.ds_name) else: df = args.command(args.data_dir, args.ds_name, solver=args.primitive) - cmd_abbrv = { 'column': 'ColMap_', - 'foreign': 'ForeignKey_', - 'primary': 'PrimaryKey_', - 'how': 'HowLineage_' - } - cmd_str = { benchmark_column_map: 'ColMap_', - benchmark_foreign_key: 'ForeignKey_', - benchmark_primary_key: 'PrimaryKey_', - benchmark_how_lineage: 'HowLineage_', - aggregate: cmd_abbrv[args.problem] if args.problem in cmd_abbrv else '' - } + cmd_abbrv = {'column': 'ColMap_', + 'foreign': 'ForeignKey_', + 'primary': 'PrimaryKey_', + 'how': 'HowLineage_' + } + cmd_str = {benchmark_column_map: 'ColMap_', + benchmark_foreign_key: 'ForeignKey_', + benchmark_primary_key: 'PrimaryKey_', + benchmark_how_lineage: 'HowLineage_', + aggregate: cmd_abbrv[args.problem] if args.problem in cmd_abbrv else '' + } csv_name = "st_" + args.ds_name + ".csv" if args.ds_name else args.csv # st is for recognition in the aggregation step diff --git a/benchmark/column_map_benchmark.py b/benchmark/column_map_benchmark.py index 9322a57..c733cb4 100644 --- a/benchmark/column_map_benchmark.py +++ b/benchmark/column_map_benchmark.py @@ -1,13 +1,13 @@ import time -from time import ctime, time +from time import time import dask import pandas as pd from dask.diagnostics import ProgressBar -import datatracer from datatracer import DataTracer, load_datasets + @dask.delayed def evaluate_single_column_map(constraint, tracer, tables): field = constraint["fields_under_consideration"][0] @@ -23,7 +23,7 @@ def evaluate_single_column_map(constraint, tracer, tables): y_pred = ret_dict y_pred = {field for field, score in y_pred.items() if score > 0.0} end = time() - except: + except BaseException: return { "table": field["table"], "field": field["field"], @@ -60,6 +60,7 @@ def evaluate_single_column_map(constraint, tracer, tables): "status": "OK", } + @dask.delayed def column_map(solver, target, datasets): """Benchmark the column map solver on the target dataset. @@ -91,7 +92,7 @@ def column_map(solver, target, datasets): def benchmark_column_map(data_dir, dataset_name=None, solver="datatracer.column_map.basic"): """Benchmark the column map solver. - This uses leave-one-out validation and evaluates the performance of the + This uses leave-one-out validation and evaluates the performance of the solver on the specified datasets. Args: @@ -130,4 +131,4 @@ def benchmark_column_map(data_dir, dataset_name=None, solver="datatracer.column_ df.insert(0, 'field', field_col) df.insert(0, 'table', table_col) df.insert(0, 'dataset', dataset_col) - return df \ No newline at end of file + return df diff --git a/benchmark/foreign_key_benchmark.py b/benchmark/foreign_key_benchmark.py index 36c7ea8..7a0be20 100644 --- a/benchmark/foreign_key_benchmark.py +++ b/benchmark/foreign_key_benchmark.py @@ -1,11 +1,10 @@ import time -from time import ctime, time +from time import time import dask import pandas as pd from dask.diagnostics import ProgressBar -import datatracer from datatracer import DataTracer, load_datasets @@ -37,7 +36,7 @@ def foreign_key(solver, target, datasets): start = time() fk_pred = tracer.solve(tables) end = time() - except: + except BaseException: return { "precision": 0, "recall": 0, @@ -76,7 +75,7 @@ def foreign_key(solver, target, datasets): def benchmark_foreign_key(data_dir, dataset_name=None, solver="datatracer.foreign_key.standard"): """Benchmark the foreign key solver. - This uses leave-one-out validation and evaluates the performance of the + This uses leave-one-out validation and evaluates the performance of the solver on the specified datasets. Args: diff --git a/benchmark/how_lineage_benchmark.py b/benchmark/how_lineage_benchmark.py index 00f87e4..f2ce1df 100644 --- a/benchmark/how_lineage_benchmark.py +++ b/benchmark/how_lineage_benchmark.py @@ -1,13 +1,13 @@ import time -from time import ctime, time +from time import time import dask import pandas as pd from dask.diagnostics import ProgressBar -import datatracer from datatracer import DataTracer, load_datasets + def transform_single_column(tables, column_info): aggregation = column_info['aggregation'] column_name = column_info['source_col']['col_name'] @@ -17,7 +17,8 @@ def transform_single_column(tables, column_info): return transformer(tables, fk, column_name) else: return tables[column_info['source_col']['table_name']][column_name].fillna(0.0).values - + + def produce_target_column(tables, map_info): transformation = map_info['transformation'] if transformation: @@ -29,18 +30,23 @@ def produce_target_column(tables, map_info): else: return None + def approx_equal(num, target, add_margin, multi_margin): if target >= 0: - return (num <= target * (1 + multi_margin) + add_margin) and (num >= target * (1 - multi_margin) - add_margin) + return (num <= target * (1 + multi_margin) + add_margin) and (num >= + target * (1 - multi_margin) - add_margin) else: - return (num <= target * (1 - multi_margin) + add_margin) and (num >= target * (1 + multi_margin) - add_margin) - + return (num <= target * (1 - multi_margin) + add_margin) and (num >= + target * (1 + multi_margin) - add_margin) + + def approx_equal_arrays(num, target, add_margin, multi_margin): for n, t in zip(num, target): if not approx_equal(n, t, add_margin, multi_margin): return False return True + @dask.delayed def evaluate_single_lineage(constraint, tracer, tables): field = constraint["fields_under_consideration"][0] @@ -53,9 +59,10 @@ def evaluate_single_lineage(constraint, tracer, tables): try: start = time() ret_dict = tracer.solve(tables, target_table=field["table"], target_field=field["field"]) - y_pred = {(col['source_col']['table_name'], col['source_col']['col_name']) for col in ret_dict['lineage_columns']} + y_pred = {(col['source_col']['table_name'], col['source_col']['col_name']) + for col in ret_dict['lineage_columns']} end = time() - except: + except BaseException: return { "table": field["table"], "field": field["field"], @@ -75,13 +82,14 @@ def evaluate_single_lineage(constraint, tracer, tables): else: precision = 0 return { - "table": field["table"], - "field": field["field"], - "precision": precision, - "inference_time": end-start, - "status": "OK", + "table": field["table"], + "field": field["field"], + "precision": precision, + "inference_time": end - start, + "status": "OK", } + @dask.delayed def how_lineage(solver, target, datasets): """Benchmark the how lineage solver on the target dataset. @@ -113,7 +121,7 @@ def how_lineage(solver, target, datasets): def benchmark_how_lineage(data_dir, dataset_name=None, solver="datatracer.how_lineage.basic"): """Benchmark the how lineage solver. - This uses leave-one-out validation and evaluates the performance of the + This uses leave-one-out validation and evaluates the performance of the solver on the specified datasets. Args: @@ -151,4 +159,4 @@ def benchmark_how_lineage(data_dir, dataset_name=None, solver="datatracer.how_li df.insert(0, 'field', field_col) df.insert(0, 'table', table_col) df.insert(0, 'dataset', dataset_col) - return df \ No newline at end of file + return df diff --git a/benchmark/primary_key_benchmark.py b/benchmark/primary_key_benchmark.py index a4faf96..726862e 100644 --- a/benchmark/primary_key_benchmark.py +++ b/benchmark/primary_key_benchmark.py @@ -1,11 +1,10 @@ import time -from time import ctime, time +from time import time import dask import pandas as pd from dask.diagnostics import ProgressBar -import datatracer from datatracer import DataTracer, load_datasets @@ -47,7 +46,7 @@ def primary_key(solver, target, datasets): start = time() y_pred = tracer.solve(tables) end = time() - except: + except BaseException: return { "precision": 0, "recall": 0, @@ -90,7 +89,7 @@ def primary_key(solver, target, datasets): def benchmark_primary_key(data_dir, dataset_name=None, solver="datatracer.primary_key.basic"): """Benchmark the primary key solver. - This uses leave-one-out validation and evaluates the performance of the + This uses leave-one-out validation and evaluates the performance of the solver on the specified datasets. Args: @@ -121,4 +120,4 @@ def benchmark_primary_key(data_dir, dataset_name=None, solver="datatracer.primar df = pd.DataFrame(list(results.values())) dataset_col = df.pop('dataset') df.insert(0, 'dataset', dataset_col) - return df \ No newline at end of file + return df diff --git a/datatracer/__init__.py b/datatracer/__init__.py index 7e6dd8e..ba0b652 100644 --- a/datatracer/__init__.py +++ b/datatracer/__init__.py @@ -13,7 +13,6 @@ from datatracer.core import PRETRAINED_DIR, DataTracer from datatracer.data import get_demo_data, load_dataset, load_datasets from datatracer.data_sampler import sample_datasets -import datatracer.how_lineage _BASE_PATH = os.path.abspath(os.path.dirname(__file__)) _JSONS_PATH = os.path.join(_BASE_PATH, 'jsons') diff --git a/datatracer/column_map/basic.py b/datatracer/column_map/basic.py index 19c0c7c..ac53871 100644 --- a/datatracer/column_map/basic.py +++ b/datatracer/column_map/basic.py @@ -1,58 +1,66 @@ import logging - -from sklearn.ensemble import RandomForestRegressor -from sklearn.linear_model import LinearRegression +import time from itertools import combinations + import numpy as np -import time +from sklearn.ensemble import RandomForestRegressor from datatracer.column_map.base import ColumnMapSolver from datatracer.column_map.transformer import Transformer LOGGER = logging.getLogger(__name__) + def approx_equal(num, target, add_margin, multi_margin): if target >= 0: - return (num <= target * (1 + multi_margin) + add_margin) and (num >= target * (1 - multi_margin) - add_margin) + return (num <= target * (1 + multi_margin) + add_margin) and\ + (num >= target * (1 - multi_margin) - add_margin) else: - return (num <= target * (1 - multi_margin) + add_margin) and (num >= target * (1 + multi_margin) - add_margin) - + return (num <= target * (1 - multi_margin) + add_margin) and\ + (num >= target * (1 + multi_margin) - add_margin) + + def approx_equal_arrays(num, target, add_margin, multi_margin): for n, t in zip(num, target): if not approx_equal(n, t, add_margin, multi_margin): return False return True + def check_sum(indicies, X, y, add_margin, multi_margin): - return approx_equal_arrays(X[:, indicies].sum(axis = 1), y, add_margin, multi_margin) + return approx_equal_arrays(X[:, indicies].sum(axis=1), y, add_margin, multi_margin) + def check_avg(indicies, X, y, add_margin, multi_margin): - return approx_equal_arrays(X[:, indicies].sum(axis = 1)/len(indicies), y, add_margin, multi_margin) + return approx_equal_arrays(X[:, indicies].sum( + axis=1) / len(indicies), y, add_margin, multi_margin) + def check_diff(indicies, X, y, add_margin, multi_margin): pred_y = X[:, indicies[0]] - X[:, indicies[1]] return approx_equal_arrays(pred_y, y, 0, 0) + def detect_restricted_reg(X, y, add_margin=1e-4, mult_margin=1e-4, max_feature=5, timeout=3600): """ This method runs a restricted regression where the target column is either the sum or difference of several columns in the given table, or the average of several columns in the given table. - + Returns: (str, tuple): a string ("sum", "diff", "avg" or "None") representing the operation, and a tuple of coeffs. """ start_time = time.time() - + dot_prods = (X.T).dot(y) length = len(dot_prods) y2 = y.dot(y) for num_feature in range(1, max_feature + 1): - for combo in combinations(range(length),num_feature): + for combo in combinations(range(length), num_feature): if time.time() - start_time > timeout: return "None", None - + indicies = list(combo) if approx_equal(dot_prods[indicies].sum(), y2, add_margin, mult_margin): if check_sum(indicies, X, y, add_margin, mult_margin): @@ -60,20 +68,23 @@ def detect_restricted_reg(X, y, add_margin=1e-4, mult_margin=1e-4, max_feature=5 for ind in indicies: weights[ind] = 1 return "sum", weights - if (num_feature > 1) and approx_equal(dot_prods[indicies].sum()/num_feature, y2, add_margin, mult_margin): + if (num_feature > 1) and approx_equal( + dot_prods[indicies].sum() / num_feature, y2, add_margin, mult_margin): if check_avg(indicies, X, y, add_margin, mult_margin): weights = [0] * length for ind in indicies: - weights[ind] = 1.0/num_feature + weights[ind] = 1.0 / num_feature return "avg", weights if num_feature == 2: - if approx_equal(dot_prods[indicies[0]] - dot_prods[indicies[1]], y2, add_margin, mult_margin): + if approx_equal(dot_prods[indicies[0]] - dot_prods[indicies[1]], + y2, add_margin, mult_margin): if check_diff(indicies, X, y, add_margin, mult_margin): weights = [0] * length weights[indicies[0]] = 1 weights[indicies[1]] = -1 return "diff", weights - if approx_equal(dot_prods[indicies[1]] - dot_prods[indicies[0]], y2, add_margin, mult_margin): + if approx_equal(dot_prods[indicies[1]] - dot_prods[indicies[0]], + y2, add_margin, mult_margin): if check_diff(indicies[::-1], X, y, add_margin, mult_margin): weights = [0] * length weights[indicies[0]] = -1 diff --git a/datatracer/how_lineage/__init__.py b/datatracer/how_lineage/__init__.py index 65da3b8..14fcec2 100644 --- a/datatracer/how_lineage/__init__.py +++ b/datatracer/how_lineage/__init__.py @@ -1,8 +1,8 @@ from datatracer.how_lineage.base import HowLineageSolver from datatracer.how_lineage.basic import BasicHowLineageSolver -from datatracer.how_lineage.table_transforms import entries_count,\ - entries_sum, entries_min, entries_max, entries_std, columns_avg,\ - columns_diff, columns_sum +from datatracer.how_lineage.table_transforms import ( + columns_avg, columns_diff, columns_sum, entries_count, entries_max, entries_min, entries_std, + entries_sum) __all__ = ( 'HowLineageSolver', diff --git a/datatracer/how_lineage/basic.py b/datatracer/how_lineage/basic.py index a5c9b8e..56d2969 100644 --- a/datatracer/how_lineage/basic.py +++ b/datatracer/how_lineage/basic.py @@ -1,70 +1,80 @@ import logging +import time +from itertools import combinations from sklearn.ensemble import RandomForestRegressor -from sklearn.linear_model import LinearRegression -from itertools import combinations -import numpy as np -import time from datatracer.how_lineage.base import HowLineageSolver from datatracer.how_lineage.transformer import Transformer LOGGER = logging.getLogger(__name__) + def approx_equal(num, target, add_margin, multi_margin): if target >= 0: - return (num <= target * (1 + multi_margin) + add_margin) and (num >= target * (1 - multi_margin) - add_margin) + return (num <= target * (1 + multi_margin) + add_margin) and\ + (num >= target * (1 - multi_margin) - add_margin) else: - return (num <= target * (1 - multi_margin) + add_margin) and (num >= target * (1 + multi_margin) - add_margin) - + return (num <= target * (1 - multi_margin) + add_margin) and\ + (num >= target * (1 + multi_margin) - add_margin) + + def approx_equal_arrays(num, target, add_margin, multi_margin): for n, t in zip(num, target): if not approx_equal(n, t, add_margin, multi_margin): return False return True + def check_sum(indicies, X, y, add_margin, multi_margin): - return approx_equal_arrays(X[:, indicies].sum(axis = 1), y, add_margin, multi_margin) + return approx_equal_arrays(X[:, indicies].sum(axis=1), y, add_margin, multi_margin) + def check_avg(indicies, X, y, add_margin, multi_margin): - return approx_equal_arrays(X[:, indicies].sum(axis = 1)/len(indicies), y, add_margin, multi_margin) + return approx_equal_arrays(X[:, indicies].sum( + axis=1) / len(indicies), y, add_margin, multi_margin) + def check_diff(indicies, X, y, add_margin, multi_margin): pred_y = X[:, indicies[0]] - X[:, indicies[1]] return approx_equal_arrays(pred_y, y, 0, 0) + def detect_restricted_reg(X, y, add_margin=1e-4, mult_margin=1e-4, max_feature=5, timeout=3600): """ This method runs a restricted regression where the target column is either the sum or difference of several columns in the given table, or the average of several columns in the given table. - + Returns: (str, tuple): a string ("sum", "diff", "avg" or "None") representing the operation, and a tuple of coeffs. """ start_time = time.time() - + dot_prods = (X.T).dot(y) length = len(dot_prods) y2 = y.dot(y) for num_feature in range(1, max_feature + 1): - for combo in combinations(range(length),num_feature): + for combo in combinations(range(length), num_feature): if time.time() - start_time > timeout: return "None", None - + indicies = list(combo) if approx_equal(dot_prods[indicies].sum(), y2, add_margin, mult_margin): if check_sum(indicies, X, y, add_margin, mult_margin): return "sum", indicies - if (num_feature > 1) and approx_equal(dot_prods[indicies].sum()/num_feature, y2, add_margin, mult_margin): + if (num_feature > 1) and approx_equal( + dot_prods[indicies].sum() / num_feature, y2, add_margin, mult_margin): if check_avg(indicies, X, y, add_margin, mult_margin): return "avg", indicies if num_feature == 2: - if approx_equal(dot_prods[indicies[0]] - dot_prods[indicies[1]], y2, add_margin, mult_margin): + if approx_equal(dot_prods[indicies[0]] - dot_prods[indicies[1]], + y2, add_margin, mult_margin): if check_diff(indicies, X, y, add_margin, mult_margin): return "diff", indicies - if approx_equal(dot_prods[indicies[1]] - dot_prods[indicies[0]], y2, add_margin, mult_margin): + if approx_equal(dot_prods[indicies[1]] - dot_prods[indicies[0]], + y2, add_margin, mult_margin): if check_diff(indicies[::-1], X, y, add_margin, mult_margin): return "diff", indicies[::-1] return "None", None @@ -118,11 +128,13 @@ def solve(self, tables, foreign_keys, target_table, target_field): X, y = transformer.forward(target_table, target_field) if len(X.shape) != 2: # invalid X shape - print("Encountered invalid X shape in how-lineage detection. Please check if any table is empty or if foreign keys have been provided.") + print("Encountered invalid X shape in how-lineage detection.\ + Please check if any table is empty or if foreign keys have been provided.") return {"lineage_columns": [], "transformation": ""} elif X.shape[0] == 0 or X.shape[1] == 0: # empty dimension - print("Encountered invalid X shape in how-lineage detection. Please check if any table is empty or if foreign keys have been provided.") + print("Encountered invalid X shape in how-lineage detection.\ + Please check if any table is empty or if foreign keys have been provided.") return {"lineage_columns": [], "transformation": ""} @@ -131,9 +143,10 @@ def solve(self, tables, foreign_keys, target_table, target_field): if restricted_linear_type == "None": print("Failed to detect any basic linear maps in how-lineage detection.") return {"lineage_columns": [], - "transformation": ""} + "transformation": ""} except BaseException as e: - print("Encountered an error in how-lineage detection, though very likely a standard timeout. Error message is as below.") + print("Encountered an error in how-lineage detection, though very likely a\ + standard timeout. Error message is as below.") print(e) return {"lineage_columns": [], "transformation": ""} @@ -141,8 +154,8 @@ def solve(self, tables, foreign_keys, target_table, target_field): lineage = [transformer.columns[idx] for idx in indicies] linear_map_dict = {"sum": "datatracer.how_lineage.columns_sum", - "diff": "datatracer.how_lineage.columns_diff", - "avg": "datatracer.how_lineage.columns_avg"} + "diff": "datatracer.how_lineage.columns_diff", + "avg": "datatracer.how_lineage.columns_avg"} - return {"lineage_columns": lineage, + return {"lineage_columns": lineage, "transformation": linear_map_dict[restricted_linear_type]} diff --git a/datatracer/how_lineage/table_transforms.py b/datatracer/how_lineage/table_transforms.py index 7f231ac..bdfe366 100644 --- a/datatracer/how_lineage/table_transforms.py +++ b/datatracer/how_lineage/table_transforms.py @@ -7,29 +7,37 @@ def transform(tables, fk, col_name, op): result_col = op(child_table.groupby(fk["field"]))[col_name] parent_table = parent_table.set_index(fk["ref_field"]) parent_table = parent_table.join(result_col).reset_index() - + return parent_table[col_name].fillna(0.0).values + def entries_count(tables, fk, col_name): return transform(tables, fk, '_dummy_', lambda x: x.count()) + def entries_sum(tables, fk, col_name): return transform(tables, fk, col_name, lambda x: x.sum()) + def entries_min(tables, fk, col_name): return transform(tables, fk, col_name, lambda x: x.min()) + def entries_max(tables, fk, col_name): return transform(tables, fk, col_name, lambda x: x.max()) + def entries_std(tables, fk, col_name): return transform(tables, fk, col_name, lambda x: x.std()) + def columns_sum(columns): return sum(columns) + def columns_diff(columns): - return column[0] - column[1] + return columns[0] - columns[1] + def columns_avg(columns): - return sum(columns)/len(columns) \ No newline at end of file + return sum(columns) / len(columns) diff --git a/datatracer/how_lineage/transformer.py b/datatracer/how_lineage/transformer.py index f080c9d..af2d272 100644 --- a/datatracer/how_lineage/transformer.py +++ b/datatracer/how_lineage/transformer.py @@ -32,9 +32,9 @@ def forward(self, table, field): df = df.fillna(0.0) X, y = df.drop([field], axis=1), df[field] self.columns = [{"source_col": {"table_name": table, "col_name": col_name}, - "row_map": {}, - "aggregation": "" - } for col_name in X.columns] + "row_map": {}, + "aggregation": "" + } for col_name in X.columns] X, y = X.values, y.values X_new, columns_new = self._get_counts(table) @@ -71,9 +71,9 @@ def _get_counts(self, table): X.append(parent_table["_tmp_"].fillna(0.0).values) columns.append({"source_col": {"table_name": fk['table'], "col_name": fk['field']}, - "row_map": fk, - "aggregation": "datatracer.how_lineage.entries_count" - }) + "row_map": fk, + "aggregation": "datatracer.how_lineage.entries_count" + }) return np.array(X).transpose(), columns @@ -110,10 +110,11 @@ def _get_aggregations(self, table): for old_name, col_name in zip(old_column_names, child_counts.columns): if parent_table[col_name].dtype.kind == "f": X.append(parent_table[col_name].fillna(0.0).values) - columns.append({"source_col": {"table_name": fk['table'], "col_name": old_name}, - "row_map": fk, - "aggregation": op_str - }) + columns.append({"source_col": {"table_name": fk['table'], + "col_name": old_name}, + "row_map": fk, + "aggregation": op_str + }) return np.array(X).transpose(), columns @@ -123,5 +124,5 @@ def backward(self, feature_importances): to the `X` matrix produced by the last call to `forward`. It returns a mapping from fields to importance scores. """ - return [(column, importance) for column, importance in zip(self.columns, feature_importances)] - + return [(column, importance) + for column, importance in zip(self.columns, feature_importances)] From 9bba0225c12747149ca63c998d50e143a2293b56 Mon Sep 17 00:00:00 2001 From: Zhuofan Xie Date: Wed, 4 Aug 2021 12:13:56 -0400 Subject: [PATCH 39/42] Fix a false unused import (which is implicitly used later) --- benchmark/how_lineage_benchmark.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/benchmark/how_lineage_benchmark.py b/benchmark/how_lineage_benchmark.py index f2ce1df..e58ccf5 100644 --- a/benchmark/how_lineage_benchmark.py +++ b/benchmark/how_lineage_benchmark.py @@ -5,7 +5,7 @@ import pandas as pd from dask.diagnostics import ProgressBar -from datatracer import DataTracer, load_datasets +import datatracer def transform_single_column(tables, column_info): @@ -107,7 +107,7 @@ def how_lineage(solver, target, datasets): if not metadata.data.get("constraints"): return {} # Skip dataset, no constraints found. - tracer = DataTracer(solver) + tracer = datatracer.DataTracer(solver) tracer.fit(datasets) list_of_metrics = [] @@ -132,7 +132,7 @@ def benchmark_how_lineage(data_dir, dataset_name=None, solver="datatracer.how_li Returns: A DataFrame containing the benchmark resuls. """ - datasets = load_datasets(data_dir) + datasets = datatracer.load_datasets(data_dir) dataset_names = list(datasets.keys()) if dataset_name is not None: if dataset_name in dataset_names: From 6cae6eb432b12603fd10018196f959e1bcf0c38e Mon Sep 17 00:00:00 2001 From: Zhuofan Xie Date: Wed, 4 Aug 2021 12:48:07 -0400 Subject: [PATCH 40/42] Updated pretrained primitives --- .../pretrained/datatracer.column_map.basic.dt | Bin 31166 -> 191756 bytes .../datatracer.foreign_key.standard.dt | Bin 26906 -> 189067 bytes .../datatracer.how_lineage.basic.dt | Bin 0 -> 186153 bytes .../datatracer.primary_key.basic.dt | Bin 10740 -> 72121 bytes 4 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 datatracer/pretrained/datatracer.how_lineage.basic.dt diff --git a/datatracer/pretrained/datatracer.column_map.basic.dt b/datatracer/pretrained/datatracer.column_map.basic.dt index 9497cf1e668d10f727553af4b3ab930b4e6504a8..0aca85f74ac297b83cc8d1dd70977593253fa149 100644 GIT binary patch literal 191756 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 pwhIWEW8qr>~bDM@enb{-B(IJ(W8zr`3iM(KSn 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 literal 31166 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 X%ug1?E}_}}6#{1XDdHM*Hg*0V@>_%0 literal 26906 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 bBXjce`}MWO91!B$hUY>Y74v}N`H}y>O>sxr diff --git a/datatracer/pretrained/datatracer.how_lineage.basic.dt b/datatracer/pretrained/datatracer.how_lineage.basic.dt new file mode 100644 index 0000000000000000000000000000000000000000..92c3bb2c0c94e115b4fe67b578954862dd035935 GIT binary patch literal 186153 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

_3+8{-gZWI+ywpnk zr@dbbL#Wn+NTVCSJrb0|9ReuqrGdr>-KKinVYA> zP2T;UFvJTUSmDJYe=Azy58mT@s>e%l|K<7KQ+u&^ZCSK33L{CQC#_a7*xx3tw!O%I z1+31d6T|h;VSATo~C{>9`8qGQVFN%+VQphP` zth^+|{W9+VJ>h?qB+YMmAL|26-Tk>g?7%dZ9g8!SQS_%)k&@2&rO>WkOe zNqqiBzy9l7YWHK1#w|#{?Tfauue&aJ;*xn7Z1s~E@jULF81p|uF6(*+OZ|Dr?v3N0XLP+t>1_8fdE#_LsOdQif3rd5r_&uL z!`hxSE@1N3dM&*k%=YW-@-dYE_#-y|ndvsD`i1p===!66ZPu@)cCpu^>r~d}WS6dg z?%U))^ZHCNN!`lKejEA!+zwsbV`V{{o(O52k|y=ix z$0N?YJp8BW)yFn`%2vGGwRUaQgA6YWDde2y_Qtal5o&r(eb0N3HNJPsGbg5QX2X#$ zfIHhg0r5WRdX0&X-({u!X5{tW@HN2(@%egNj`&`TFPI zB)-3rO+&r_4JFq3QGXPA$wGG^rZWhkmS58uLq2DzYtQQOuM6rK^?#{XV4a^?Kl^cl zu2Wg80(S_OZz96*_}S2N$I!n|y^Q@?A7H**<(`7_ry|tK89(XMq{K08G)^BKJ=KZF(wO!2nX)kL%t^B-XE{z)#u$(4n>n2BR zzIfiwI`+C=zZI)o8mGpif|hRK>K@xzL)XVX`3Jj=73&G6N?jN65eT*Zl2x34D;r%m zq+tNVlpl-Gv^ULP zQla@vXLmHpk3p!}Mcj`hd1vUpY5yFcH^Yya?`q|MBG_L?W`6eLwb`$8t=o^T8)>Im z)_UjZ1>Jdtn~(ONgwOOzI^E1b*mR|4RCj34g@Q020!q+OpoXnur z3TB|($q11QSGqjwf^@=(PmuUSrCi<;l+TkeS%A2MKWazh!;D2TauK4K%Afdq%b)nY zq<(1}(;@p-AdYekUI;Hn9PI-$Hb?#tjwc++3J=kd4Kqx6Ms`Iy0hKAI6#$LSZ6C%G`BdJ&!U2@;=hDu1|t&BFXp4n}^ZAx@~iY5AZW@hLyT%~bdu zGdKVSzj$yU9Ou`~_ttw57UR*9AOk3g;2=0!#Z2D0dGiq4m`RWcsq)eIV1PwITb>6W ziI)ZKIAc%olb}6t;=v(sgeZ#F=)KY$j|j4Wx;!`(jzvKSIC>EC@ZRv60~*0$oC+Cs z6f%dyQARd`PMo0!H-aO85)V4Vk%u^fBjKbdI0}x5;AlAVSVvp$!~B>>A&sC5ka^)afAS1NH>cxt zacRa&GdZn(8Cjp7&(7|6_qg}Zel_Glw|{bNt=k30mrTSDMo9Tew0XvD>eUG6>NHD>rCyc|8uGv#~!rv}F;MOfqy~U^>O0luZ(@&mzO)teGC!0p4p6QqK zG^73@_3USeX1hm}r@upbIV0`fU9UXTW;@t>R$6LLQ_pcbV)^zK$r!f#kVjLaUt}aF z#FMT|kqGjY_fM_- zP!@X+Y)jjZ`mwC|pfpc^__`%ev1u3ka+>wCA78@uBv0pY*kW3`rPsdsD$AHYX3Jv} z9%G*(Ux4~0%G2LNr>k=smv%DhACl#|5tQkUP|t7p3ESBC!rh%~U*EzgvL?lbxZO}b zJ6*4_?5f898qd)WMWV3yp~z2;+Y=eR5NhpIJY&Xd>N+y-!}ixvOZ%Bt4k%d@%fa&D z1C;Ku&zk(V&)Lp1u58S`@qYFi$_Y?^mh$xXL3;RlsNeGXMF(tpn$6PnN~6lte*)6Y z`lpy!0I=@8kOv{#+Jj)GXMY`O{T51=>p?;+0Cn6biAJgcd(K*f2Z(e7q|C;%dZ9g;ZVn44l=M#?WqCH0-G(n0b z&wcsj!rW`wFLXv~p>=orS2re>3*YK*M)6{-NA&z{)3kptzePj#PYPSY_zjo)<3 zxev2HiUk9^tt3(WlN%W2N{?Au`g+D(G# zE=5k~8FWHAxz1%rd~{5zgk7i?wCzfFHj2zesM))w>buKWD)G%Tlm2g=xiYDt$UP0^ z=cMZ;AG&|JT7hczYx@+5#9ZqJ6x-4e7%X}VoLIjk$rTb*~A zS-(8%{LJ}6#EoJ;^AVaL%a!=f(DfIw&QGr%zuwC*gK~u0_)>qz9aO*6&lIt?zy7px z@RGSf1Ss*K8yx4KsYficfx^AY-2oN_J>V$3$}(cvlQSV=SxD}&57)S&qAo@#op};3 zT_T|~3t@i=RRl9v;wlmrju;T$1@#^LQHv2jBg7{@Wz^BRc7I4ABWEKFJT3a*@R-PxVB)ihQYj zP_7q32Y(6=(TSgfbgB=PgK&}~Nc=phS6_$vy(Lr;K<nmvBmlAn~z&$FNv5UTuX zjwDB;JdG>;&Ga0yJMqJi`i;z<&z$5L$q`QNsBlssh*j9tx5mK{x0HHQ~^JwVu zpf4PYf_`vJj|iX4sgPlRAu|Au7!e-G8Ae2S5K!X5U^t41@DMmD3QmDzA{YusvHJ}o z!e=x`gogo{Cw{6B9nOWIHfJmg7-v94ctxk?i11jlyaB&v!&}|6a@5Wpd)@nWG|Fo; zVG$8-7{7haM^!u749H9UQq2)z$Xfi&8Cr@J4u5;lV=ugHdMe#{YVX(KsHY_>_I|-e zrK3I_^#U7@duyjUIn!SD9=X#@?x z4=LC`shN6NE=A-iq91;Ot#3DW=G0z~r9~8N%Sq|VY7S5lVe&w0! z;M_v=u!vzA}Zr|?AhQ}+&O{g3BhT~1T4i5-UJqsXmR&QzDz7OHZF>u1{z zW`6dbt~OXM3G5@7$LV^>$!hP{QAjt-)1X#XnDy)EQp9=>5$~(d{c1Cl$)#9JuOqV@ z_7Uio>S^r?MbB`%pgnpbG(9OPmKvJc`p7j~7+-Luek+%_#~^YnLX9oe^8hlCl-ydRTf0>jP5!Ui6 zVOhtrtWyVeQ5BWERBJ*?H0hrIg z^!!9*-d+2*Zg)&w!zf}R#N5-Y#}}%(56!Pd6m$8)>~G;+33`5x{a(TLhTaer5uS#0tzS1*j{ShSoAWkwTem$luj{M!eia$% z5z@xp`Ik4k)s(#viV*cu5n+n(YWcPO?7n(6uQQ(m?4LXI=7Z9>6hWSfMbp}^aL&XH zYMju@0h0FPgq9xY+4oPgoc8xy(=PV&$nd;%2HImLLaqLhi>f!Pd5Af`wx4Hd=|K*i z-C3B=T!dylnpX^&<;+$QVT$l->1KU>_v32iq(+Av?ENoh`(^DbB3yjP-<#i2($e-b z^Rw?Srk?%0F04o6ehIdN*}f^(z+Vy6t3Qn>0cun&+k$iHNTZ;Ka5o+Zr{pa%6 z)b(!Kv!(TGsXd!m8J4dcVc0ItVP|hF>`}^=>IL0+rL|pZbiMIcx-raWLAqVs-Ix7^ z^*H2!>--b9gr0L+x>w%bf2Oq$lni!&xj=~rBj9L8php6Qd$C6WEDA=$QFyUs1bPf- zLIk?z0IXh@wXQg$h;wPF#5XRM(4oj3MncqH!`{drjS%tVNW{A!q{v1`#2tiT+zI`N zpGA7eXM`i9h!e7%9wB}<;V3|n9x4Zt;f^HF6dxHBfued4uJrH(`QTAAIg}pNgYYmk zwRj7g6v`Lt4{N(6zn_F@`R1E^wI`O^O=@!wwLBw8aH180J&?D1DM6NPL1y&Ls&6i5`ZgRC9krxrV5j4m?qq=}~)9y5aPY zpG6VoUQ%C0y6HL{kLhMhm>h*T)oTdi4*sYg5I+)Yp&k#EL@)u4RuSsS&z4nMN2n)4s{C%453neh z#Pg65>dBn3#|U)+aN@xfIHpIa3xT>kmopGCZ1#5$c(oVMM5B z0VN*rH|vTB^=vpP3QmJ#BA5e5icse_N2uolnI}F^h@Q@cpf>r-f)dVv2(_2h9HGt& zH-W&vW?k-jqt~WC?qDbAQ+jRB@*W~OB0@dAZIAY=UVokO22{Vs<_NX<1WOVr*5l|i z&g%8ptEQ(9-pxVT(-VjGq>wlG+?!eHfiE37_06|JYmkRn&+N4HpfDL}5o(QJzwwGk zS<|?JFYZ5kN9c)qpo&muV3jnzhJg!zr#ytFC&AuBNK-MHd~fx3(#-Vy3g2A)z)yTK z{<%D1PFjST+EErewb#g@t%r&FhfJ^KPyQ6q?u7OXm$S_W58rn2wkOOdD|eQPP#=zZ zTDKP~C?YjCpLFcE_cO!u=FUvpv#jZf6%>#D^N^<^Pj59N_YSk}*Hj-_tRee)J88~Y zb@h9RP`BK3W7~e(bpl1TZ@=Tv-V!uNs43#CrTg6ap|||R>%nna8~NFLe6)6j6q?jt z`EHn=IUc0gC!0SUam}zN+1Gl0HA~X=ENkhCi1GNpe|Evtfp0R3EDH688P@$JTh9;P z<#G(-$07`04{r~@>YmT8cp|iSZm4yB?Tq{erA4T<_KVCp^uxV)8tnUvW-p$zbveU& zWT%s`{2E_e?{3ju`wu?ocJ{1MzgiWc?qkpifxWULzv}wl!S}KYh{Pehg|i3Z6j3$% zA$XL#J~HOMCAYAIu9x)Gp5@+1*X&u-ZR@8jdDi9*M?ZhB`Hql=IqL1C$EWLw2zBYb z?>(~W`PFQco}V*EMW}Pq^~BpdDPr7B*Mo?$?Klz9^&ERWt$iQ`dk+(}v&c4SzZb)7 zANz4)uwFlRhT5~7i}bKZi^mZfi{A_Fsj&B)YW)FnurH@sKl|%%j8VVY>g}Zck*>8< zR`Q)|S+ehjcVBz^Qud*qp}Sf|s43#C=@mZr;p> zh1<{h{ryLdd+>brmR`TztJI!l!(PgEo!sP_OJ`mFR2|!@>s`G{MW}}>S^cDgTfc7C z^RX*$U>ECTcqbY4JBq-Yc7yTd%dIb^j#$A0T`v-|zHTXEAk^zi(<0PbKkVH8xlOF` zkW1H|fBT)GcTKv#i-O^6UF9RQG9(4msG56MDA> zMeBCg#sOX~stNyt;;Q@I9(yK=h z8Ok%V6VeH&b|gNPi*OPoNPNU;Ph5mNs8EEpI@O4AVWJlr?o%%Pme>h^NJmHkj-XJAN5amL4buK4;lvDjN zM5po+Ka8Wg0uXau0E1UNI0KHR2ipTmBB+3)l?VGDWe-HHJ=kYLs(dtE2(T!q~n$46Hf@yUvMF)&Fp2td7J?r>?Maad$2nd-QGp*!Xp*041)^~U< zJ;<{24|=d=xl(Cw8MK|jw3Z3bG0lT*&cJD{>bs#epb_i(X?Es$?(6$nn#Vl+)_@=O zfYMt+%=+1{?J~>Rq-Jb1Q`FiiZ}qc2Yr6mJvohY^$WA6p3OLi+E{7X>qm&2xP^4@5 zHGFmaSgKO1OR%&0@Ze3M?2-%l*gft<%zj652n>Y2^pKESDk(M_@g}{krsz-#u6H``Z{r(4^Q{>-M95A@zo& z?XlA8(fQCrx3Lw8f1D6p{7`5u=gHRj(fA|t%W;pwdXa}%%TMgJq3=_e?a(F7gRR*u zXV^j<&zzqt@?_tBTKS=7_Ily#y(`)!8=(o{ZL7GZ*0g6YH-duQ5M~&PG!Fjne1Ai0 zSExysG!J(8dbs+l=>CPbKFPk->kV%)?TL6Vgjzd^h{vPrKWqQ{$Qx{cu9uu{-LHEX zdXv&T*x`uB_?~sG*JZre;z)DY19Lp)-#fj2BL@w77Ck8~Z+FdJkV8xRb%sk33oMXh z_IvOURv&$0|1D*8RE(e)Z)YL6AQtEML+5S@3O zdw0+2n^>i;2Oi0j5GRkarYGK>DVE+hK9#oDjaEPyku#Mx?2*Ion08k*ev)t9&yF_q z#_r35Eh4}4wlZ@ZXzAWad(Xbv-|Zs_W>`tqdHvkha1gz3cy}4 z+j-f@?cL#y6LG-vC?}^{N}`0*0Q&C!$1USB+5_I z(^KaVVOiWNXLJ5C)~M?>ty16DnuxOD`&eYa66eW}?_^i1wBV%S%(OjLI@@s|GV|ul zJAOCxYBR!;D!0BKqI!bKsCw(>1f*;2zWL+zs~AP@X}&MThNgM2H9g_k&#oyua&6`- zq3_<>zqQ|-_t?J$+PqHeJ^PuuU7S2OAMr`)b`g7K$~zXG_{Od$L(d)KtlJ^W$Zx3H zV^xrzpNKrs*CNmML5! zVX{=hm5U`*dr0ym4!f8f@rxkrg%Ca3;K34Xbs#!H#XvctXCUqnL_wm5VOS15!fBrV zQD%C{afnlTm<`O##hKz$`sA@Dk8?;KJPkyV`eP6$c@=R%v3?L-ffiZfWlr6~MFMMg=! zFYyU-DeXsm>R)6RvIB_{Jq(FYkobth%uSrBJs&v|-?>Uc)ep}!@p`f=)&pibj`36d z9qHK!son~ALO+zFaR#|bx}L}mDnf+mG~=Qcknn5vK)g-2q62rbquOX3En?LT$=b|Z?72Ah`_z_O7QD{*j+5r!`RY4y zU64q(UWtNdW95zaJ^AV@Y+|z#oSm-rTpVoB34ym-!PmPE@AZZkTx&=Z`dx4G>x8*v7@HMG{zJ`$v*2PNygueSmkk)YO8WeHn2 zGq<0)8qN13Ja&3_abRyN%}AlkiJz9PSffneJvjyW63lYxNu&ePH9g^(n*WLSaCz=_ za}64-1Za=)^hBxIyZDJaSE;pYSw?=R?&~{o^%=ijt~?^{Y9%cFR^vI zQ@@whJUK05Wj>iSzcs-u=MP65$&6+>n^e1WLIJHE#9IjIiPP*aEss>%dcw4FK*_Sw zB32q-e6O$RvsGr$>b-dMXa=bUHI8k zyVUpd!Vz5C>&UEku8LUo!TOo~qIqv!c>K&k`Qy{=BHqqO*Mm7O*?a8F{*smURzR&C zM8uf-wPr6U*}s2W3YXKi9klX8Ih$B6+L1hrT0O*f_ev`kA9h{UJ?2{-H`b|$70tgi zJ@H+-WaE}MqOBfbb(p>Yr3=(|;zl7|(-UvaYgmw7u%Pn;tX9{HEKqwc=)SM%B_G?p zgKbQLLwt}qir|8WG9dWmM+p-V?zmRP^(7aYAZ?D(VcBgih<(#1+RwL8v zCnA;qEpL$=jpdt=Ue3aMx__>|rxm`hj6wPF>3SmKPb=lZ^Utv;KMtX0ZxOkmw|xJn zuLs+Hrj?)9$hv=;*Ps3U&(w3&TLCAh+Y{zJH!!Eu9VeFzTfw5pm%x5cm#gc+bAJKi zQxIzP7jKbtN>;dAXWwXk4ZxXfZ5J&)$iaR*i(q*KfaeE_a8blqOIPeUbT5j3UE1ps z_PlP;O{>zr6Q}7FwjOw`@=uu0fA%YJ&3T=DWJW6oucC@rRqFLt=bc>l!_kX3xfig< z_4>tEsfbkx%4+GZjCOv8Z9L}L>L=#i&Ms7G!3jjRXCY1zGV{KgVk=&J=r4<|-@-cU zdd?{8b~pV6L*3b!&S?m>@nHNDpWUQZ*qP<9zrSngK@J_=IhfDs2u;w&m3S)Tm+I`! zMZtLpwRCIBPy7V?OUqYw>CN|_m=}~{y%r+W`b$Gz+qYQi+~3}COzU4W;!CjwZWQIc zbiGJ+_M2#zd(7{TI)(0hlrKYQUN5jh633UG$d|x=d?CMp&`XYW%Q2l8LaqJ8N&#B2 zZ(i^Ak!JJ0ne8sXe9k~*05$KM_IoO|_JJJi$4{-^pqJrRVEN8OXkKS2_4N-^ z&)%P9_G|n5UrXyZ3hN<({R*^UdO-(Y<_7gpf_QKv9L%wORcF?Y+6pMh`~LQ0qV72%YR zOx1A+CqB^$s&)&dOMIn=e4?Y>8cIz)cuSd-AUzjxNrmPJ@rjNwN!JnSQ~3xF$-`PU zNg?}C{~{dA2_rpfqP)V9tN`*!PtoxN+eP&&;$wQ?AtH)^@I)YSZH zNA*z=Yose&iZvf4zm)W`ypc0e4{^0;mFz_Qh!TpRJn>b%QGnv^9KqMo zaC3_Xx5Lp~QLr8;iC_a9tyUDo$9_M_dPTt45Ge6rGaSW=f`{Ow zD0mo-iQo}9(pwzoG_NRl6v#aB$Aswbxe(N5&a&Wf&VUsKpB&b_qJY+vll>+9nr%Gn zq-7<)d5z_^(KynMMaov~*^iz6Un^Bt@RK*3>it{`)=8dN+eC z)rta&lv~R0_mJ2>ftlA! zKeeKu6P6?V1h#wCEAZ_7PTCs%jwpXPLd~8_9{IHS?T;EAB<-Kv!tH14DK-0<{S&Qu z{WX7QuqW2f1Sz(p&8-Ww#yrDX=>;ua>>i1NJrJ7ZfhX^-@1AqtySK6%^#toH)rx|y zNZ0IEAKL(L*TX$yuC51f$0kq1vFUmuGUE7!Ypx!7J2S>}m><##F4}Xers7Veq-H;JaM`q)anNj0NShc^L-C08}sRo z@IZtTkjLeJXKz$gFMnxNy~VLF(!=BY%8~mQzIx$ntW?*F%vUQ4a*-aszjwN9;$PbI z-=^MCAjO8L?+hM~df{=2`llHQ9bnyG=>9L0@2yr8oQU~p{ZOnZkmU3ggD0Rot(XX3 z??3PT&;EMQ$`2*9zdvZ#3FviHD+=2rY9f#ZMO3Qt=~cp_Va?{usj05 zP77L5KoP2N`z0@WD)+c6RrbzHM^71iaN9Z**~Y6_uVW63r|0tt<@{IbEVn?OFJyW^N{-2 z9{KQz*N**nVQJnV%F{4attc?>Q&idxF0ELi=M2GV@rtEE?leq#CPJ;BiMO`4 zef{Q|Q!}3pJ^%G^X~n~gbUm@R`=_qJ)SIE@*Ps2o#~df@{UKBIg4SG-wxR&dgCN6Q zTU9dqvHb9k75hl9p52e}+nD1-#0?tY3Xca*z|o8_ZUGAS5P4j8AhW ze~T&&A1l|oqH1anWr@U7brL!=5t3(l2I3tqpd)|eQC57UlN|90R}kgMvrKe_ zQ#u4)andkNLggVZKwNO)(9h%(ndB9p7*y^qs7E-JhvW!Xe8rF_WUrB~z$0W|qE|{f z%-98A?H#6c$?k-YMtLC(2Ze{|#2<@vvbVBhNS^2fNuO|nL`MiunDPv7^vSu14?swE z>5n+2(;IOIe`IIkQ+>lY$rB_#*)vQ}t2YTCBz_N95LCn}oF3wePx+>mGn78@$qp0| zLopTELB84(O!A@Rh+n_jm7HD@CJCpAdmrRe{tB0B%||IWp7b$acNXfCeN=gnuJGoR zDyNi!V~9_<;^PUX2QveM^Xf{(k>4;ogikf)(;~5hLi}t~U+uA{ z`s}U##1Hi!xWhQckL(eWqj8`)Lhl66z~CJZo`s_sL3<7;iQsuST1C)u+D$ydI)e5B zq{{CmF9IwIw(>k=1nm!;vBwD7OTdW-FT*iCg4PJs<-s;M76sekm>xlUg;OEJSB1=L zaKs4O4$d$lXs-h$9_)mph@iazCq==Va7+Ym!I4MM+QFXODa{eIw}H$Pe@BSE%Y~pe zQ+S0Y^%gqN+5d`eg!Y6$DnV)H|mOmk8V!6p9 zY1*|pB6+G_KN#O^Js6#IJy?0r7IElu;-|I4)n7hd&%9SU%^H8f)6DQ-z)ByA0A-;b zbY1$1y}ZtEAH8zoRU6DN%EL;g_9(T72-?PnPN+sl#sAJ4RPzaHgxdk@2SIT0)9fiC zcCfMiqpuEpBjjnZf6I=R9;Bb)cEo%RN2s-fw_w2z<*}seOp4j>NizF&#HIdo1m>re zXZ_Vn?)$H4KWZ0>=$Q3SF^Wi<^j<20MiDWsemM(Ys8@Tx^E;~#&7eS>@nma09u*+|#QBlgTWC*6F>xY6gC z5y`?DH-hrr()Glixg=kbJ?MeZ9_NA9;~9;UGI{&Rjb85`(+*c*Jp@QCN_&f}mad5S z{NmIjb_DIVG4k{ZJ$O#(iRtu4Xg*n|*xl2&5l*pm>SgfQG+oDPq?)4SwK_Jv--!K>AtTroH64a^*yYWl$3xA)t=lj>3U)>g|}oFJL|&D=J#}Ak0nLi z`lFs^x5jgZ+^xO~Xw;)&j&(cG^&*mol{Et}pMmN5ZOrMvQSFKEfqV&C{(Zken9k_* z{8C*uyw5(_J#2Jx(PON;o?mjjilB{3*K2$rlU4+5Wi;=QVuRfwC{GcwaKEN^t#c^kT|+uq`Nh&jHrG@_j01}SKnct+&SbKDAbrBa`63^l2;|v730>?bX?tVxP>#|` z^N5G|4&qMeNBWA4a#&9l0ik*;Ie9|-j^uIAmilk6k`UI~I{fFJDWSsWnz;I29kvV1 zus}=ujdLYJ?M5srKk=y_ke$Og*`YV4N9m~kg>*_c3`w3K@u^=CPWgm!(j!QGy3UAB zkoZGfqDZLPgXnvMrkaXOgzN|-xgiK6w1GR+zsR11n^Jt{5&Q`Tzj*L598C}ZCqPLA ze}dpcplQj|5wh~qlbSNaN@z|a7_2`Hvx5d@PBYD z3jPMibPxX*oC+C!DP;Z*NA&Q2#TkZ&{~th!2VcWcc=*48lcL~TI3|LB!jZlcm*4E+ z-wkA*_;*6|Ut9=klfNwZo-@G1?}6JCPQ{O&c+8ggnq_P{;jL~L?cD1gnZ0@V?<#Ne z{v(Tbm~Y+8YJThH-aPz8BadJHdG2fGlWwH+2R;1#cc!LZ_WXDA-&n(mkmdC;}%sPU@ncQNN|1du{Y~M9@~4^RIHB#r7g@aLwWf7VtQJ6#M|v6!#95LxVzF^x#5;r=SS@< zl6R*n4?lVAwfw{$i=^DtVZJ5VvA^EUcChc)be)QF!k&%+XpgZ7&HFffhpYI`FV;S^ zhP|T?ymzfq--#Q8dM1GHa4lW1Zq3{T+otQmJ01q1;An(eJ;d9m8s=^B=H7P`Ga@Fb zGnI#bM7o}gn0#>W9iKPd%P8_A#9-xL*u$^2L*t_-{+4AOdDXdnkH3pez;vZv3)hsx z^ZZauXBa{)KM^sZ?+4LyiOg@TbwAVW#Z}cFnc^~6%ELbq?V`1x_%2pWhp*nb=ec)6D-BLg+arVO2->(m^{r}VzqYrF+28F~?3nG> z*$o_^#Di9FG(F(0fx^8M`vEKp+Q3nGDWnH{f6nA<^yzk5gC6j#b6jbf5(!JsmM}RR z=|d${-^n6R6M5Lep06&K+>60C$ z7XQMmJR?5QG2i+#kx%8Aj5x^=PJE&xgc+$k!$^u#ia6PyJfBn#)Jyk_qMqX83F#BW zehg0trX-b*_{fDBkUT>^%rp#KBF#tQC&{DUOUf4#j^!vNyCI%DP0CTbs5RtR&NL7E z7?cm!gZdNksoXBgJNU~+oa#+`X|NkERC$EDGB4JfsJ^9cS#( zgWVoD@!$|RrhBlnfVw<56plqf2RNpCun*%@$grc3IUJ7Y!S2Kvh6noypu~gDa1_(Lbo8-0pnjW8WDcgPGgP(j7dy0+2bOlJ-KOyzd_1r?`!KN9emS0|M@xUdNV)* zKj^_;U4PriP4BzcMIC( z-OziITN+8QU-PJyA4+Dwh9;~>?MWVDvz;2$nk)VCz8lpIOQE$>>9udZ%F3U(eO~nDCs|v)fl_BH5B9OB zr?JI%fNuKhih|PIw;Az;7|bV*LiwZ9^Al^T`y77jMVC~(XujV(Y5(@5wwSI2C-3Vm zPh#zN)3|~!?mv4+=*h}{4YyWr$kP6Lpa_~QXJ6&Pj!1%vumUPPnJy0mecKR<9NI@_ z%yzfmZEe=?hrb&&e$z427RMfByRaSt)GSsWY`WiQ>B5WW*0PLUhu1{@`Lg=%kI+j_ zw;p%9>-i<8s5R>3aSeOW=!x2Fhm+M?o{q!x%yBuT_Gr=lN{Y3#9l9tFHhG9OyNGsQ z@yxf4#a-WI<~Z0T&4aD!iSJFxa)rl1`?YpjdORuXc4(Z8hoJzx)dUv>J7HC)yE zg8CcoV)Jx8*hA45aauv4iHFQyy&cNNedD->osP2m2KbT6(;S*7eic2h{AZBeVb6=NFX? zfX6cB!8SbUN(Map9|heTx~yf}^$e%4Qft&rJCkAuH+j{^Cw%biee5zKahP1BJlGV0 z(Cn6Kzx8RB>{_<#w2?QkvvobELcQe)Ct~_{s>EA+==&u{8hZBMFQIv~R6oUaF`W?z zwRTE3a3Ao%ahyd$7%M!hU6mRu0I)eqLwV(|%ks=kr6-)~JW)FUcdW>ah5sfQ{7~e*C4@ z?e6M&JAaw>mM5+LA_B2fyruc&pK1PSfBl*Eble~U1&;^a;Aln!vVp?AKHUKp1wG&> zygo7_(33MEB9KMh_M$be=r|1+0Z7f4IIJa+^tlq!_aPbx?vtgE;ZYBi}nD7eSo(8GFH<(2wK^5}Q1c=&@C6#)%%53)6{DRQ-s*S4av3U3o%wCNe>?OJ^z9>Gx^DiYn zm1ii*JNQ$0h)#Uv8Am?#CrXdx2>uYiOs12w3e&^usk9RDYzgZ<#L+Hlj|bW56y!Vj z%SN2|M5l5RpZWvQ!?@DJ6XJ*3TYYbZ7AI{@qrIHs3{XBR8dOB*SH^;VT#qmBD38XY@gQ0j3vz3Mibc_2 zY01La(%g~J+KRG5Nyv{aomO3WRxIe=SC^>`PKHG4qP5<}6~&z)sgihI6;HgxTUrwf zYN7#DI35dXgZ`p41K{Y@CW3(gbG%`RV30UD+8gfW@;*vQ@0k8<7Zx zpui|E&l~NHnHQWQ2;;o*z@Lx^h9W=T%PZh){xPha0&lWc;7tLE!bET?N)&Mk$YD6g z;of#Eu%{;ihU}S8g4UeRZ|^zzt@F!)Uz`Z|dV?G=-f6&Z#rY#qpY!J?f>Fpn-OE!C z3S4Y7rz^@hmICX*50pw1!5CDUk7eRh5y*4$C;I`vJQ0jT{(?}Vi24;R_c~AhbDB!Ozg? delta 3859 zcmb_fdu$ZP8NaoC7)<=cPfD;U7@tiGE`b;@09d>KKX;ov+_3^KtG zKMO995!W5GJfjJjm2uJ;2dx-G-2{D(TRbGt<4FxqtCQxmjaC-gJ;Di8GZkRWutrRRpaL2pSXd1EzrAh+$4>nS_IShg`>| zOgwJcCR$I>hC=Pfpf}>nL@Su_ehnv8+tILtf&_)QR+cAk+_Dnrap*mf5l-j{&9>G3 zW;_NC>LV!36?xv{j4%%g+6e4a_gE*B5?lQQZ3+nUlxo@z3xi`}8x0V&xsc5k#->6z zi?vkDMEce-6bVF>Z67ISMidCE8g>jXn?ZuMdA7EL(Gc{CgvMA^qwOLKQ(7X!GBiAf zo(PDjm?;Na>1nj%p5{(^tF(S%grHr8V4r6$0JD2CFeFs9jANmwTQF8^C3;FsZNy^r z1<5R_9Z(H3?Eo4jXtxK(Gcv}(m?wMaXxYLiRD$*vN;T$l2^WtCA|(-nq1CWpCgUm9 z){<#fVQRwECXh~$QD|l|GliQ?(AUe_7sm(!F6Q+*k+3ue#i4hAEyR!O?aRH{tTfOL ze|^g&L5V`Fq$HT4BmTvUErQa;Q0P7=Ouyw{vfC!e;d=QR$w2RMH%pc^uG+eVO*BQ& z{vxFVypY&Wn2C2`8d~nV`v>)JY*;|wpVgc_B-B*?06Yz?qRiM_6?dJ$Vo|qzL%s=L zghfI}O=I^SXQ5NxJh65SXJA>hip51Lsq(TOhVG(mhMDCL&6l7{RFf(yAAR{a_z8L@ z0BeR|eZ&XB6VNvV1Ca_CgzYdg1fxTsvW)61J+XTkm>dG@-pW1>Q|vzRN0>Y3J;Fx_ zIx3z4=|I->B*zFk?z-k(sS^Z!B>)4knVq{0Mqn3=!fsGuFX&)E3;-q}0T!ge1_!2K zf5yF~DR#wE?1}p-9AeM**9iK0z~zy&3^UBY9PIWON>hxAs#_U$d43xyrM*>)lu|lY z_0P58^NJ{FpRYuY-kL98D?byw^ybgs+5NNY!GlsRhMPc#2RfB{3g>%jCJH5OMu?tk z>uD;DD3Ow5*w5hL@`-S_&}lR1@_#hdg+p@Xj%-2SYwwWse;?l-Ve~uc$kNMx1vuU2 zj<(9uBe<1g{qY2&OlvkY(z~b2TW$txuAB~k`{a$_VF4*Wl3p{dc%`R0UL?`!o(TO{bNHxz?B0V_wr-FgR>NW2!y!xQv(!YGSioR=f)mR=IK3*|HRCiW***A;a zxMJpp8&mp z^D!UrD*C;2S8+w`HR!67-Cp326fxc_1Mlk#ahQ9*zL1#b<;;Wf=R6Nmv5s01_@|%q z0v~!_cyZ>FdhzGiU%It@*^S_#dq;X+RM!{o&4hXVRL2VUdSiT(xk9O;=T>%Aj~5Ehn)LiHy1hdBDF@9zBWKOrOoP%i zXPxTeRc2f+Yfq`>5G4;qQ`c1RhxzU&+2sW@A)$KGv5p1DLMN3U9NdM zxjyU38R_819_jf6CI#t>AA6(^+;tynj(&d${Fjn;UqZK%jr`$!V&wMJDtXrJMXdjK zDTh3)+)Is0uTq!$Q=^hBZwB_C0z8t_nv|p2I)Wc%sO;}P!98NK6hza^Iy}6FZ^&jD z1_@p(6}!e1lz_ zg-osz9F%gehs7Zw7i}!)1V1k2UbBk(q&;hMAx3am%8Pr|+#Aixg0+(b_e-(tPw^&U sU9#;?DKobS9+2|l{xsLtqAX~&3Em>bqA!Ihg15>I)`R9oTa-Qj0&=7JxBvhE From a67444b16f90cda5545beff3d8448e8dd3aa3b89 Mon Sep 17 00:00:00 2001 From: Zhuofan Xie Date: Wed, 4 Aug 2021 12:49:09 -0400 Subject: [PATCH 41/42] Updated documentations and author list --- AUTHORS.rst | 1 + tutorials/DataTracer Quickstart.ipynb | 52 ++++++++++++++------------ tutorials/Introducing DataTracer.ipynb | 18 ++------- 3 files changed, 33 insertions(+), 38 deletions(-) diff --git a/AUTHORS.rst b/AUTHORS.rst index 741c4f9..6bc762c 100644 --- a/AUTHORS.rst +++ b/AUTHORS.rst @@ -6,3 +6,4 @@ Credits * Felipe Hofmann * Kevin Alex Zhang * Carles Sala +* Zhuofan Xie diff --git a/tutorials/DataTracer Quickstart.ipynb b/tutorials/DataTracer Quickstart.ipynb index 0bacd37..aff0311 100644 --- a/tutorials/DataTracer Quickstart.ipynb +++ b/tutorials/DataTracer Quickstart.ipynb @@ -85,7 +85,7 @@ { "data": { "text/plain": [ - "dict_keys(['mutagenesis', 'Chess', 'posts', 'classicmodels', 'university', 'Bupa', 'trains', 'SameGen', 'NBA', 'pubs'])" + "dict_keys(['posts', 'NBA', 'university', 'pubs', 'Chess', 'classicmodels', 'mutagenesis', 'Bupa', 'trains', 'SameGen'])" ] }, "execution_count": 3, @@ -426,6 +426,7 @@ "['datatracer.column_map.basic',\n", " 'datatracer.foreign_key.basic',\n", " 'datatracer.foreign_key.standard',\n", + " 'datatracer.how_lineage.basic',\n", " 'datatracer.metadata.update_metadata_column_map',\n", " 'datatracer.metadata.update_metadata_foreign_keys',\n", " 'datatracer.metadata.update_metadata_primary_keys',\n", @@ -490,7 +491,7 @@ "name": "stderr", "output_type": "stream", "text": [ - "Extracting features from SameGen: 100%|██████████| 9/9 [00:00<00:00, 145.54it/s]\n" + "Extracting features from SameGen: 100%|██████████| 9/9 [00:00<00:00, 43.14it/s] \n" ] } ], @@ -530,14 +531,14 @@ { "data": { "text/plain": [ - "{'customers': 'customerNumber',\n", - " 'employees': 'employeeNumber',\n", - " 'offices': 'officeCode',\n", - " 'orderdetails': 'orderNumber',\n", - " 'orders': 'orderNumber',\n", - " 'payments': 'customerNumber',\n", - " 'productlines': 'productLine',\n", - " 'products': 'productCode'}" + "{'customers': ['customerNumber'],\n", + " 'employees': ['employeeNumber'],\n", + " 'offices': ['officeCode'],\n", + " 'orderdetails': ['orderNumber'],\n", + " 'orders': ['orderNumber'],\n", + " 'payments': ['customerNumber'],\n", + " 'productlines': ['productLine'],\n", + " 'products': ['productCode']}" ] }, "execution_count": 14, @@ -569,7 +570,8 @@ "name": "stderr", "output_type": "stream", "text": [ - "Extracting features from SameGen: 100%|██████████| 9/9 [00:01<00:00, 8.46it/s] \n" + "Extracting features from SameGen: 100%|██████████| 9/9 [00:00<00:00, 46.95it/s] \n", + "Extracting features from SameGen: 100%|██████████| 9/9 [00:00<00:00, 20.83it/s] \n" ] } ], @@ -595,7 +597,11 @@ { "data": { "text/plain": [ - "[{'table': 'payments',\n", + "[{'table': 'products',\n", + " 'field': 'productLine',\n", + " 'ref_table': 'productlines',\n", + " 'ref_field': 'productLine'},\n", + " {'table': 'payments',\n", " 'field': 'customerNumber',\n", " 'ref_table': 'customers',\n", " 'ref_field': 'customerNumber'},\n", @@ -655,7 +661,8 @@ "name": "stderr", "output_type": "stream", "text": [ - "Extracting features from SameGen: 100%|██████████| 9/9 [00:01<00:00, 8.10it/s] \n" + "Extracting features from SameGen: 100%|██████████| 9/9 [00:00<00:00, 43.29it/s] \n", + "Extracting features from SameGen: 100%|██████████| 9/9 [00:00<00:00, 21.37it/s] \n" ] } ], @@ -685,9 +692,9 @@ { "data": { "text/plain": [ - "{('orderdetails', 'orderNumber'): 0.3831749750863929,\n", - " ('orderdetails', 'priceEach'): 0.4397562935716433,\n", - " ('orderdetails', 'orderLineNumber'): 0.1770687313419638}" + "{('orderdetails', 'orderNumber'): 0.3867085505946312,\n", + " ('orderdetails', 'priceEach'): 0.4226638356684435,\n", + " ('orderdetails', 'orderLineNumber'): 0.19062761373692533}" ] }, "execution_count": 18, @@ -751,10 +758,7 @@ { "data": { "text/plain": [ - "{('orderdetails', 'orderNumber'): 0.00019290156505023432,\n", - " ('orderdetails', 'priceEach'): 0.00014624354192835704,\n", - " ('orderdetails', 'orderLineNumber'): 9.105599151739842e-05,\n", - " ('orderdetails', 'quantityOrdered_x2'): 0.9995697989015039}" + "{('orderdetails', 'quantityOrdered_x2'): 0.9996667966308828}" ] }, "execution_count": 21, @@ -805,7 +809,7 @@ }, { "cell_type": "code", - "execution_count": 23, + "execution_count": 22, "metadata": {}, "outputs": [], "source": [ @@ -829,7 +833,7 @@ }, { "cell_type": "code", - "execution_count": 24, + "execution_count": 23, "metadata": {}, "outputs": [ { @@ -843,7 +847,7 @@ " {'table': 'orderdetails', 'field': 'orderLineNumber'}]}]" ] }, - "execution_count": 24, + "execution_count": 23, "metadata": {}, "output_type": "execute_result" } @@ -869,7 +873,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.6.9" + "version": "3.7.4" } }, "nbformat": 4, diff --git a/tutorials/Introducing DataTracer.ipynb b/tutorials/Introducing DataTracer.ipynb index 9c92a28..c2b5717 100644 --- a/tutorials/Introducing DataTracer.ipynb +++ b/tutorials/Introducing DataTracer.ipynb @@ -265,7 +265,7 @@ { "data": { "text/plain": [ - "{'users': 'id', 'posts': 'id'}" + "{'users': ['id'], 'posts': ['id', 'uid']}" ] }, "execution_count": 5, @@ -338,12 +338,7 @@ { "data": { "text/plain": [ - "{('users', 'id'): 5.046102180418218e-07,\n", - " ('users', 'birthyear'): 0.9999978458447893,\n", - " ('users', 'height'): 0.0,\n", - " ('users', 'nb_posts'): 0.0,\n", - " ('posts', 'uid'): 0.0,\n", - " ('posts', 'id'): 7.225373384175289e-07}" + "{('users', 'birthyear'): 0.9999985726528566}" ] }, "execution_count": 7, @@ -379,12 +374,7 @@ { "data": { "text/plain": [ - "{('users', 'id'): 0.0,\n", - " ('users', 'age'): 0.0,\n", - " ('users', 'birthyear'): 0.0,\n", - " ('users', 'height'): 0.0,\n", - " ('posts', 'uid'): 0.5263822157542845,\n", - " ('posts', 'id'): 0.46999603701224835}" + "{('posts', 'uid'): 1.0}" ] }, "execution_count": 8, @@ -421,7 +411,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.6.9" + "version": "3.7.4" } }, "nbformat": 4, From 718f58ca87f297e7541c910a53ca8dde8ed7b66e Mon Sep 17 00:00:00 2001 From: Zhuofan Xie Date: Wed, 4 Aug 2021 12:52:34 -0400 Subject: [PATCH 42/42] Add a placeholder how lineage test --- tests/test_how_lineage.py | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 tests/test_how_lineage.py diff --git a/tests/test_how_lineage.py b/tests/test_how_lineage.py new file mode 100644 index 0000000..6f36910 --- /dev/null +++ b/tests/test_how_lineage.py @@ -0,0 +1,9 @@ +from unittest import TestCase + +from datatracer.how_lineage import HowLineageSolver + + +class TestColumnMap(TestCase): + + def test_A(self): + HowLineageSolver()