# -*- coding: utf-8 -*- # Copyright (c) 2019 Uber Technologies, Inc. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. # ============================================================================== # This file is copied and adapted from # https://github.com/ludwig-ai/ludwig/blob/master/tests/integration_tests/utils.py import multiprocessing import os import random import shutil import sys import traceback import unittest import uuid from distutils.util import strtobool from typing import Any, Dict, List, Optional import cloudpickle import numpy as np import pandas as pd from ludwig.api import LudwigModel from ludwig.backend import LocalBackend from ludwig.constants import COLUMN, NAME, PROC_COLUMN, VECTOR from ludwig.data.dataset_synthesizer import DATETIME_FORMATS, build_synthetic_dataset from ludwig.experiment import experiment_cli from ludwig.features.feature_utils import compute_feature_hash from ludwig.utils.data_utils import read_csv, replace_file_extension ENCODERS = [ "embed", "rnn", "parallel_cnn", "cnnrnn", "stacked_parallel_cnn", "stacked_cnn", "transformer", ] HF_ENCODERS_SHORT = ["distilbert"] HF_ENCODERS = [ "bert", "gpt", "gpt2", # "transformer_xl", "xlnet", "xlm", "roberta", "distilbert", "ctrl", "camembert", "albert", "t5", "xlmroberta", "longformer", "flaubert", "electra", "mt5", ] class LocalTestBackend(LocalBackend): @property def supports_multiprocessing(self): return False def parse_flag_from_env(key, default=False): try: value = os.environ[key] except KeyError: # KEY isn't set, default to `default`. _value = default else: # KEY is set, convert it to True or False. try: _value = strtobool(value) except ValueError: # More values are supported, but let's keep the message simple. raise ValueError("If set, {} must be yes or no.".format(key)) return _value _run_slow_tests = parse_flag_from_env("RUN_SLOW", default=False) def slow(test_case): """ Decorator marking a test as slow. Slow tests are skipped by default. Set the RUN_SLOW environment variable to a truth value to run them. """ if not _run_slow_tests: test_case = unittest.skip("Skipping: this test is too slow")(test_case) return test_case def generate_data( input_features: List[Dict[str, Any]], output_features: List[Dict[str, Any]], filename: str = "test_csv.csv", num_examples: int = 25, ) -> str: """Generate synthetic data based on input/output feature specs. Args: input_features: Input feature schema. output_features: Output feature schema. filename: Path to the file where data is stored. num_examples: Number of examples to generate. Returns: The path to the file where the generated data was written. """ features = input_features + output_features df = build_synthetic_dataset(num_examples, features) data = [next(df) for _ in range(num_examples)] dataframe = pd.DataFrame(data[1:], columns=data[0]) dataframe.to_csv(filename, index=False) return filename def random_string(length=5): return uuid.uuid4().hex[:length].upper() def numerical_feature(normalization=None, **kwargs): feature = { "name": "num_" + random_string(), "type": "number", "preprocessing": {"normalization": normalization}, } feature.update(kwargs) feature[COLUMN] = feature[NAME] feature[PROC_COLUMN] = compute_feature_hash(feature) return feature def category_feature(**kwargs): feature = { "type": "category", "name": "category_" + random_string(), "vocab_size": 10, "embedding_size": 5, } feature.update(kwargs) feature[COLUMN] = feature[NAME] feature[PROC_COLUMN] = compute_feature_hash(feature) return feature def text_feature(**kwargs): feature = { "name": "text_" + random_string(), "type": "text", "reduce_input": None, "vocab_size": 5, "min_len": 7, "max_len": 7, "embedding_size": 8, "state_size": 8, } feature.update(kwargs) feature[COLUMN] = feature[NAME] feature[PROC_COLUMN] = compute_feature_hash(feature) return feature def set_feature(**kwargs): feature = { "type": "set", "name": "set_" + random_string(), "vocab_size": 10, "max_len": 5, "embedding_size": 5, } feature.update(kwargs) feature[COLUMN] = feature[NAME] feature[PROC_COLUMN] = compute_feature_hash(feature) return feature def sequence_feature(**kwargs): feature = { "type": "sequence", "name": "sequence_" + random_string(), "vocab_size": 10, "max_len": 7, "encoder": "embed", "embedding_size": 8, "fc_size": 8, "state_size": 8, "num_filters": 8, "hidden_size": 8, } feature.update(kwargs) feature[COLUMN] = feature[NAME] feature[PROC_COLUMN] = compute_feature_hash(feature) return feature def image_feature(folder, **kwargs): feature = { "type": "image", "name": "image_" + random_string(), "encoder": "resnet", "preprocessing": { "in_memory": True, "height": 12, "width": 12, "num_channels": 3, }, "resnet_size": 8, "destination_folder": folder, "fc_size": 8, "num_filters": 8, } feature.update(kwargs) feature[COLUMN] = feature[NAME] feature[PROC_COLUMN] = compute_feature_hash(feature) return feature def audio_feature(folder, **kwargs): feature = { "name": "audio_" + random_string(), "type": "audio", "preprocessing": { "audio_feature": { "type": "fbank", "window_length_in_s": 0.04, "window_shift_in_s": 0.02, "num_filter_bands": 80, }, "audio_file_length_limit_in_s": 3.0, }, "encoder": "stacked_cnn", "should_embed": False, "conv_layers": [ { "filter_size": 400, "pool_size": 16, "num_filters": 32, "regularize": "false", }, { "filter_size": 40, "pool_size": 10, "num_filters": 64, "regularize": "false", }, ], "fc_size": 256, "destination_folder": folder, } feature.update(kwargs) feature[COLUMN] = feature[NAME] feature[PROC_COLUMN] = compute_feature_hash(feature) return feature def timeseries_feature(**kwargs): feature = { "name": "timeseries_" + random_string(), "type": "timeseries", "max_len": 7, } feature.update(kwargs) feature[COLUMN] = feature[NAME] feature[PROC_COLUMN] = compute_feature_hash(feature) return feature def binary_feature(**kwargs): feature = {"name": "binary_" + random_string(), "type": "binary"} feature.update(kwargs) feature[COLUMN] = feature[NAME] feature[PROC_COLUMN] = compute_feature_hash(feature) return feature def bag_feature(**kwargs): feature = { "name": "bag_" + random_string(), "type": "bag", "max_len": 5, "vocab_size": 10, "embedding_size": 5, } feature.update(kwargs) feature[COLUMN] = feature[NAME] feature[PROC_COLUMN] = compute_feature_hash(feature) return feature def date_feature(**kwargs): feature = { "name": "date_" + random_string(), "type": "date", "preprocessing": { "datetime_format": random.choice(list(DATETIME_FORMATS.keys())) }, } feature.update(kwargs) feature[COLUMN] = feature[NAME] feature[PROC_COLUMN] = compute_feature_hash(feature) return feature def h3_feature(**kwargs): feature = {"name": "h3_" + random_string(), "type": "h3"} feature.update(kwargs) feature[COLUMN] = feature[NAME] feature[PROC_COLUMN] = compute_feature_hash(feature) return feature def vector_feature(**kwargs): feature = {"type": VECTOR, "vector_size": 5, "name": "vector_" + random_string()} feature.update(kwargs) feature[COLUMN] = feature[NAME] feature[PROC_COLUMN] = compute_feature_hash(feature) return feature def run_experiment( input_features: Optional[List[Dict[str, Any]]], output_features: Optional[List[Dict[str, Any]]], skip_save_processed_input: bool = True, config: Optional[Dict[str, Any]] = None, backend: Optional[LocalBackend] = None, **kwargs, ) -> None: """Run an experiment and clean up artifacts saved to disk. Args: input_features: List of input feature dictionaries. output_features: List of output feature dictionaries. skip_save_processed_input: Whether to skip persisting processed input to disk. config: Optional Ludwig configuration dictionary. If unset, a default config is constructed from ``input_features`` and ``output_features``. backend: Optional Ludwig backend to use. Defaults to ``LocalTestBackend()``. **kwargs: Extra keyword arguments forwarded to the underlying ``experiment_cli`` call. """ if input_features is not None and output_features is not None: # This if is necessary so that the caller can call with # config_file (and not config) config = { "input_features": input_features, "output_features": output_features, "combiner": {"type": "concat", "fc_size": 14}, "training": {"epochs": 2}, } args = { "config": config, "backend": backend or LocalTestBackend(), "skip_save_training_description": True, "skip_save_training_statistics": True, "skip_save_processed_input": skip_save_processed_input, "skip_save_progress": True, "skip_save_unprocessed_output": True, "skip_save_model": True, "skip_save_predictions": True, "skip_save_eval_stats": True, "skip_collect_predictions": True, "skip_collect_overall_stats": True, "skip_save_log": True, } args.update(kwargs) _, _, _, _, exp_dir_name = experiment_cli(**args) shutil.rmtree(exp_dir_name, ignore_errors=True) def generate_output_features_with_dependencies(main_feature, dependencies): # helper function to generate multiple output features specifications # with dependencies, support for 'test_experiment_multiple_seq_seq` unit # test # Parameters: # main_feature: feature identifier, valid values 'feat1', 'feat2', 'feat3' # dependencies: list of dependencies for 'main_feature', do not li # Example: # generate_output_features_with_dependencies('feat2', ['feat1', 'feat3']) output_features = [ category_feature(vocab_size=2, reduce_input="sum"), sequence_feature(vocab_size=10, max_len=5), numerical_feature(), ] # value portion of dictionary is a tuple: (position, feature_name) # position: location of output feature in the above output_features list # feature_name: Ludwig generated feature name feature_names = { "feat1": (0, output_features[0]["name"]), "feat2": (1, output_features[1]["name"]), "feat3": (2, output_features[2]["name"]), } # generate list of dependencies with real feature names generated_dependencies = [feature_names[feat_name][1] for feat_name in dependencies] # specify dependencies for the main_feature output_features[feature_names[main_feature][0]][ "dependencies" ] = generated_dependencies return output_features def _subproc_wrapper(fn, queue, *args, **kwargs): fn = cloudpickle.loads(fn) try: results = fn(*args, **kwargs) except Exception as e: traceback.print_exc(file=sys.stderr) results = e queue.put(results) def spawn(fn): def wrapped_fn(*args, **kwargs): ctx = multiprocessing.get_context("spawn") queue = ctx.Queue() p = ctx.Process( target=_subproc_wrapper, args=(cloudpickle.dumps(fn), queue, *args), kwargs=kwargs, ) p.start() p.join() results = queue.get() if isinstance(results, Exception): raise RuntimeError( f"Spawned subprocess raised {type(results).__name__}, " f"check log output above for stack trace." ) return results return wrapped_fn def run_api_experiment( input_features: List[Dict[str, Any]], output_features: List[Dict[str, Any]], data_csv: str, ) -> None: """Run an experiment through Ludwig's Python API. Args: input_features: Input schema. output_features: Output schema. data_csv: Path to data. """ config = { "input_features": input_features, "output_features": output_features, "combiner": {"type": "concat", "fc_size": 14}, "training": {"epochs": 2}, } model = LudwigModel(config) output_dir = None try: # Training with csv _, _, output_dir = model.train( dataset=data_csv, skip_save_processed_input=True, skip_save_progress=True, skip_save_unprocessed_output=True, ) model.predict(dataset=data_csv) model_dir = os.path.join(output_dir, "model") loaded_model = LudwigModel.load(model_dir) # Necessary before call to get_weights() to materialize the weights loaded_model.predict(dataset=data_csv) model_weights = model.model.get_weights() loaded_weights = loaded_model.model.get_weights() for model_weight, loaded_weight in zip(model_weights, loaded_weights): assert np.allclose(model_weight, loaded_weight) finally: # Remove results/intermediate data saved to disk shutil.rmtree(output_dir, ignore_errors=True) try: # Training with dataframe data_df = read_csv(data_csv) _, _, output_dir = model.train( dataset=data_df, skip_save_processed_input=True, skip_save_progress=True, skip_save_unprocessed_output=True, ) model.predict(dataset=data_df) finally: shutil.rmtree(output_dir, ignore_errors=True) def create_data_set_to_use(data_format, raw_data): # helper function for generating training and test data with specified # format handles all data formats except for hdf5 # assumes raw_data is a csv dataset generated by # tests.integration_tests.utils.generate_data() function # support for writing to a fwf dataset based on this stackoverflow posting: # https://stackoverflow.com/questions/16490261/python-pandas-write-dataframe-to-fixed-width-file-to-fwf from ray._private.thirdparty.tabulate.tabulate import tabulate def to_fwf(df, fname): content = tabulate(df.values.tolist(), list(df.columns), tablefmt="plain") open(fname, "w").write(content) pd.DataFrame.to_fwf = to_fwf dataset_to_use = None if data_format == "csv": dataset_to_use = raw_data elif data_format in {"df", "dict"}: dataset_to_use = pd.read_csv(raw_data) if data_format == "dict": dataset_to_use = dataset_to_use.to_dict(orient="list") elif data_format == "excel": dataset_to_use = replace_file_extension(raw_data, "xlsx") pd.read_csv(raw_data).to_excel(dataset_to_use, index=False) elif data_format == "excel_xls": dataset_to_use = replace_file_extension(raw_data, "xls") pd.read_csv(raw_data).to_excel(dataset_to_use, index=False) elif data_format == "feather": dataset_to_use = replace_file_extension(raw_data, "feather") pd.read_csv(raw_data).to_feather(dataset_to_use) elif data_format == "fwf": dataset_to_use = replace_file_extension(raw_data, "fwf") pd.read_csv(raw_data).to_fwf(dataset_to_use) elif data_format == "html": dataset_to_use = replace_file_extension(raw_data, "html") pd.read_csv(raw_data).to_html(dataset_to_use, index=False) elif data_format == "json": dataset_to_use = replace_file_extension(raw_data, "json") pd.read_csv(raw_data).to_json(dataset_to_use, orient="records") elif data_format == "jsonl": dataset_to_use = replace_file_extension(raw_data, "jsonl") pd.read_csv(raw_data).to_json(dataset_to_use, orient="records", lines=True) elif data_format == "parquet": dataset_to_use = replace_file_extension(raw_data, "parquet") pd.read_csv(raw_data).to_parquet(dataset_to_use, index=False) elif data_format == "pickle": dataset_to_use = replace_file_extension(raw_data, "pickle") pd.read_csv(raw_data).to_pickle(dataset_to_use) elif data_format == "stata": dataset_to_use = replace_file_extension(raw_data, "stata") pd.read_csv(raw_data).to_stata(dataset_to_use) elif data_format == "tsv": dataset_to_use = replace_file_extension(raw_data, "tsv") pd.read_csv(raw_data).to_csv(dataset_to_use, sep="\t", index=False) else: ValueError("'{}' is an unrecognized data format".format(data_format)) return dataset_to_use def train_with_backend( backend, config, dataset=None, training_set=None, validation_set=None, test_set=None, predict=True, evaluate=True, ): model = LudwigModel(config, backend=backend) output_dir = None ret = False try: _, _, output_dir = model.train( dataset=dataset, training_set=training_set, validation_set=validation_set, test_set=test_set, skip_save_processed_input=True, skip_save_progress=True, skip_save_unprocessed_output=True, ) if dataset is None: dataset = training_set if predict: preds, _ = model.predict(dataset=dataset) assert backend.df_engine.compute(preds) is not None if evaluate: _, eval_preds, _ = model.evaluate(dataset=dataset) assert backend.df_engine.compute(eval_preds) is not None ret = True finally: # Remove results/intermediate data saved to disk shutil.rmtree(output_dir, ignore_errors=True) return ret